From afae8277545dc8ddb4f0c10debc4b21175a0fbb4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Aug 2020 10:18:29 +0900 Subject: [PATCH 001/865] Make app.shortcut compatible with Bolt for JS (#40) --- scripts/run_tests.sh | 17 +++++++++++++---- slack_bolt/listener_matcher/builtins.py | 14 ++++++++++++-- tests/async_scenario_tests/test_shortcut.py | 15 +++++++++++++-- tests/scenario_tests/test_shortcut.py | 14 ++++++++++++-- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 0a49d4e42..9d75ed908 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -6,7 +6,16 @@ script_dir=`dirname $0` cd ${script_dir}/.. -pip install -e ".[testing]" && \ - black slack_bolt/ tests/ && \ - pytest $1 && \ - pytype slack_bolt/ \ No newline at end of file +test_target="$1" + +if [[ $test_target != "" ]] +then + pip install -e ".[testing]" && \ + black slack_bolt/ tests/ && \ + pytest $1 +else + pip install -e ".[testing]" && \ + black slack_bolt/ tests/ && \ + pytest && \ + pytype slack_bolt/ +fi diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 2794e9050..40f1c7a66 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -127,8 +127,18 @@ def func(payload: dict) -> bool: return ( payload and "callback_id" in payload - and payload["type"] == "shortcut" - and _matches(callback_id, payload["callback_id"]) + and ( + ( + # global shortcut + _is_expected_type(payload, "shortcut") + and _matches(callback_id, payload["callback_id"]) + ) + or ( + # message shortcut + _is_expected_type(payload, "message_action") + and _matches(callback_id, payload["callback_id"]) + ) + ) ) return build_listener_matcher(func, asyncio) diff --git a/tests/async_scenario_tests/test_shortcut.py b/tests/async_scenario_tests/test_shortcut.py index ac2fe3eb1..9859729a0 100644 --- a/tests/async_scenario_tests/test_shortcut.py +++ b/tests/async_scenario_tests/test_shortcut.py @@ -56,8 +56,9 @@ async def test_mock_server_is_running(self): resp = await self.web_client.api_test() assert resp != None + # NOTE: This is a compatible behavior with Bolt for JS @pytest.mark.asyncio - async def test_success_global(self): + async def test_success_both_global_and_message(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) app.shortcut("test-shortcut")(simple_listener) @@ -68,9 +69,19 @@ async def test_success_global(self): request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) - assert response.status == 404 + assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 2 + @pytest.mark.asyncio + async def test_success_global(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.shortcut("test-shortcut")(simple_listener) + + request = self.build_valid_request(global_shortcut_raw_body) + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + @pytest.mark.asyncio async def test_success_global_2(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) diff --git a/tests/scenario_tests/test_shortcut.py b/tests/scenario_tests/test_shortcut.py index c687937bd..3ff2198c4 100644 --- a/tests/scenario_tests/test_shortcut.py +++ b/tests/scenario_tests/test_shortcut.py @@ -49,7 +49,8 @@ def test_mock_server_is_running(self): resp = self.web_client.api_test() assert resp != None - def test_success_global(self): + # NOTE: This is a compatible behavior with Bolt for JS + def test_success_both_global_and_message(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) app.shortcut("test-shortcut")(simple_listener) @@ -60,9 +61,18 @@ def test_success_global(self): request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) - assert response.status == 404 + assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 2 + def test_success_global(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + app.shortcut("test-shortcut")(simple_listener) + + request = self.build_valid_request(global_shortcut_raw_body) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + def test_success_global_2(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) app.global_shortcut("test-shortcut")(simple_listener) From 20862473fe1f2873ebee05c4aea7f08ed628fae9 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Aug 2020 10:25:16 +0900 Subject: [PATCH 002/865] Fix #37 by implementing context.matches (#41) --- slack_bolt/app/app.py | 14 +---- slack_bolt/app/async_app.py | 17 ++---- slack_bolt/context/base_context.py | 7 ++- .../async_message_listener_matches.py | 28 +++++++++ .../middleware/message_listener_matches.py | 24 ++++++++ tests/async_scenario_tests/test_message.py | 59 +++++++++++++++++++ tests/scenario_tests/test_message.py | 57 ++++++++++++++++++ 7 files changed, 182 insertions(+), 24 deletions(-) create mode 100644 slack_bolt/middleware/async_message_listener_matches.py create mode 100644 slack_bolt/middleware/message_listener_matches.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 0e1a397b0..efc95291d 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -28,6 +28,7 @@ IgnoringSelfEvents, CustomMiddleware, ) +from slack_bolt.middleware.message_listener_matches import MessageListenerMatches from slack_bolt.middleware.url_verification import UrlVerification from slack_bolt.oauth import OAuthFlow from slack_bolt.request import BoltRequest @@ -388,20 +389,11 @@ def message( middleware: Optional[List[Union[Callable, Middleware]]] = None, ): matchers = matchers if matchers else [] + middleware = middleware if middleware else [] def __call__(func): primary_matcher = builtin_matchers.event("message") - - def keyword_matcher(payload) -> bool: - text: Optional[str] = payload.get("event", {}).get("text", {}) - if text: - if isinstance(keyword, Pattern): - return keyword.match(text) # type: ignore - elif isinstance(keyword, str): - return keyword in text - return False - - matchers.insert(0, keyword_matcher) + middleware.append(MessageListenerMatches(keyword)) return self._register_listener( func, primary_matcher, matchers, middleware, True ) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 91d6eb702..2d0798143 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -33,6 +33,9 @@ AsyncMiddleware, AsyncCustomMiddleware, ) +from slack_bolt.middleware.async_message_listener_matches import ( + AsyncMessageListenerMatches, +) from slack_bolt.middleware.authorization.async_multi_teams_authorization import ( AsyncMultiTeamsAuthorization, ) @@ -418,21 +421,11 @@ def message( middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): matchers = matchers if matchers else [] + middleware = middleware if middleware else [] def __call__(func): primary_matcher = builtin_matchers.event("message", True) - - async def keyword_matcher(payload) -> bool: - text: Optional[str] = payload.get("event", {}).get("text", {}) - if text: - if isinstance(keyword, Pattern): - return keyword.match(text) # type: ignore - elif isinstance(keyword, str): - return keyword in text - return False - - matchers.insert(0, keyword_matcher) - + middleware.append(AsyncMessageListenerMatches(keyword)) return self._register_listener( func, primary_matcher, matchers, middleware, True ) diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index ab7c2d596..77a806105 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -1,5 +1,5 @@ from logging import Logger -from typing import Optional +from typing import Optional, Tuple from slack_bolt.auth import AuthorizationResult from slack_sdk import WebClient @@ -41,3 +41,8 @@ def channel_id(self) -> Optional[str]: @property def response_url(self) -> Optional[str]: return self.get("response_url", None) + + @property + def matches(self) -> Optional[Tuple]: + """Returns all the matched parts in message listener's regexp""" + return self.get("matches", None) diff --git a/slack_bolt/middleware/async_message_listener_matches.py b/slack_bolt/middleware/async_message_listener_matches.py new file mode 100644 index 000000000..9d95a5cc1 --- /dev/null +++ b/slack_bolt/middleware/async_message_listener_matches.py @@ -0,0 +1,28 @@ +import re +from typing import Callable, Awaitable, Union, Pattern + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from .async_middleware import AsyncMiddleware + + +class AsyncMessageListenerMatches(AsyncMiddleware): + def __init__(self, keyword: Union[str, Pattern]): + self.keyword = keyword + + async def async_process( + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + next: Callable[[], Awaitable[BoltResponse]], + ) -> BoltResponse: + text = req.payload.get("event", {}).get("text", "") + if text: + m = re.search(self.keyword, text) + if m is not None: + req.context["matches"] = m.groups() # tuple + return await next() + + # As the text doesn't match, skip running the listener + return resp diff --git a/slack_bolt/middleware/message_listener_matches.py b/slack_bolt/middleware/message_listener_matches.py new file mode 100644 index 000000000..a63e458e1 --- /dev/null +++ b/slack_bolt/middleware/message_listener_matches.py @@ -0,0 +1,24 @@ +import re +from typing import Callable, Pattern, Union + +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse +from .middleware import Middleware + + +class MessageListenerMatches(Middleware): # type: ignore + def __init__(self, keyword: Union[str, Pattern]): + self.keyword = keyword + + def process( + self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + ) -> BoltResponse: + text = req.payload.get("event", {}).get("text", "") + if text: + m = re.search(self.keyword, text) + if m is not None: + req.context["matches"] = m.groups() # tuple + return next() + + # As the text doesn't match, skip running the listener + return resp diff --git a/tests/async_scenario_tests/test_message.py b/tests/async_scenario_tests/test_message.py index d1c971cd0..e04cf02e8 100644 --- a/tests/async_scenario_tests/test_message.py +++ b/tests/async_scenario_tests/test_message.py @@ -51,6 +51,10 @@ def build_request(self) -> AsyncBoltRequest: timestamp, body = str(int(time())), json.dumps(message_payload) return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + def build_request2(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(message_payload2) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + @pytest.mark.asyncio async def test_string_keyword(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) @@ -63,6 +67,32 @@ async def test_string_keyword(self): await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 + @pytest.mark.asyncio + async def test_string_keyword_capturing(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.message("We've received ([0-9]+) messages from (.+)!")(verify_matches) + + request = self.build_request2() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_string_keyword_capturing2(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.message(re.compile("We've received ([0-9]+) messages from (.+)!"))( + verify_matches + ) + + request = self.build_request2() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + @pytest.mark.asyncio async def test_string_keyword_unmatched(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) @@ -134,3 +164,32 @@ async def test_regexp_keyword_unmatched(self): async def whats_up(payload, say): assert payload == message_payload await say("What's up?") + + +message_payload2 = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "a8744611-0210-4f85-9f15-5faf7fb225c8", + "type": "message", + "text": "We've received 103 messages from you!", + "user": "W111", + "ts": "1596183880.004200", + "team": "T111", + "channel": "C111", + "event_ts": "1596183880.004200", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1596183880, + "authed_users": ["W111"], +} + + +async def verify_matches(context, say): + assert context["matches"] == ("103", "you") + assert context.matches == ("103", "you") + await say("Thanks!") diff --git a/tests/scenario_tests/test_message.py b/tests/scenario_tests/test_message.py index 4f315c165..463c2621b 100644 --- a/tests/scenario_tests/test_message.py +++ b/tests/scenario_tests/test_message.py @@ -45,6 +45,10 @@ def build_request(self) -> BoltRequest: timestamp, body = str(int(time.time())), json.dumps(message_payload) return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + def build_request2(self) -> BoltRequest: + timestamp, body = str(int(time.time())), json.dumps(message_payload2) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + def test_string_keyword(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) app.message("Hello")(whats_up) @@ -56,6 +60,30 @@ def test_string_keyword(self): time.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 + def test_string_keyword_capturing(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + app.message("We've received ([0-9]+) messages from (.+)!")(verify_matches) + + request = self.build_request2() + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + time.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_string_keyword_capturing2(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + app.message(re.compile("We've received ([0-9]+) messages from (.+)!"))( + verify_matches + ) + + request = self.build_request2() + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + time.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + def test_string_keyword_unmatched(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) app.message("HELLO")(whats_up) @@ -124,3 +152,32 @@ def test_regexp_keyword_unmatched(self): def whats_up(payload, say): assert payload == message_payload say("What's up?") + + +message_payload2 = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "a8744611-0210-4f85-9f15-5faf7fb225c8", + "type": "message", + "text": "We've received 103 messages from you!", + "user": "W111", + "ts": "1596183880.004200", + "team": "T111", + "channel": "C111", + "event_ts": "1596183880.004200", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1596183880, + "authed_users": ["W111"], +} + + +def verify_matches(context, say): + assert context["matches"] == ("103", "you") + assert context.matches == ("103", "you") + say("Thanks!") From 6e4a6fc72c4c81b19a8a8fea52b3fc96a8a75a41 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Aug 2020 10:56:59 +0900 Subject: [PATCH 003/865] Turn on pip's new feature - use-feature=2020-resolver (#42) --- .github/maintainers_guide.md | 17 +++++++++++++++++ .travis.yml | 2 ++ 2 files changed, 19 insertions(+) diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index deb40fa94..367771f4d 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -39,6 +39,23 @@ $ python -m venv env_3.8.5 $ source env_3.8.5/bin/activate ``` +### Additional settings for pip + +pip 20.2 introduced a new flag to test the upcoming change: https://discuss.python.org/t/announcement-pip-20-2-release/4863/2 +Turn on the feature on your local machine for testing it. Just running the following command helps you turn it on. + +```bash +pip config set global.use-feature 2020-resolver +``` + +The following file should be generated. + +```yaml +# ~/.config/pip/pip.conf +[global] +use-feature = 2020-resolver +``` + ## Tasks ### Testing diff --git a/.travis.yml b/.travis.yml index a0967c61b..28302ab00 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,8 @@ python: install: - python setup.py install - pip install -U pip + # https://discuss.python.org/t/announcement-pip-20-2-release/4863 + - pip config set global.use-feature 2020-resolver - pip install "pytest>=5,<6" - pip install "pytype" script: From 059d3754b9509419fefdd2e01736b127b577a0f8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 26 Aug 2020 12:34:54 +0900 Subject: [PATCH 004/865] Add client type validation in App/AsyncApp constructors (#44) --- slack_bolt/app/app.py | 2 ++ slack_bolt/app/async_app.py | 2 ++ tests/async_scenario_tests/test_app.py | 7 +++++++ 3 files changed, 11 insertions(+) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index efc95291d..6a59e85e7 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -100,6 +100,8 @@ def __init__( self._token: Optional[str] = token if client is not None: + if not isinstance(client, WebClient): + raise BoltError("client must be a WebClient") self._client = client self._token = client.token if token is not None: diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 2d0798143..4db20af46 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -111,6 +111,8 @@ def __init__( self._token: Optional[str] = token if client is not None: + if not isinstance(client, AsyncWebClient): + raise BoltError("client must be an AsyncWebClient") self._async_client = client self._token = client.token if token is not None: diff --git a/tests/async_scenario_tests/test_app.py b/tests/async_scenario_tests/test_app.py index 418cfb26d..df9a68f54 100644 --- a/tests/async_scenario_tests/test_app.py +++ b/tests/async_scenario_tests/test_app.py @@ -1,4 +1,5 @@ import pytest +from slack_sdk import WebClient from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore @@ -37,6 +38,12 @@ def test_listener_registration_error(self): with pytest.raises(BoltError): app.action({"type": "invalid_type", "action_id": "a"})(self.simple_listener) + # NOTE: We intentionally don't have this test in scenario_tests + # to avoid having async dependencies in the tests. + def test_invalid_client_type(self): + with pytest.raises(BoltError): + AsyncApp(signing_secret="valid", client=WebClient(token="xoxb-xxx")) + # -------------------------- # single team auth # -------------------------- From dd855e10a07c8b1c85278dbfdbcea3481f8ae8eb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 26 Aug 2020 13:27:51 +0900 Subject: [PATCH 005/865] Fix #31 by adding app.error handler (#43) --- .travis.yml | 2 +- samples/app.py | 6 + scripts/install_all_and_run_tests.sh | 21 +++ scripts/run_pytype.sh | 6 + scripts/run_tests.sh | 6 +- slack_bolt/app/app.py | 71 ++++++-- slack_bolt/app/async_app.py | 77 +++++++-- slack_bolt/context/async_context.py | 6 + slack_bolt/context/base_context.py | 5 - slack_bolt/context/context.py | 6 + .../listener/async_listener_error_handler.py | 70 ++++++++ slack_bolt/listener/listener_error_handler.py | 61 +++++++ .../test_error_handler.py | 151 ++++++++++++++++++ tests/scenario_tests/test_error_handler.py | 141 ++++++++++++++++ 14 files changed, 592 insertions(+), 37 deletions(-) create mode 100755 scripts/install_all_and_run_tests.sh create mode 100755 scripts/run_pytype.sh create mode 100644 slack_bolt/listener/async_listener_error_handler.py create mode 100644 slack_bolt/listener/listener_error_handler.py create mode 100644 tests/async_scenario_tests/test_error_handler.py create mode 100644 tests/scenario_tests/test_error_handler.py diff --git a/.travis.yml b/.travis.yml index 28302ab00..974b6b9b5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,4 +20,4 @@ script: # run all tests just in case - travis_retry python setup.py test # Run pytype only for Python 3.8 - - if [ ${TRAVIS_PYTHON_VERSION:0:3} == "3.8" ]; then pip install -e ".[adapter]"; pytype slack_bolt/; fi + - if [ ${TRAVIS_PYTHON_VERSION:0:3} == "3.8" ]; then pip install -e ".[adapter]" && pytype slack_bolt/; fi diff --git a/samples/app.py b/samples/app.py index 0cddb3829..54739356a 100644 --- a/samples/app.py +++ b/samples/app.py @@ -26,6 +26,12 @@ def event_test(payload, say, logger): say("What's up?") +@app.error +def global_error_handler(error, payload, logger): + logger.exception(error) + logger.info(payload) + + if __name__ == "__main__": app.start(3000) diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh new file mode 100755 index 000000000..a1dbb8e2c --- /dev/null +++ b/scripts/install_all_and_run_tests.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Run all the tests or a single test +# all: ./scripts/install_all_and_run_tests.sh +# single: ./scripts/install_all_and_run_tests.sh tests/scenario_tests/test_app.py + +script_dir=`dirname $0` +cd ${script_dir}/.. + +test_target="$1" + +if [[ $test_target != "" ]] +then + pip install -e ".[testing]" && \ + black slack_bolt/ tests/ && \ + pytest $1 +else + pip install -e ".[testing]" && \ + black slack_bolt/ tests/ && \ + pytest && \ + pytype slack_bolt/ +fi diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh new file mode 100755 index 000000000..f5424bbf0 --- /dev/null +++ b/scripts/run_pytype.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# ./scripts/run_pytype.sh + +script_dir=$(dirname $0) +cd ${script_dir}/.. +pip install -e ".[adapter]" && pytype slack_bolt/ diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 9d75ed908..6a5579008 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -10,12 +10,10 @@ test_target="$1" if [[ $test_target != "" ]] then - pip install -e ".[testing]" && \ - black slack_bolt/ tests/ && \ + black slack_bolt/ tests/ && \ pytest $1 else - pip install -e ".[testing]" && \ - black slack_bolt/ tests/ && \ + black slack_bolt/ tests/ && \ pytest && \ pytype slack_bolt/ fi diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 6a59e85e7..95d89ea45 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -15,6 +15,11 @@ from slack_bolt.error import BoltError from slack_bolt.listener.custom_listener import CustomListener from slack_bolt.listener.listener import Listener +from slack_bolt.listener.listener_error_handler import ( + ListenerErrorHandler, + DefaultListenerErrorHandler, + CustomListenerErrorHandler, +) from slack_bolt.listener_matcher import CustomListenerMatcher from slack_bolt.listener_matcher import builtins as builtin_matchers from slack_bolt.listener_matcher.listener_matcher import ListenerMatcher @@ -180,6 +185,9 @@ def __init__( self._middleware_list: List[Union[Callable, Middleware]] = [] self._listeners: List[Listener] = [] self._listener_executor = ThreadPoolExecutor(max_workers=5) # TODO: shutdown + self._listener_error_handler = DefaultListenerErrorHandler( + logger=self._framework_logger + ) self._process_before_response = process_before_response self._init_middleware_list_done = False @@ -238,6 +246,10 @@ def installation_store(self) -> Optional[InstallationStore]: def oauth_state_store(self) -> Optional[OAuthStateStore]: return self._oauth_state_store + @property + def listener_error_handler(self) -> ListenerErrorHandler: + return self._listener_error_handler + # ------------------------- # standalone server @@ -301,13 +313,24 @@ def run_listener( ack = request.context.ack starting_time = time.time() if self._process_before_response: - returned_value = listener.run_ack_function( - request=request, response=response - ) - if isinstance(returned_value, BoltResponse): - response = returned_value - if ack.response is None and listener.auto_acknowledgement: - ack() # automatic ack() call if the call is not yet done + try: + returned_value = listener.run_ack_function( + request=request, response=response + ) + if isinstance(returned_value, BoltResponse): + response = returned_value + if ack.response is None and listener.auto_acknowledgement: + ack() # automatic ack() call if the call is not yet done + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + self._listener_error_handler.handle( + error=e, request=request, response=response, + ) + ack.response = response if response is not None: self._debug_log_completion(starting_time, response) @@ -318,13 +341,22 @@ def run_listener( else: # start the listener function asynchronously def run_ack_function_asynchronously(): + nonlocal ack, request, response try: listener.run_ack_function(request=request, response=response) except Exception as e: - # TODO: error handler - self._framework_logger.exception( - f"Failed to run listener function (error: {e})" + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + if ack.response is not None: # already acknowledged + response = None + + self._listener_error_handler.handle( + error=e, request=request, response=response, ) + ack.response = response self._listener_executor.submit(run_ack_function_asynchronously) @@ -336,12 +368,17 @@ def run_ack_function_asynchronously(): while ack.response is None and time.time() - starting_time <= 3: time.sleep(0.01) + if response is None and ack.response is None: + self._framework_logger.warning(f"{listener_name} didn't call ack()") + return None + if response is None and ack.response is not None: response = ack.response self._debug_log_completion(starting_time, response) return response - else: - self._framework_logger.warning(f"{listener_name} didn't call ack()") + + if response is not None: + return response # None for both means no ack() in the listener return None @@ -367,6 +404,16 @@ def middleware(self, *args): CustomMiddleware(app_name=self.name, func=func) ) + # ------------------------- + # global error handler + + def error(self, *args): + if len(args) > 0: + func = args[0] + self._listener_error_handler = CustomListenerErrorHandler( + logger=self._framework_logger, func=func, + ) + # ------------------------- # events diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 4db20af46..6d303ac36 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -15,8 +15,14 @@ from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.error import BoltError from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener +from slack_bolt.listener.async_listener_error_handler import ( + AsyncDefaultListenerErrorHandler, + AsyncListenerErrorHandler, + AsyncCustomListenerErrorHandler, +) from slack_bolt.listener_matcher import builtins as builtin_matchers from slack_bolt.listener_matcher.async_listener_matcher import ( AsyncListenerMatcher, @@ -199,6 +205,9 @@ def __init__( self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] self._async_listeners: List[AsyncListener] = [] + self._async_listener_error_handler = AsyncDefaultListenerErrorHandler( + logger=self._framework_logger + ) self._process_before_response = process_before_response self._init_middleware_list_done = False @@ -256,6 +265,10 @@ def installation_store(self) -> Optional[AsyncInstallationStore]: def oauth_state_store(self) -> Optional[AsyncOAuthStateStore]: return self._async_oauth_state_store + @property + def listener_error_handler(self) -> AsyncListenerErrorHandler: + return self._async_listener_error_handler + # ------------------------- # standalone server @@ -325,13 +338,24 @@ async def run_async_listener( ack = request.context.ack starting_time = time.time() if self._process_before_response: - returned_value = await async_listener.run_ack_function( - request=request, response=response - ) - if isinstance(returned_value, BoltResponse): - response = returned_value - if ack.response is None and async_listener.auto_acknowledgement: - await ack() # automatic ack() call if the call is not yet done + try: + returned_value = await async_listener.run_ack_function( + request=request, response=response + ) + if isinstance(returned_value, BoltResponse): + response = returned_value + if ack.response is None and async_listener.auto_acknowledgement: + await ack() # automatic ack() call if the call is not yet done + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + await self._async_listener_error_handler.handle( + error=e, request=request, response=response, + ) + ack.response = response if response is not None: self._debug_log_completion(starting_time, response) @@ -347,20 +371,28 @@ async def run_async_listener( # start the listener function asynchronously # NOTE: intentionally async def run_ack_function_asynchronously( - request: AsyncBoltRequest, response: BoltResponse, + ack: AsyncAck, request: AsyncBoltRequest, response: BoltResponse, ): try: await async_listener.run_ack_function( request=request, response=response ) except Exception as e: - # TODO: error handler - self._framework_logger.exception( - f"Failed to run listener function (error: {e})" + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + if ack.response is not None: # already acknowledged + response = None + + await self._async_listener_error_handler.handle( + error=e, request=request, response=response, ) + ack.response = response _f: Future = asyncio.ensure_future( - run_ack_function_asynchronously(request, response) + run_ack_function_asynchronously(ack, request, response) ) self._framework_logger.debug(f"Async listener: {listener_name} started..") @@ -368,12 +400,17 @@ async def run_ack_function_asynchronously( while ack.response is None and time.time() - starting_time <= 3: await asyncio.sleep(0.01) - if ack.response is not None: + if response is None and ack.response is None: + self._framework_logger.warning(f"{listener_name} didn't call ack()") + return None + + if response is None and ack.response is not None: response = ack.response self._debug_log_completion(starting_time, response) return response - else: - self._framework_logger.warning(f"{listener_name} didn't call ack()") + + if response is not None: + return response # None for both means no ack() in the listener return None @@ -399,6 +436,16 @@ def middleware(self, *args): AsyncCustomMiddleware(app_name=self.name, func=func) ) + # ------------------------- + # global error handler + + def error(self, *args): + if len(args) > 0: + func = args[0] + self._async_listener_error_handler = AsyncCustomListenerErrorHandler( + logger=self._framework_logger, func=func, + ) + # ------------------------- # events diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index 2c28cec28..b9a5b7873 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -1,5 +1,7 @@ from typing import Optional +from slack_sdk.web.async_client import AsyncWebClient + from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.context.base_context import BaseContext from slack_bolt.context.respond.async_respond import AsyncRespond @@ -7,6 +9,10 @@ class AsyncBoltContext(BaseContext): + @property + def client(self) -> Optional[AsyncWebClient]: + return self.get("client", None) + @property def ack(self) -> AsyncAck: if "ack" not in self: diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 77a806105..839ff0826 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -2,7 +2,6 @@ from typing import Optional, Tuple from slack_bolt.auth import AuthorizationResult -from slack_sdk import WebClient class BaseContext(dict): @@ -18,10 +17,6 @@ def logger(self) -> Logger: def token(self) -> Optional[str]: return self.get("token", None) - @property - def client(self) -> Optional[WebClient]: - return self.get("client", None) - @property def enterprise_id(self) -> Optional[str]: return self.get("enterprise_id", None) diff --git a/slack_bolt/context/context.py b/slack_bolt/context/context.py index 19fb1cbfb..b428b5c9a 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -1,6 +1,8 @@ # pytype: skip-file from typing import Optional +from slack_sdk import WebClient + from slack_bolt.context.ack import Ack from slack_bolt.context.base_context import BaseContext from slack_bolt.context.respond import Respond @@ -8,6 +10,10 @@ class BoltContext(BaseContext): + @property + def client(self) -> Optional[WebClient]: + return self.get("client", None) + @property def ack(self) -> Ack: if "ack" not in self: diff --git a/slack_bolt/listener/async_listener_error_handler.py b/slack_bolt/listener/async_listener_error_handler.py new file mode 100644 index 000000000..7416e2a96 --- /dev/null +++ b/slack_bolt/listener/async_listener_error_handler.py @@ -0,0 +1,70 @@ +import inspect +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Awaitable, Optional + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse + + +class AsyncListenerErrorHandler(metaclass=ABCMeta): + @abstractmethod + async def handle( + self, + error: Exception, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + raise NotImplementedError() + + +class AsyncCustomListenerErrorHandler(AsyncListenerErrorHandler): + def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]): + self.func = func + self.logger = logger + self.arg_names = inspect.getfullargspec(func).args + + async def handle( + self, + error: Exception, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + all_available_args = { + "logger": self.logger, + "error": error, + "client": request.context.client, + "req": request, + "request": request, + "resp": response, + "response": response, + "context": request.context, + "payload": request.payload, + "body": request.payload, + "say": request.context.say, + "respond": request.context.respond, + } + kwargs: Dict[str, Any] = { # type: ignore + k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore + } + found_arg_names = kwargs.keys() + for name in self.arg_names: + if name not in found_arg_names: + self.logger.warning(f"{name} is not a valid argument") + kwargs[name] = None + + await self.func(**kwargs) + + +class AsyncDefaultListenerErrorHandler(AsyncListenerErrorHandler): + def __init__(self, logger: Logger): + self.logger = logger + + async def handle( + self, + error: Exception, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ): + message = f"Failed to run listener function (error: {error})" + self.logger.exception(message) diff --git a/slack_bolt/listener/listener_error_handler.py b/slack_bolt/listener/listener_error_handler.py new file mode 100644 index 000000000..f283bd4c4 --- /dev/null +++ b/slack_bolt/listener/listener_error_handler.py @@ -0,0 +1,61 @@ +import inspect +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Optional + +from slack_bolt.request.request import BoltRequest +from slack_bolt.response.response import BoltResponse + + +class ListenerErrorHandler(metaclass=ABCMeta): + @abstractmethod + def handle( + self, error: Exception, request: BoltRequest, response: Optional[BoltResponse], + ) -> None: + raise NotImplementedError() + + +class CustomListenerErrorHandler(ListenerErrorHandler): + def __init__(self, logger: Logger, func: Callable[..., None]): + self.func = func + self.logger = logger + self.arg_names = inspect.getfullargspec(func).args + + def handle( + self, error: Exception, request: BoltRequest, response: Optional[BoltResponse], + ): + all_available_args = { + "logger": self.logger, + "error": error, + "client": request.context.client, + "req": request, + "request": request, + "resp": response, + "response": response, + "context": request.context, + "payload": request.payload, + "body": request.payload, + "say": request.context.say, + "respond": request.context.respond, + } + kwargs: Dict[str, Any] = { # type: ignore + k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore + } + found_arg_names = kwargs.keys() + for name in self.arg_names: + if name not in found_arg_names: + self.logger.warning(f"{name} is not a valid argument") + kwargs[name] = None + + self.func(**kwargs) + + +class DefaultListenerErrorHandler(ListenerErrorHandler): + def __init__(self, logger: Logger): + self.logger = logger + + def handle( + self, error: Exception, request: BoltRequest, response: Optional[BoltResponse], + ): + message = f"Failed to run listener function (error: {error})" + self.logger.exception(message) diff --git a/tests/async_scenario_tests/test_error_handler.py b/tests/async_scenario_tests/test_error_handler.py new file mode 100644 index 000000000..4fb0a7fab --- /dev/null +++ b/tests/async_scenario_tests/test_error_handler.py @@ -0,0 +1,151 @@ +import asyncio +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.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 TestAsyncErrorHandler: + 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,) + + @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) + + # ---------------- + # utilities + # ---------------- + + 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): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> AsyncBoltRequest: + payload = { + "type": "block_actions", + "user": {"id": "W111",}, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "team": {"id": "T111",}, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], + } + raw_body = f"payload={quote(json.dumps(payload))}" + timestamp = str(int(time())) + return AsyncBoltRequest( + body=raw_body, headers=self.build_headers(timestamp, raw_body) + ) + + # ---------------- + # tests + # ---------------- + + @pytest.mark.asyncio + async def test_default(self): + async def failing_listener(): + raise Exception("Something wrong!") + + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 500 + + @pytest.mark.asyncio + async def test_custom(self): + async def error_handler(logger, payload, response): + logger.info(payload) + response.headers["x-test-result"] = ["1"] + + async def failing_listener(): + raise Exception("Something wrong!") + + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.error(error_handler) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"] + + @pytest.mark.asyncio + async def test_process_before_response_default(self): + async def failing_listener(): + raise Exception("Something wrong!") + + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 500 + + @pytest.mark.asyncio + async def test_process_before_response_custom(self): + async def error_handler(logger, payload, response): + logger.info(payload) + response.headers["x-test-result"] = ["1"] + + async def failing_listener(): + raise Exception("Something wrong!") + + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.error(error_handler) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"] diff --git a/tests/scenario_tests/test_error_handler.py b/tests/scenario_tests/test_error_handler.py new file mode 100644 index 000000000..1f6d1f526 --- /dev/null +++ b/tests/scenario_tests/test_error_handler.py @@ -0,0 +1,141 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk import WebClient +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import BoltRequest +from slack_bolt.app import App +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 TestErrorHandler: + 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) + + # ---------------- + # utilities + # ---------------- + + 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): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> BoltRequest: + payload = { + "type": "block_actions", + "user": {"id": "W111",}, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "team": {"id": "T111",}, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], + } + raw_body = f"payload={quote(json.dumps(payload))}" + timestamp = str(int(time())) + return BoltRequest( + body=raw_body, headers=self.build_headers(timestamp, raw_body) + ) + + # ---------------- + # tests + # ---------------- + + def test_default(self): + def failing_listener(): + raise Exception("Something wrong!") + + app = App(client=self.web_client, signing_secret=self.signing_secret,) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 500 + + def test_custom(self): + def error_handler(logger, payload, response): + logger.info(payload) + response.headers["x-test-result"] = ["1"] + + def failing_listener(): + raise Exception("Something wrong!") + + app = App(client=self.web_client, signing_secret=self.signing_secret,) + app.error(error_handler) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"] + + def test_process_before_response_default(self): + def failing_listener(): + raise Exception("Something wrong!") + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 500 + + def test_process_before_response_custom(self): + def error_handler(logger, payload, response): + logger.info(payload) + response.headers["x-test-result"] = ["1"] + + def failing_listener(): + raise Exception("Something wrong!") + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.error(error_handler) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"] From 5a702cdee51764c33ff09ae3326bcf1bd087ea6b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 26 Aug 2020 16:21:11 +0900 Subject: [PATCH 006/865] Implement Lazy Listener Functions #34 (#35) * Implement Lazy Listener Functions #34 * Add unit tests * Remove test.pypi.org from instructions * Fix errors in Python 3.6 * Apply formtter to samples * Fix type hints --- samples/aws_chalice/app.py | 5 +- samples/aws_chalice/simple_app.py | 5 +- samples/aws_lambda/.gitignore | 3 +- samples/aws_lambda/deploy_lazy.sh | 6 + samples/aws_lambda/lazy_aws_lambda.py | 57 ++++ .../aws_lambda/lazy_aws_lambda_config.yaml | 40 +++ samples/django/slackapp/slackapp/settings.py | 4 +- ...modals_app.py => lazy_async_modals_app.py} | 36 ++- samples/lazy_modals_app.py | 161 ++++++++++ samples/modals_app_typed.py | 4 +- samples/readme_app.py | 25 +- samples/readme_async_app.py | 26 +- scripts/run_tests.sh | 14 +- .../adapter/aws_lambda/chalice_handler.py | 13 + .../chalice_lazy_listener_runner.py | 38 +++ slack_bolt/adapter/aws_lambda/handler.py | 11 + .../aws_lambda/lazy_listener_runner.py | 29 ++ slack_bolt/app/app.py | 264 +++++++++++----- slack_bolt/app/async_app.py | 283 ++++++++++++------ slack_bolt/lazy_listener/__init__.py | 3 + slack_bolt/lazy_listener/async_internals.py | 31 ++ slack_bolt/lazy_listener/async_runner.py | 24 ++ slack_bolt/lazy_listener/asyncio_runner.py | 25 ++ slack_bolt/lazy_listener/internals.py | 29 ++ slack_bolt/lazy_listener/runner.py | 17 ++ slack_bolt/lazy_listener/thread_runner.py | 22 ++ slack_bolt/listener/async_listener.py | 22 +- slack_bolt/listener/custom_listener.py | 19 +- slack_bolt/listener/listener.py | 3 +- slack_bolt/request/async_request.py | 6 + slack_bolt/request/request.py | 6 + slack_bolt/util/utils.py | 20 +- tests/async_scenario_tests/test_lazy.py | 107 +++++++ tests/scenario_tests/test_lazy.py | 100 +++++++ 34 files changed, 1216 insertions(+), 242 deletions(-) create mode 100755 samples/aws_lambda/deploy_lazy.sh create mode 100644 samples/aws_lambda/lazy_aws_lambda.py create mode 100644 samples/aws_lambda/lazy_aws_lambda_config.yaml rename samples/{async_modals_app.py => lazy_async_modals_app.py} (86%) create mode 100644 samples/lazy_modals_app.py create mode 100644 slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py create mode 100644 slack_bolt/adapter/aws_lambda/lazy_listener_runner.py create mode 100644 slack_bolt/lazy_listener/__init__.py create mode 100644 slack_bolt/lazy_listener/async_internals.py create mode 100644 slack_bolt/lazy_listener/async_runner.py create mode 100644 slack_bolt/lazy_listener/asyncio_runner.py create mode 100644 slack_bolt/lazy_listener/internals.py create mode 100644 slack_bolt/lazy_listener/runner.py create mode 100644 slack_bolt/lazy_listener/thread_runner.py create mode 100644 tests/async_scenario_tests/test_lazy.py create mode 100644 tests/scenario_tests/test_lazy.py diff --git a/samples/aws_chalice/app.py b/samples/aws_chalice/app.py index 3d63a9ce4..2f9fc378b 100644 --- a/samples/aws_chalice/app.py +++ b/samples/aws_chalice/app.py @@ -6,10 +6,7 @@ from slack_bolt.adapter.aws_lambda.chalice_handler import ChaliceSlackRequestHandler # process_before_response must be True when running on FaaS -bolt_app = App( - process_before_response=True, - authorization_test_enabled=False, -) +bolt_app = App(process_before_response=True, authorization_test_enabled=False,) @bolt_app.event("app_mention") diff --git a/samples/aws_chalice/simple_app.py b/samples/aws_chalice/simple_app.py index 3d63a9ce4..2f9fc378b 100644 --- a/samples/aws_chalice/simple_app.py +++ b/samples/aws_chalice/simple_app.py @@ -6,10 +6,7 @@ from slack_bolt.adapter.aws_lambda.chalice_handler import ChaliceSlackRequestHandler # process_before_response must be True when running on FaaS -bolt_app = App( - process_before_response=True, - authorization_test_enabled=False, -) +bolt_app = App(process_before_response=True, authorization_test_enabled=False,) @bolt_app.event("app_mention") diff --git a/samples/aws_lambda/.gitignore b/samples/aws_lambda/.gitignore index a725465ae..b44d4ebbd 100644 --- a/samples/aws_lambda/.gitignore +++ b/samples/aws_lambda/.gitignore @@ -1 +1,2 @@ -vendor/ \ No newline at end of file +vendor/ +.env \ No newline at end of file diff --git a/samples/aws_lambda/deploy_lazy.sh b/samples/aws_lambda/deploy_lazy.sh new file mode 100755 index 000000000..262ac060d --- /dev/null +++ b/samples/aws_lambda/deploy_lazy.sh @@ -0,0 +1,6 @@ +#!/bin/bash +rm -rf vendor && mkdir -p vendor/slack_bolt && cp -pr ../../slack_bolt/* vendor/slack_bolt/ +pip install python-lambda -U +lambda deploy \ + --config-file lazy_aws_lambda_config.yaml \ + --requirements requirements.txt \ No newline at end of file diff --git a/samples/aws_lambda/lazy_aws_lambda.py b/samples/aws_lambda/lazy_aws_lambda.py new file mode 100644 index 000000000..b72fa2943 --- /dev/null +++ b/samples/aws_lambda/lazy_aws_lambda.py @@ -0,0 +1,57 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys +import time + +sys.path.insert(1, "vendor") +# ------------------------------------------------ + +import logging + +from slack_bolt import App +from slack_bolt.adapter.aws_lambda import SlackRequestHandler + +# process_before_response must be True when running on FaaS +app = App(process_before_response=True) + + +@app.middleware # or app.use(log_request) +def log_request(logger, payload, next): + logger.debug(payload) + return next() + + +command = "/hello-bolt-python-lambda" + + +def respond_to_slack_within_3_seconds(payload, ack): + if payload.get("text", None) is None: + ack(f":x: Usage: {command} (description here)") + else: + title = payload["text"] + ack(f"Accepted! (task: {title})") + + +def process_request(respond, payload): + time.sleep(5) + title = payload["text"] + respond(f"Completed! (task: {title})") + + +app.command(command)(ack=respond_to_slack_within_3_seconds, lazy=[process_request]) + +SlackRequestHandler.clear_all_log_handlers() +logging.basicConfig(format="%(asctime)s %(message)s", level=logging.DEBUG) + + +def handler(event, context): + slack_handler = SlackRequestHandler(app=app) + return slack_handler.handle(event, context) + + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** + +# rm -rf vendor && cp -pr ../../src/* vendor/ +# pip install python-lambda +# lambda deploy --config-file aws_lambda_config.yaml --requirements requirements.txt diff --git a/samples/aws_lambda/lazy_aws_lambda_config.yaml b/samples/aws_lambda/lazy_aws_lambda_config.yaml new file mode 100644 index 000000000..abc1654da --- /dev/null +++ b/samples/aws_lambda/lazy_aws_lambda_config.yaml @@ -0,0 +1,40 @@ +region: us-east-1 + +function_name: bolt_py_function +handler: lazy_aws_lambda.handler +description: My first lambda function +runtime: python3.8 +# role: lambda_basic_execution +role: bolt_python_lambda_invocation # AWSLambdaFullAccess + +# S3 upload requires appropriate role with s3:PutObject permission +# (ex. basic_s3_upload), a destination bucket, and the key prefix +# bucket_name: 'example-bucket' +# s3_key_prefix: 'path/to/file/' + +# if access key and secret are left blank, boto will use the credentials +# defined in the [default] section of ~/.aws/credentials. +aws_access_key_id: +aws_secret_access_key: + +# dist_directory: dist +# timeout: 15 +# memory_size: 512 +# concurrency: 500 +# + +# Experimental Environment variables +environment_variables: + SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN} + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} + +# If `tags` is uncommented then tags will be set at creation or update +# time. During an update all other tags will be removed except the tags +# listed here. +#tags: +# tag_1: foo +# tag_2: bar + +# Build options +build: + source_directories: vendor # a comma delimited list of directories in your project root that contains source to package. diff --git a/samples/django/slackapp/slackapp/settings.py b/samples/django/slackapp/slackapp/settings.py index f91069c71..563da74fb 100644 --- a/samples/django/slackapp/slackapp/settings.py +++ b/samples/django/slackapp/slackapp/settings.py @@ -35,7 +35,9 @@ # SECURITY WARNING: keep the secret key used in production secret! # TODO: CHANGE THIS IF YOU REUSE THIS APP -SECRET_KEY = "This is just a example. You should not expose your secret key in real apps" +SECRET_KEY = ( + "This is just a example. You should not expose your secret key in real apps" +) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True diff --git a/samples/async_modals_app.py b/samples/lazy_async_modals_app.py similarity index 86% rename from samples/async_modals_app.py rename to samples/lazy_async_modals_app.py index c4a65a777..45dcf2051 100644 --- a/samples/async_modals_app.py +++ b/samples/lazy_async_modals_app.py @@ -1,17 +1,16 @@ # ------------------------------------------------ # instead of slack_bolt in requirements.txt -import asyncio import sys sys.path.insert(1, "..") # ------------------------------------------------ +import asyncio import logging +from slack_bolt.async_app import AsyncApp logging.basicConfig(level=logging.DEBUG) -from slack_bolt.async_app import AsyncApp - app = AsyncApp() @@ -21,11 +20,12 @@ async def log_request(logger, payload, next): return await next() -@app.command("/hello-bolt-python") -async def handle_command(payload, ack, respond, client, logger): +async def ack_command(payload, ack, logger): logger.info(payload) - await ack("Accepted!") + await ack("Thanks!") + +async def post_button_message(respond): await respond( blocks=[ { @@ -45,6 +45,8 @@ async def handle_command(payload, ack, respond, client, logger): ] ) + +async def open_modal(payload, client, logger): res = await client.views_open( trigger_id=payload["trigger_id"], view={ @@ -66,6 +68,7 @@ async def handle_command(payload, ack, respond, client, logger): "type": "external_select", "action_id": "es_a", "placeholder": {"type": "plain_text", "text": "Select an item"}, + "min_query_length": 0, }, "label": {"type": "plain_text", "text": "Search"}, }, @@ -76,6 +79,7 @@ async def handle_command(payload, ack, respond, client, logger): "type": "multi_external_select", "action_id": "mes_a", "placeholder": {"type": "plain_text", "text": "Select an item"}, + "min_query_length": 0, }, "label": {"type": "plain_text", "text": "Search (multi)"}, }, @@ -85,6 +89,11 @@ async def handle_command(payload, ack, respond, client, logger): logger.info(res) +app.command("/hello-bolt-python")( + ack=ack_command, lazy=[post_button_message, open_modal], +) + + @app.options("es_a") async def show_options(ack): await ack( @@ -125,19 +134,22 @@ async def show_multi_options(ack): @app.view("view-id") -async def view_submission(ack, payload, logger): +async def handle_view_submission(ack, payload, logger): await ack() logger.info(payload["view"]["state"]["values"]) -@app.action("a") -async def button_click(ack, respond): +async def ack_button_click(ack, respond): await ack() + await respond("Loading ...") + + +async def respond_5_seconds_later(respond): await asyncio.sleep(5) - await respond( - {"response_type": "in_channel", "text": "Clicked!",} - ) + await respond("Completed!") + +app.action("a")(ack=ack_button_click, lazy=[respond_5_seconds_later]) if __name__ == "__main__": app.start(3000) diff --git a/samples/lazy_modals_app.py b/samples/lazy_modals_app.py new file mode 100644 index 000000000..7f279a073 --- /dev/null +++ b/samples/lazy_modals_app.py @@ -0,0 +1,161 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging +import time + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App + +app = App() + + +@app.middleware # or app.use(log_request) +def log_request(logger, payload, next): + logger.debug(payload) + return next() + + +def ack_command(payload, ack, logger): + logger.info(payload) + ack("Thanks!") + + +def post_button_message(respond): + respond( + blocks=[ + { + "type": "section", + "block_id": "b", + "text": { + "type": "mrkdwn", + "text": "You can add a button alongside text in your message. ", + }, + "accessory": { + "type": "button", + "action_id": "a", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + }, + } + ] + ) + + +def open_modal(payload, client, logger): + res = client.views_open( + trigger_id=payload["trigger_id"], + view={ + "type": "modal", + "callback_id": "view-id", + "title": {"type": "plain_text", "text": "My App",}, + "submit": {"type": "plain_text", "text": "Submit",}, + "close": {"type": "plain_text", "text": "Cancel",}, + "blocks": [ + { + "type": "input", + "element": {"type": "plain_text_input"}, + "label": {"type": "plain_text", "text": "Label",}, + }, + { + "type": "input", + "block_id": "es_b", + "element": { + "type": "external_select", + "action_id": "es_a", + "placeholder": {"type": "plain_text", "text": "Select an item"}, + "min_query_length": 0, + }, + "label": {"type": "plain_text", "text": "Search"}, + }, + { + "type": "input", + "block_id": "mes_b", + "element": { + "type": "multi_external_select", + "action_id": "mes_a", + "placeholder": {"type": "plain_text", "text": "Select an item"}, + "min_query_length": 0, + }, + "label": {"type": "plain_text", "text": "Search (multi)"}, + }, + ], + }, + ) + logger.info(res) + + +app.command("/hello-bolt-python")( + ack=ack_command, lazy=[post_button_message, open_modal], +) + + +@app.options("es_a") +def show_options(ack): + ack( + {"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]} + ) + + +@app.options("mes_a") +def show_multi_options(ack): + ack( + { + "option_groups": [ + { + "label": {"type": "plain_text", "text": "Group 1"}, + "options": [ + { + "text": {"type": "plain_text", "text": "Option 1"}, + "value": "1-1", + }, + { + "text": {"type": "plain_text", "text": "Option 2"}, + "value": "1-2", + }, + ], + }, + { + "label": {"type": "plain_text", "text": "Group 2"}, + "options": [ + { + "text": {"type": "plain_text", "text": "Option 1"}, + "value": "2-1", + }, + ], + }, + ] + } + ) + + +@app.view("view-id") +def handle_view_submission(ack, payload, logger): + ack() + logger.info(payload["view"]["state"]["values"]) + + +def ack_button_click(ack, respond): + ack() + respond("Loading ...") + + +def respond_5_seconds_later(respond): + time.sleep(5) + respond("Completed!") + + +app.action("a")(ack=ack_button_click, lazy=[respond_5_seconds_later]) + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python modals_app.py diff --git a/samples/modals_app_typed.py b/samples/modals_app_typed.py index 021a5e4f1..a0e2da54f 100644 --- a/samples/modals_app_typed.py +++ b/samples/modals_app_typed.py @@ -60,7 +60,9 @@ def handle_command( text="You can add a button alongside text in your message. " ), accessory=ButtonElement( - action_id="a", text=PlainTextObject(text="Button"), value="click_me_123", + action_id="a", + text=PlainTextObject(text="Button"), + value="click_me_123", ), ), ] diff --git a/samples/readme_app.py b/samples/readme_app.py index d9de6becd..b49587ecb 100644 --- a/samples/readme_app.py +++ b/samples/readme_app.py @@ -34,29 +34,18 @@ def open_modal(ack, client, logger, payload): view={ "type": "modal", "callback_id": "view-id", - "title": { - "type": "plain_text", - "text": "My App", - }, - "submit": { - "type": "plain_text", - "text": "Submit", - }, + "title": {"type": "plain_text", "text": "My App",}, + "submit": {"type": "plain_text", "text": "Submit",}, "blocks": [ { "type": "input", "block_id": "b", - "element": { - "type": "plain_text_input", - "action_id": "a" - }, - "label": { - "type": "plain_text", - "text": "Label", - } + "element": {"type": "plain_text_input", "action_id": "a"}, + "label": {"type": "plain_text", "text": "Label",}, } - ] - }) + ], + }, + ) logger.debug(api_response) diff --git a/samples/readme_async_app.py b/samples/readme_async_app.py index 937b61022..e1927d69c 100644 --- a/samples/readme_async_app.py +++ b/samples/readme_async_app.py @@ -1,4 +1,5 @@ import logging + # requires `pip install "aiohttp>=3,<4"` from slack_bolt.async_app import AsyncApp @@ -34,29 +35,18 @@ async def open_modal(ack, client, logger, payload): view={ "type": "modal", "callback_id": "view-id", - "title": { - "type": "plain_text", - "text": "My App", - }, - "submit": { - "type": "plain_text", - "text": "Submit", - }, + "title": {"type": "plain_text", "text": "My App",}, + "submit": {"type": "plain_text", "text": "Submit",}, "blocks": [ { "type": "input", "block_id": "b", - "element": { - "type": "plain_text_input", - "action_id": "a" - }, - "label": { - "type": "plain_text", - "text": "Label", - } + "element": {"type": "plain_text_input", "action_id": "a"}, + "label": {"type": "plain_text", "text": "Label",}, } - ] - }) + ], + }, + ) logger.debug(api_response) diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 6a5579008..7170abeb0 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -7,13 +7,21 @@ script_dir=`dirname $0` cd ${script_dir}/.. test_target="$1" +python_version=`python --version | awk '{print $2}'` if [[ $test_target != "" ]] then black slack_bolt/ tests/ && \ pytest $1 else - black slack_bolt/ tests/ && \ - pytest && \ - pytype slack_bolt/ + if [ ${python_version:0:3} == "3.8" ] + then + # pytype's behavior can be different in older Python versions + black slack_bolt/ tests/ \ + && pytest \ + && pip install -e ".[adapter]" \ + && pytype slack_bolt/ + else + black slack_bolt/ tests/ && pytest + fi fi diff --git a/slack_bolt/adapter/aws_lambda/chalice_handler.py b/slack_bolt/adapter/aws_lambda/chalice_handler.py index b65838479..a5f078ffc 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_handler.py +++ b/slack_bolt/adapter/aws_lambda/chalice_handler.py @@ -2,6 +2,9 @@ from chalice.app import Request, Response, Chalice +from slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner import ( + ChaliceLazyListenerRunner, +) from slack_bolt.adapter.aws_lambda.internals import _first_value from slack_bolt.app import App from slack_bolt.logger import get_bolt_app_logger @@ -15,6 +18,7 @@ def __init__(self, app: App, chalice: Chalice): # type: ignore self.app = app self.chalice = chalice self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler) + self.app.lazy_listener_runner = ChaliceLazyListenerRunner(logger=self.logger) @classmethod def clear_all_log_handlers(cls): @@ -50,6 +54,15 @@ def handle(self, request: Request): bolt_resp = oauth_flow.handle_installation(bolt_req) return to_chalice_response(bolt_resp) elif method == "POST": + bolt_req: BoltRequest = to_bolt_request(request, body) + # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html + aws_lambda_function_name = self.chalice.lambda_context.function_name + bolt_req.context["aws_lambda_function_name"] = aws_lambda_function_name + bolt_req.context["chalice_request"] = request.to_dict() + bolt_resp = self.app.dispatch(bolt_req) + aws_response = to_chalice_response(bolt_resp) + return aws_response + elif method == "NONE": bolt_req: BoltRequest = to_bolt_request(request, body) bolt_resp = self.app.dispatch(bolt_req) aws_response = to_chalice_response(bolt_resp) diff --git a/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py b/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py new file mode 100644 index 000000000..bb61cc169 --- /dev/null +++ b/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py @@ -0,0 +1,38 @@ +import json +from logging import Logger +from typing import Callable + +import boto3 + +from slack_bolt import BoltRequest +from slack_bolt.lazy_listener import LazyListenerRunner + + +class ChaliceLazyListenerRunner(LazyListenerRunner): + def __init__(self, logger: Logger): + self.lambda_client = boto3.client("lambda") + self.logger = logger + + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + chalice_request: dict = request.context["chalice_request"] + request.headers["x-slack-bolt-lazy-only"] = ["1"] + request.headers["x-slack-bolt-lazy-function-name"] = [ + request.lazy_function_name + ] + payload = { + "method": "NONE", + "headers": {k: v[0] for k, v in request.headers.items()}, + "multiValueQueryStringParameters": request.query, + "queryStringParameters": {k: v[0] for k, v in request.query.items()}, + "pathParameters": {}, + "stageVariables": {}, + "requestContext": chalice_request["context"], + "body": request.body, + "isBase64Encoded": False, + } + invocation = self.lambda_client.invoke( + FunctionName=request.context["aws_lambda_function_name"], + InvocationType="Event", + Payload=json.dumps(payload), + ) + self.logger.info(invocation) diff --git a/slack_bolt/adapter/aws_lambda/handler.py b/slack_bolt/adapter/aws_lambda/handler.py index f7faee016..7dbcefa96 100644 --- a/slack_bolt/adapter/aws_lambda/handler.py +++ b/slack_bolt/adapter/aws_lambda/handler.py @@ -3,6 +3,7 @@ from typing import List, Dict, Any from slack_bolt.adapter.aws_lambda.internals import _first_value +from slack_bolt.adapter.aws_lambda.lazy_listener_runner import LambdaLazyListenerRunner from slack_bolt.app import App from slack_bolt.logger import get_bolt_app_logger from slack_bolt.oauth import OAuthFlow @@ -14,6 +15,7 @@ class SlackRequestHandler: def __init__(self, app: App): # type: ignore self.app = app self.logger = get_bolt_app_logger(app.name, SlackRequestHandler) + self.app.lazy_listener_runner = LambdaLazyListenerRunner(self.logger) @classmethod def clear_all_log_handlers(cls): @@ -48,6 +50,15 @@ def handle(self, event, context): bolt_resp = oauth_flow.handle_installation(bolt_req) return to_aws_response(bolt_resp) elif method == "POST": + bolt_req = to_bolt_request(event) + # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html + aws_lambda_function_name = context.function_name + bolt_req.context["aws_lambda_function_name"] = aws_lambda_function_name + bolt_req.context["lambda_request"] = event + bolt_resp = self.app.dispatch(bolt_req) + aws_response = to_aws_response(bolt_resp) + return aws_response + elif method == "NONE": bolt_req = to_bolt_request(event) bolt_resp = self.app.dispatch(bolt_req) aws_response = to_aws_response(bolt_resp) diff --git a/slack_bolt/adapter/aws_lambda/lazy_listener_runner.py b/slack_bolt/adapter/aws_lambda/lazy_listener_runner.py new file mode 100644 index 000000000..ca95e7fe8 --- /dev/null +++ b/slack_bolt/adapter/aws_lambda/lazy_listener_runner.py @@ -0,0 +1,29 @@ +import json +from logging import Logger +from typing import Callable + +import boto3 + +from slack_bolt import BoltRequest +from slack_bolt.lazy_listener import LazyListenerRunner + + +class LambdaLazyListenerRunner(LazyListenerRunner): + def __init__(self, logger: Logger): + self.lambda_client = boto3.client("lambda") + self.logger = logger + + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + event: dict = request.context["lambda_request"] + headers = event["headers"] + headers["x-slack-bolt-lazy-only"] = "1" # not an array + headers[ + "x-slack-bolt-lazy-function-name" + ] = request.lazy_function_name # not an array + event["method"] = "NONE" + invocation = self.lambda_client.invoke( + FunctionName=request.context["aws_lambda_function_name"], + InvocationType="Event", + Payload=json.dumps(event), + ) + self.logger.info(invocation) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 95d89ea45..b211c1c72 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -13,6 +13,8 @@ from slack_sdk.web import WebClient from slack_bolt.error import BoltError +from slack_bolt.lazy_listener.runner import LazyListenerRunner +from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner from slack_bolt.listener.custom_listener import CustomListener from slack_bolt.listener.listener import Listener from slack_bolt.listener.listener_error_handler import ( @@ -38,7 +40,7 @@ from slack_bolt.oauth import OAuthFlow from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import create_web_client +from slack_bolt.util.utils import create_web_client, _copy_object class App: @@ -190,6 +192,10 @@ def __init__( ) self._process_before_response = process_before_response + self.lazy_listener_runner: LazyListenerRunner = ThreadLazyListenerRunner( + logger=self._framework_logger, executor=self._listener_executor, + ) + self._init_middleware_list_done = False self._init_middleware_list() @@ -280,7 +286,7 @@ def middleware_next(): return resp for listener in self._listeners: - listener_name = listener.func.__name__ + listener_name = listener.ack_function.__name__ self._framework_logger.debug(f"Checking listener: {listener_name} ...") if listener.matches(req=req, resp=resp): # run all the middleware attached to this listener first @@ -313,60 +319,88 @@ def run_listener( ack = request.context.ack starting_time = time.time() if self._process_before_response: - try: - returned_value = listener.run_ack_function( - request=request, response=response - ) - if isinstance(returned_value, BoltResponse): - response = returned_value - if ack.response is None and listener.auto_acknowledgement: - ack() # automatic ack() call if the call is not yet done - except Exception as e: - # The default response status code is 500 in this case. - # You can customize this by passing your own error handler. - if response is None: - response = BoltResponse(status=500) - response.status = 500 - self._listener_error_handler.handle( - error=e, request=request, response=response, - ) - ack.response = response - - if response is not None: - self._debug_log_completion(starting_time, response) - return response - elif ack.response is not None: - self._debug_log_completion(starting_time, ack.response) - return ack.response - else: - # start the listener function asynchronously - def run_ack_function_asynchronously(): - nonlocal ack, request, response + if not request.lazy_only: try: - listener.run_ack_function(request=request, response=response) + returned_value = listener.run_ack_function( + request=request, response=response + ) + if isinstance(returned_value, BoltResponse): + response = returned_value + if ack.response is None and listener.auto_acknowledgement: + ack() # automatic ack() call if the call is not yet done except Exception as e: # The default response status code is 500 in this case. # You can customize this by passing your own error handler. if response is None: response = BoltResponse(status=500) response.status = 500 - if ack.response is not None: # already acknowledged - response = None - self._listener_error_handler.handle( error=e, request=request, response=response, ) ack.response = response - self._listener_executor.submit(run_ack_function_asynchronously) + for lazy_func in listener.lazy_functions: + if request.lazy_function_name: + func_name = lazy_func.__name__ + if func_name == request.lazy_function_name: + self.lazy_listener_runner.run( + function=lazy_func, request=request + ) + return None + else: + continue + else: + self._start_lazy_function(lazy_func, request) + if response is not None: + self._debug_log_completion(starting_time, response) + return response + elif ack.response is not None: + self._debug_log_completion(starting_time, ack.response) + return ack.response + else: if listener.auto_acknowledgement: # acknowledge immediately in case of Events API ack() - else: - # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= 3: - time.sleep(0.01) + + if not request.lazy_only: + # start the listener function asynchronously + def run_ack_function_asynchronously(): + nonlocal ack, request, response + try: + listener.run_ack_function(request=request, response=response) + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + if ack.response is not None: # already acknowledged + response = None + + self._listener_error_handler.handle( + error=e, request=request, response=response, + ) + ack.response = response + + self._listener_executor.submit(run_ack_function_asynchronously) + + for lazy_func in listener.lazy_functions: + if request.lazy_function_name: + func_name = lazy_func.__name__ + if func_name == request.lazy_function_name: + self.lazy_listener_runner.run( + function=lazy_func, request=request + ) + return None + else: + continue + else: + self._start_lazy_function(lazy_func, request) + + # await for the completion of ack() in the async listener execution + while ack.response is None and time.time() - starting_time <= 3: + time.sleep(0.01) if response is None and ack.response is None: self._framework_logger.warning(f"{listener_name} didn't call ack()") @@ -383,6 +417,23 @@ def run_ack_function_asynchronously(): # None for both means no ack() in the listener return None + def _start_lazy_function( + self, lazy_func: Callable[..., None], request: BoltRequest + ): + # Start a lazy function asynchronously + func_name: str = lazy_func.__name__ + self._framework_logger.debug(f"Running lazy listener: {func_name} ...") + copied_request = self._build_lazy_request(request, func_name) + self.lazy_listener_runner.start(function=lazy_func, request=copied_request) + + @staticmethod + def _build_lazy_request(request: BoltRequest, lazy_func_name: str) -> BoltRequest: + copied_request = _copy_object(request) + copied_request.method = "NONE" + copied_request.lazy_only = True + copied_request.lazy_function_name = lazy_func_name + return copied_request + def _debug_log_completion( self, starting_time: float, response: BoltResponse ) -> None: @@ -423,10 +474,11 @@ def event( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event) return self._register_listener( - func, primary_matcher, matchers, middleware, True + list(functions), primary_matcher, matchers, middleware, True ) return __call__ @@ -440,11 +492,12 @@ def message( matchers = matchers if matchers else [] middleware = middleware if middleware else [] - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event("message") middleware.append(MessageListenerMatches(keyword)) return self._register_listener( - func, primary_matcher, matchers, middleware, True + list(functions), primary_matcher, matchers, middleware, True ) return __call__ @@ -458,9 +511,12 @@ def command( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.command(command) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -473,9 +529,12 @@ def shortcut( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.shortcut(constraints) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -485,9 +544,12 @@ def global_shortcut( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.global_shortcut(callback_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -497,9 +559,12 @@ def message_shortcut( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.message_shortcut(callback_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -512,9 +577,12 @@ def action( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.action(constraints) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -524,9 +592,12 @@ def block_action( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.block_action(action_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -536,9 +607,12 @@ def attachment_action( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.attachment_action(callback_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -548,9 +622,12 @@ def dialog_submission( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.dialog_submission(callback_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -560,9 +637,12 @@ def dialog_cancellation( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.dialog_cancellation(callback_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -575,9 +655,12 @@ def view( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.view(constraints) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -587,9 +670,12 @@ def view_submission( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.view_submission(constraints) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -599,9 +685,12 @@ def view_closed( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.view_closed(constraints) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -614,9 +703,12 @@ def options( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.options(constraints) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -626,9 +718,12 @@ def block_suggestion( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.block_suggestion(action_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -638,9 +733,12 @@ def dialog_suggestion( matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.dialog_suggestion(callback_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -651,14 +749,27 @@ def _init_context(self, req: BoltRequest): req.context["token"] = self._token req.context["client"] = self._client + @staticmethod + def _to_listener_functions( + kwargs: dict, + ) -> Optional[List[Callable[..., Optional[BoltResponse]]]]: + if kwargs: + functions = [kwargs["ack"]] + for sub in kwargs["lazy"]: + functions.append(sub) + return functions + return None + def _register_listener( self, - func: Callable[..., BoltResponse], + functions: List[Callable[..., Optional[BoltResponse]]], primary_matcher: ListenerMatcher, matchers: Optional[List[Callable[..., bool]]], middleware: Optional[List[Union[Callable, Middleware]]], auto_acknowledgement: bool = False, ) -> None: + if not isinstance(functions, list): + functions = list(functions) listener_matchers = [ CustomListenerMatcher(app_name=self.name, func=f) for f in (matchers or []) @@ -678,7 +789,8 @@ def _register_listener( self._listeners.append( CustomListener( app_name=self.name, - func=func, + ack_function=functions.pop(0), + lazy_functions=functions, matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 6d303ac36..d5a8b5f22 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -17,6 +17,8 @@ from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.error import BoltError +from slack_bolt.lazy_listener.async_runner import AsyncLazyListenerRunner +from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener from slack_bolt.listener.async_listener_error_handler import ( AsyncDefaultListenerErrorHandler, @@ -52,6 +54,7 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from slack_bolt.util.async_utils import create_async_web_client +from slack_bolt.util.utils import _copy_object class AsyncApp: @@ -209,6 +212,9 @@ def __init__( logger=self._framework_logger ) self._process_before_response = process_before_response + self.lazy_listener_runner: AsyncLazyListenerRunner = AsyncioLazyListenerRunner( + logger=self._framework_logger, + ) self._init_middleware_list_done = False self._init_async_middleware_list() @@ -301,7 +307,7 @@ async def async_middleware_next(): return resp for listener in self._async_listeners: - listener_name = listener.func.__name__ + listener_name = listener.ack_function.__name__ self._framework_logger.debug(f"Checking listener: {listener_name} ...") if await listener.async_matches(req=req, resp=resp): # run all the middleware attached to this listener first @@ -338,63 +344,91 @@ async def run_async_listener( ack = request.context.ack starting_time = time.time() if self._process_before_response: - try: - returned_value = await async_listener.run_ack_function( - request=request, response=response - ) - if isinstance(returned_value, BoltResponse): - response = returned_value - if ack.response is None and async_listener.auto_acknowledgement: - await ack() # automatic ack() call if the call is not yet done - except Exception as e: - # The default response status code is 500 in this case. - # You can customize this by passing your own error handler. - if response is None: - response = BoltResponse(status=500) - response.status = 500 - await self._async_listener_error_handler.handle( - error=e, request=request, response=response, - ) - ack.response = response - - if response is not None: - self._debug_log_completion(starting_time, response) - return response - elif ack.response is not None: - self._debug_log_completion(starting_time, ack.response) - return ack.response - else: - if async_listener.auto_acknowledgement: - # acknowledge immediately in case of Events API - await ack() - - # start the listener function asynchronously - # NOTE: intentionally - async def run_ack_function_asynchronously( - ack: AsyncAck, request: AsyncBoltRequest, response: BoltResponse, - ): + if not request.lazy_only: try: - await async_listener.run_ack_function( + returned_value = await async_listener.run_ack_function( request=request, response=response ) + if isinstance(returned_value, BoltResponse): + response = returned_value + if ack.response is None and async_listener.auto_acknowledgement: + await ack() # automatic ack() call if the call is not yet done except Exception as e: # The default response status code is 500 in this case. # You can customize this by passing your own error handler. if response is None: response = BoltResponse(status=500) response.status = 500 - if ack.response is not None: # already acknowledged - response = None - await self._async_listener_error_handler.handle( error=e, request=request, response=response, ) ack.response = response - _f: Future = asyncio.ensure_future( - run_ack_function_asynchronously(ack, request, response) - ) - self._framework_logger.debug(f"Async listener: {listener_name} started..") + for lazy_func in async_listener.lazy_functions: + if request.lazy_function_name: + func_name = lazy_func.__name__ + if func_name == request.lazy_function_name: + return await self.lazy_listener_runner.run( + function=lazy_func, request=request + ) + else: + continue + else: + self._start_lazy_function(lazy_func, request) + + if response is not None: + self._debug_log_completion(starting_time, response) + return response + elif ack.response is not None: + self._debug_log_completion(starting_time, ack.response) + return ack.response + else: + if async_listener.auto_acknowledgement: + # acknowledge immediately in case of Events API + await ack() + + if not request.lazy_only: + # start the listener function asynchronously + # NOTE: intentionally + async def run_ack_function_asynchronously( + ack: AsyncAck, request: AsyncBoltRequest, response: BoltResponse, + ): + try: + await async_listener.run_ack_function( + request=request, response=response + ) + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + if ack.response is not None: # already acknowledged + response = None + + await self._async_listener_error_handler.handle( + error=e, request=request, response=response, + ) + ack.response = response + + _f: Future = asyncio.ensure_future( + run_ack_function_asynchronously(ack, request, response) + ) + self._framework_logger.debug( + f"Async listener: {listener_name} started.." + ) + + for lazy_func in async_listener.lazy_functions: + if request.lazy_function_name: + func_name = lazy_func.__name__ + if func_name == request.lazy_function_name: + return await self.lazy_listener_runner.run( + function=lazy_func, request=request + ) + else: + continue + else: + self._start_lazy_function(lazy_func, request) # await for the completion of ack() in the async listener execution while ack.response is None and time.time() - starting_time <= 3: @@ -415,6 +449,25 @@ async def run_ack_function_asynchronously( # None for both means no ack() in the listener return None + def _start_lazy_function( + self, lazy_func: Callable[..., Awaitable[None]], request: AsyncBoltRequest + ): + # Start a lazy function asynchronously + func_name: str = lazy_func.__name__ + self._framework_logger.debug(f"Running lazy listener: {func_name} ...") + copied_request = self._build_lazy_request(request, func_name) + self.lazy_listener_runner.start(function=lazy_func, request=copied_request) + + @staticmethod + def _build_lazy_request( + request: AsyncBoltRequest, lazy_func_name: str + ) -> AsyncBoltRequest: + copied_request = _copy_object(request) + copied_request.method = "NONE" + copied_request.lazy_only = True + copied_request.lazy_function_name = lazy_func_name + return copied_request + def _debug_log_completion( self, starting_time: float, response: BoltResponse ) -> None: @@ -455,10 +508,11 @@ def event( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, True) return self._register_listener( - func, primary_matcher, matchers, middleware, True + list(functions), primary_matcher, matchers, middleware, True ) return __call__ @@ -472,11 +526,12 @@ def message( matchers = matchers if matchers else [] middleware = middleware if middleware else [] - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event("message", True) middleware.append(AsyncMessageListenerMatches(keyword)) return self._register_listener( - func, primary_matcher, matchers, middleware, True + list(functions), primary_matcher, matchers, middleware, True ) return __call__ @@ -490,9 +545,12 @@ def command( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.command(command, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -505,9 +563,12 @@ def shortcut( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.shortcut(constraints, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -517,9 +578,12 @@ def global_shortcut( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.global_shortcut(callback_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -529,9 +593,12 @@ def message_shortcut( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.message_shortcut(callback_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -544,9 +611,12 @@ def action( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.action(constraints, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -556,9 +626,12 @@ def block_action( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.block_action(action_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -568,9 +641,12 @@ def attachment_action( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.attachment_action(callback_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -580,9 +656,12 @@ def dialog_submission( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.dialog_submission(callback_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -592,9 +671,12 @@ def dialog_cancellation( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.dialog_cancellation(callback_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -607,9 +689,12 @@ def view( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.view(constraints, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -619,9 +704,12 @@ def view_submission( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.view_submission(constraints, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -631,9 +719,12 @@ def view_closed( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.view_closed(constraints, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -646,9 +737,12 @@ def options( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.options(constraints, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -658,9 +752,12 @@ def block_suggestion( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.block_suggestion(action_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -670,9 +767,12 @@ def dialog_suggestion( matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): - def __call__(func): + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.dialog_suggestion(callback_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + return self._register_listener( + list(functions), primary_matcher, matchers, middleware + ) return __call__ @@ -683,20 +783,32 @@ def _init_context(self, req: AsyncBoltRequest): req.context["token"] = self._token req.context["client"] = self._async_client + @staticmethod + def _to_listener_functions( + kwargs: dict, + ) -> Optional[List[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + if kwargs: + functions = [kwargs["ack"]] + for sub in kwargs["lazy"]: + functions.append(sub) + return functions + return None + def _register_listener( self, - func: Callable[..., Awaitable[BoltResponse]], + functions: List[Callable[..., Awaitable[Optional[BoltResponse]]]], primary_matcher: AsyncListenerMatcher, matchers: Optional[List[Callable[..., Awaitable[bool]]]], middleware: Optional[List[Union[Callable, AsyncMiddleware]]], auto_acknowledgement: bool = False, ) -> None: - if not inspect.iscoroutinefunction(func): - name = func.__name__ - raise BoltError( - f"The listener function ({name}) is not a coroutine function." - ) + for func in functions: + if not inspect.iscoroutinefunction(func): + name = func.__name__ + raise BoltError( + f"The listener function ({name}) is not a coroutine function." + ) listener_matchers = [ AsyncCustomListenerMatcher(app_name=self.name, func=f) @@ -719,7 +831,8 @@ def _register_listener( self._async_listeners.append( AsyncCustomListener( app_name=self.name, - func=func, + ack_function=functions.pop(0), + lazy_functions=functions, matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, diff --git a/slack_bolt/lazy_listener/__init__.py b/slack_bolt/lazy_listener/__init__.py new file mode 100644 index 000000000..1b2968a22 --- /dev/null +++ b/slack_bolt/lazy_listener/__init__.py @@ -0,0 +1,3 @@ +# Don't add async module imports here +from .runner import LazyListenerRunner # noqa +from .thread_runner import ThreadLazyListenerRunner # noqa diff --git a/slack_bolt/lazy_listener/async_internals.py b/slack_bolt/lazy_listener/async_internals.py new file mode 100644 index 000000000..a3e3bfe79 --- /dev/null +++ b/slack_bolt/lazy_listener/async_internals.py @@ -0,0 +1,31 @@ +import inspect +from functools import wraps +from logging import Logger +from typing import Callable, Awaitable + +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs +from slack_bolt.request.async_request import AsyncBoltRequest + + +async def to_runnable_function( + internal_func: Callable[..., Awaitable[None]], + logger: Logger, + request: AsyncBoltRequest, +): + arg_names = inspect.getfullargspec(internal_func).args + + @wraps(internal_func) + async def request_wired_wrapper() -> None: + try: + await internal_func( + **build_async_required_kwargs( + logger=logger, + required_arg_names=arg_names, + request=request, + response=None, + ) + ) + except Exception as e: + logger.error(f"Failed to run an internal function ({e})") + + return await request_wired_wrapper() diff --git a/slack_bolt/lazy_listener/async_runner.py b/slack_bolt/lazy_listener/async_runner.py new file mode 100644 index 000000000..e5f919869 --- /dev/null +++ b/slack_bolt/lazy_listener/async_runner.py @@ -0,0 +1,24 @@ +from abc import abstractmethod, ABCMeta +from logging import Logger +from typing import Callable, Awaitable, Any, Coroutine + +from slack_bolt.lazy_listener.async_internals import to_runnable_function +from slack_bolt.request.async_request import AsyncBoltRequest + + +class AsyncLazyListenerRunner(metaclass=ABCMeta): + logger: Logger + + @abstractmethod + def start( + self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest + ) -> None: + raise NotImplementedError() + + async def run( + self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest + ) -> None: + func = to_runnable_function( + internal_func=function, logger=self.logger, request=request, + ) + return await func() # type: ignore diff --git a/slack_bolt/lazy_listener/asyncio_runner.py b/slack_bolt/lazy_listener/asyncio_runner.py new file mode 100644 index 000000000..f9ceb6cf3 --- /dev/null +++ b/slack_bolt/lazy_listener/asyncio_runner.py @@ -0,0 +1,25 @@ +import asyncio +from logging import Logger +from typing import Callable, Awaitable + +from slack_bolt.lazy_listener.async_internals import to_runnable_function +from slack_bolt.lazy_listener.async_runner import AsyncLazyListenerRunner +from slack_bolt.request.async_request import AsyncBoltRequest + + +class AsyncioLazyListenerRunner(AsyncLazyListenerRunner): + logger: Logger + + def __init__( + self, logger: Logger, + ): + self.logger = logger + + def start( + self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest + ) -> None: + asyncio.ensure_future( + to_runnable_function( + internal_func=function, logger=self.logger, request=request, + ) + ) diff --git a/slack_bolt/lazy_listener/internals.py b/slack_bolt/lazy_listener/internals.py new file mode 100644 index 000000000..ee6659540 --- /dev/null +++ b/slack_bolt/lazy_listener/internals.py @@ -0,0 +1,29 @@ +import inspect +from functools import wraps +from logging import Logger +from typing import Callable + +from slack_bolt.kwargs_injection import build_required_kwargs +from slack_bolt.request import BoltRequest + + +def build_runnable_function( + func: Callable[..., None], logger: Logger, request: BoltRequest, +) -> Callable[[], None]: + arg_names = inspect.getfullargspec(func).args + + @wraps(func) + def request_wired_func_wrapper() -> None: + try: + func( + **build_required_kwargs( + logger=logger, + required_arg_names=arg_names, + request=request, + response=None, + ) + ) + except Exception as e: + logger.error(f"Failed to run an internal function ({e})") + + return request_wired_func_wrapper diff --git a/slack_bolt/lazy_listener/runner.py b/slack_bolt/lazy_listener/runner.py new file mode 100644 index 000000000..565c83080 --- /dev/null +++ b/slack_bolt/lazy_listener/runner.py @@ -0,0 +1,17 @@ +from abc import abstractmethod, ABCMeta +from logging import Logger +from typing import Callable + +from slack_bolt.lazy_listener.internals import build_runnable_function +from slack_bolt.request import BoltRequest + + +class LazyListenerRunner(metaclass=ABCMeta): + logger: Logger + + @abstractmethod + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + raise NotImplementedError() + + def run(self, function: Callable[..., None], request: BoltRequest) -> None: + build_runnable_function(func=function, logger=self.logger, request=request,)() diff --git a/slack_bolt/lazy_listener/thread_runner.py b/slack_bolt/lazy_listener/thread_runner.py new file mode 100644 index 000000000..c6812a46a --- /dev/null +++ b/slack_bolt/lazy_listener/thread_runner.py @@ -0,0 +1,22 @@ +from concurrent.futures.thread import ThreadPoolExecutor +from logging import Logger +from typing import Callable + +from slack_bolt.lazy_listener.internals import build_runnable_function +from slack_bolt.lazy_listener.runner import LazyListenerRunner +from slack_bolt.request import BoltRequest + + +class ThreadLazyListenerRunner(LazyListenerRunner): + logger: Logger + + def __init__( + self, logger: Logger, executor: ThreadPoolExecutor, + ): + self.logger = logger + self.executor = executor + + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + self.executor.submit( + build_runnable_function(func=function, logger=self.logger, request=request,) + ) diff --git a/slack_bolt/listener/async_listener.py b/slack_bolt/listener/async_listener.py index 55186847e..0535290b4 100644 --- a/slack_bolt/listener/async_listener.py +++ b/slack_bolt/listener/async_listener.py @@ -1,5 +1,5 @@ from abc import abstractmethod, ABCMeta -from typing import List, Callable, Awaitable, Tuple +from typing import List, Callable, Awaitable, Tuple, Optional from slack_bolt.listener_matcher.async_listener_matcher import AsyncListenerMatcher from slack_bolt.middleware.async_middleware import AsyncMiddleware @@ -11,7 +11,8 @@ class AsyncListener(metaclass=ABCMeta): matchers: List[AsyncListenerMatcher] middleware: List[AsyncMiddleware] - func: Callable[..., Awaitable[BoltResponse]] + ack_function: Callable[..., Awaitable[BoltResponse]] + lazy_functions: List[Callable[..., Awaitable[None]]] auto_acknowledgement: bool async def async_matches( @@ -71,7 +72,8 @@ async def run_ack_function( class AsyncCustomListener(AsyncListener): app_name: str - func: Callable[..., Awaitable[BoltResponse]] + ack_function: Callable[..., Awaitable[Optional[BoltResponse]]] + lazy_functions: List[Callable[..., Awaitable[None]]] matchers: List[AsyncListenerMatcher] middleware: List[AsyncMiddleware] auto_acknowledgement: bool @@ -82,23 +84,25 @@ def __init__( self, *, app_name: str, - func: Callable[..., Awaitable[BoltResponse]], + ack_function: Callable[..., Awaitable[Optional[BoltResponse]]], + lazy_functions: List[Callable[..., Awaitable[None]]], matchers: List[AsyncListenerMatcher], middleware: List[AsyncMiddleware], auto_acknowledgement: bool = False, ): self.app_name = app_name - self.func = func + self.ack_function = ack_function + self.lazy_functions = lazy_functions self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement - self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(app_name, self.func) + self.arg_names = inspect.getfullargspec(ack_function).args + self.logger = get_bolt_app_logger(app_name, self.ack_function) async def run_ack_function( self, *, request: AsyncBoltRequest, response: BoltResponse, - ) -> BoltResponse: - return await self.func( + ) -> Optional[BoltResponse]: + return await self.ack_function( **build_async_required_kwargs( logger=self.logger, required_arg_names=self.arg_names, diff --git a/slack_bolt/listener/custom_listener.py b/slack_bolt/listener/custom_listener.py index 8f4cfbf9c..45e552ff6 100644 --- a/slack_bolt/listener/custom_listener.py +++ b/slack_bolt/listener/custom_listener.py @@ -1,6 +1,6 @@ import inspect from logging import Logger -from typing import Callable, List +from typing import Callable, List, Optional from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.listener_matcher import ListenerMatcher @@ -13,7 +13,8 @@ class CustomListener(Listener): app_name: str - func: Callable[..., BoltResponse] + ack_function: Callable[..., Optional[BoltResponse]] + lazy_functions: List[Callable[..., None]] matchers: List[ListenerMatcher] middleware: List[Middleware] # type: ignore auto_acknowledgement: bool @@ -24,23 +25,25 @@ def __init__( self, *, app_name: str, - func: Callable[..., BoltResponse], + ack_function: Callable[..., Optional[BoltResponse]], + lazy_functions: List[Callable[..., None]], matchers: List[ListenerMatcher], middleware: List[Middleware], # type: ignore auto_acknowledgement: bool = False, ): self.app_name = app_name - self.func = func + self.ack_function = ack_function + self.lazy_functions = lazy_functions self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement - self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(app_name, self.func) + self.arg_names = inspect.getfullargspec(ack_function).args + self.logger = get_bolt_app_logger(app_name, self.ack_function) def run_ack_function( self, *, request: BoltRequest, response: BoltResponse, - ) -> BoltResponse: - return self.func( + ) -> Optional[BoltResponse]: + return self.ack_function( **build_required_kwargs( logger=self.logger, required_arg_names=self.arg_names, diff --git a/slack_bolt/listener/listener.py b/slack_bolt/listener/listener.py index c097c09e3..aa87f14ef 100644 --- a/slack_bolt/listener/listener.py +++ b/slack_bolt/listener/listener.py @@ -10,7 +10,8 @@ class Listener(metaclass=ABCMeta): matchers: List[ListenerMatcher] middleware: List[Middleware] # type: ignore - func: Callable[..., BoltResponse] + ack_function: Callable[..., BoltResponse] + lazy_functions: List[Callable[..., None]] auto_acknowledgement: bool def matches(self, *, req: BoltRequest, resp: BoltResponse,) -> bool: diff --git a/slack_bolt/request/async_request.py b/slack_bolt/request/async_request.py index f2b9c11a6..d6fe634a1 100644 --- a/slack_bolt/request/async_request.py +++ b/slack_bolt/request/async_request.py @@ -17,6 +17,8 @@ class AsyncBoltRequest: content_type: Optional[str] payload: Dict[str, Any] context: AsyncBoltContext + lazy_only: bool + lazy_function_name: Optional[str] def __init__( self, @@ -35,3 +37,7 @@ def __init__( self.context = build_async_context( AsyncBoltContext(context if context else {}), self.payload ) + self.lazy_only = self.headers.get("x-slack-bolt-lazy-only", [False])[0] + self.lazy_function_name = self.headers.get( + "x-slack-bolt-lazy-function-name", [None] + )[0] diff --git a/slack_bolt/request/request.py b/slack_bolt/request/request.py index ea3d0c27f..ea4520024 100644 --- a/slack_bolt/request/request.py +++ b/slack_bolt/request/request.py @@ -17,6 +17,8 @@ class BoltRequest: content_type: Optional[str] payload: Dict[str, Any] context: BoltContext + lazy_only: bool + lazy_function_name: Optional[str] def __init__( self, @@ -35,3 +37,7 @@ def __init__( self.context = build_context( BoltContext(context if context else {}), self.payload ) + self.lazy_only = self.headers.get("x-slack-bolt-lazy-only", [False])[0] + self.lazy_function_name = self.headers.get( + "x-slack-bolt-lazy-function-name", [None] + )[0] diff --git a/slack_bolt/util/utils.py b/slack_bolt/util/utils.py index 09ce24bad..55e971c7c 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -1,4 +1,6 @@ -from typing import Optional, Union, Dict, List +import copy +import sys +from typing import Optional, Union, Dict, List, Any from slack_sdk import WebClient from slack_sdk.models import JsonObject @@ -21,3 +23,19 @@ def _to_dict(obj: Union[Dict, JsonObject]) -> Dict: if isinstance(obj, JsonObject) or hasattr(obj, "to_dict"): return obj.to_dict() raise BoltError(f"{obj} (type: {type(obj)}) is unsupported") + + +def _copy_object(original: Any) -> Any: + if sys.version_info.major == 3 and sys.version_info.minor <= 6: + # NOTE: Unfortunately, copy.deepcopy doesn't work in Python 3.6.5. + # -------------------- + # > rv = reductor(4) + # E TypeError: can't pickle _thread.RLock objects + # ../../.pyenv/versions/3.6.10/lib/python3.6/copy.py:169: TypeError + # -------------------- + # As a workaround, this operation uses shallow copies in Python 3.6. + # If your code modifies the shared data in threads / async functions, race conditions may arise. + # Please consider upgrading Python major version to 3.7+ if you encounter some issues due to this. + return copy.copy(original) + else: + return copy.deepcopy(original) diff --git a/tests/async_scenario_tests/test_lazy.py b/tests/async_scenario_tests/test_lazy.py new file mode 100644 index 000000000..de7fa758e --- /dev/null +++ b/tests/async_scenario_tests/test_lazy.py @@ -0,0 +1,107 @@ +import asyncio +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.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 TestAsyncLazy: + 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,) + + @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) + + # ---------------- + # utilities + # ---------------- + + 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): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> AsyncBoltRequest: + payload = { + "type": "block_actions", + "user": {"id": "W111",}, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "team": {"id": "T111",}, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], + } + raw_body = f"payload={quote(json.dumps(payload))}" + timestamp = str(int(time())) + return AsyncBoltRequest( + body=raw_body, headers=self.build_headers(timestamp, raw_body) + ) + + # ---------------- + # tests + # ---------------- + + @pytest.mark.asyncio + async def test_lazy(self): + async def just_ack(ack): + await ack() + + async def async1(say): + await asyncio.sleep(0.3) + await say(text="lazy function 1") + + async def async2(say): + await asyncio.sleep(0.5) + await say(text="lazy function 2") + + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.action("a")( + ack=just_ack, lazy=[async1, async2], + ) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await asyncio.sleep(1) # wait a bit + assert self.mock_received_requests["/chat.postMessage"] == 2 diff --git a/tests/scenario_tests/test_lazy.py b/tests/scenario_tests/test_lazy.py new file mode 100644 index 000000000..facd2cc6a --- /dev/null +++ b/tests/scenario_tests/test_lazy.py @@ -0,0 +1,100 @@ +import json +import time +from urllib.parse import quote + +from slack_sdk import WebClient +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import BoltRequest +from slack_bolt.app import App +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 TestErrorHandler: + 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) + + # ---------------- + # utilities + # ---------------- + + 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): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> BoltRequest: + payload = { + "type": "block_actions", + "user": {"id": "W111",}, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "team": {"id": "T111",}, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], + } + raw_body = f"payload={quote(json.dumps(payload))}" + timestamp = str(int(time.time())) + return BoltRequest( + body=raw_body, headers=self.build_headers(timestamp, raw_body) + ) + + # ---------------- + # tests + # ---------------- + + def test_lazy(self): + def just_ack(ack): + ack() + + def async1(say): + time.sleep(0.3) + say(text="lazy function 1") + + def async2(say): + time.sleep(0.5) + say(text="lazy function 2") + + app = App(client=self.web_client, signing_secret=self.signing_secret,) + app.action("a")( + ack=just_ack, lazy=[async1, async2], + ) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + time.sleep(1) # wait a bit + assert self.mock_received_requests["/chat.postMessage"] == 2 From 697a224906884e5f140e833c0ea982622ec1d863 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 26 Aug 2020 16:20:42 +0900 Subject: [PATCH 007/865] Update .github templates --- .github/ISSUE_TEMPLATE/01_question.md | 2 +- .github/ISSUE_TEMPLATE/02_enhancement.md | 2 +- .github/ISSUE_TEMPLATE/03_document.md | 2 +- .github/ISSUE_TEMPLATE/04_bug.md | 2 +- .github/pull_request_template.md | 4 +--- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/01_question.md b/.github/ISSUE_TEMPLATE/01_question.md index d8dc6433e..8e95720eb 100644 --- a/.github/ISSUE_TEMPLATE/01_question.md +++ b/.github/ISSUE_TEMPLATE/01_question.md @@ -46,4 +46,4 @@ sw_vers && uname -v # or `ver` ## Requirements -Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to the those rules. +Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. diff --git a/.github/ISSUE_TEMPLATE/02_enhancement.md b/.github/ISSUE_TEMPLATE/02_enhancement.md index fd9007c24..658f22f11 100644 --- a/.github/ISSUE_TEMPLATE/02_enhancement.md +++ b/.github/ISSUE_TEMPLATE/02_enhancement.md @@ -16,4 +16,4 @@ assignees: '' ## Requirements -Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to the those rules. +Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. diff --git a/.github/ISSUE_TEMPLATE/03_document.md b/.github/ISSUE_TEMPLATE/03_document.md index 7e0f8e2cc..fbd07e438 100644 --- a/.github/ISSUE_TEMPLATE/03_document.md +++ b/.github/ISSUE_TEMPLATE/03_document.md @@ -14,4 +14,4 @@ assignees: '' ## Requirements -Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to the those rules. +Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. diff --git a/.github/ISSUE_TEMPLATE/04_bug.md b/.github/ISSUE_TEMPLATE/04_bug.md index 04c3eda54..e5191ee02 100644 --- a/.github/ISSUE_TEMPLATE/04_bug.md +++ b/.github/ISSUE_TEMPLATE/04_bug.md @@ -46,4 +46,4 @@ sw_vers && uname -v # or `ver` ## Requirements -Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to the those rules. +Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c2001a38d..6b8e8c8c7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,3 @@ -## Summary - (Describe the goal of this PR. Mention any related Issue numbers) ### Category (place an `x` in each of the `[ ]`) @@ -10,7 +8,7 @@ ## Requirements (place an `x` in each `[ ]`) -Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to the those rules. +Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. * [ ] I've read and understood the [Contributing Guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and have done my best effort to follow them. * [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct). From a8b25c0d22c4b6ea16caf491ae8b2ac43b0fea3c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 26 Aug 2020 18:21:38 +0900 Subject: [PATCH 008/865] Fix #38 by adding samples using Google Cloud Functions (#45) * Fix #38 by adding samples using Google Cloud Functions * Add process_before_response=True --- .../google_cloud_functions/.env.yaml.sample | 2 + samples/google_cloud_functions/.gcloudignore | 18 ++++++ samples/google_cloud_functions/.gitignore | 1 + samples/google_cloud_functions/main.py | 60 +++++++++++++++++++ .../google_cloud_functions/requirements.txt | 2 + 5 files changed, 83 insertions(+) create mode 100644 samples/google_cloud_functions/.env.yaml.sample create mode 100644 samples/google_cloud_functions/.gcloudignore create mode 100644 samples/google_cloud_functions/.gitignore create mode 100644 samples/google_cloud_functions/main.py create mode 100644 samples/google_cloud_functions/requirements.txt diff --git a/samples/google_cloud_functions/.env.yaml.sample b/samples/google_cloud_functions/.env.yaml.sample new file mode 100644 index 000000000..3a2d4c08f --- /dev/null +++ b/samples/google_cloud_functions/.env.yaml.sample @@ -0,0 +1,2 @@ +SLACK_SIGNING_SECRET: xxx +SLACK_BOT_TOKEN: xoxb-xxx \ No newline at end of file diff --git a/samples/google_cloud_functions/.gcloudignore b/samples/google_cloud_functions/.gcloudignore new file mode 100644 index 000000000..1451c7b19 --- /dev/null +++ b/samples/google_cloud_functions/.gcloudignore @@ -0,0 +1,18 @@ +# This file specifies files that are *not* uploaded to Google Cloud Platform +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore +.env.yaml.sample + +node_modules +#!include:.gitignore diff --git a/samples/google_cloud_functions/.gitignore b/samples/google_cloud_functions/.gitignore new file mode 100644 index 000000000..69748e961 --- /dev/null +++ b/samples/google_cloud_functions/.gitignore @@ -0,0 +1 @@ +.env.yaml \ No newline at end of file diff --git a/samples/google_cloud_functions/main.py b/samples/google_cloud_functions/main.py new file mode 100644 index 000000000..76b9c7a9e --- /dev/null +++ b/samples/google_cloud_functions/main.py @@ -0,0 +1,60 @@ +# https://cloud.google.com/functions/docs/first-python + +import logging + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App + +# process_before_response must be True when running on FaaS +app = App(process_before_response=True) + + +@app.command("/hello-bolt-python-gcp") +def hello_command(ack): + ack("Hi from Google Cloud Functions!") + + +@app.event("app_mention") +def event_test(ack, payload, say, logger): + logger.info(payload) + say("Hi from Google Cloud Functions!") + + +# Flask adapter +from slack_bolt.adapter.flask import SlackRequestHandler + +handler = SlackRequestHandler(app) + +# Cloud Function +def hello_bolt_app(request): + """HTTP Cloud Function. + Args: + request (flask.Request): The request object. + + Returns: + The response text, or any set of values that can be turned into a + Response object using `make_response` + . + """ + return handler.handle(request) + +# Step1: Create a new Slack App: https://api.slack.com/apps +# Bot Token Scopes: chat:write, commands, app_mentions:read + +# Step2: Set env variables +# cp .env.yaml.sample .env.yaml +# vi .env.yaml + +# Step3: Create a new Google Cloud project +# gcloud projects create YOUR_PROJECT_NAME +# gcloud config set project YOUR_PROJECT_NAME + +# Step4: Deploy a function in the project +# gcloud functions deploy hello_bolt_app --runtime python38 --trigger-http --allow-unauthenticated --env-vars-file .env.yaml +# gcloud functions describe hello_bolt_app + +# Step5: Set Request URL +# Set https://us-central1-YOUR_PROJECT_NAME.cloudfunctions.net/hello_bolt_app to the following: +# * slash command: /hello-bolt-python-gcp +# * Events Subscriptions & add `app_mention` event \ No newline at end of file diff --git a/samples/google_cloud_functions/requirements.txt b/samples/google_cloud_functions/requirements.txt new file mode 100644 index 000000000..d682b1606 --- /dev/null +++ b/samples/google_cloud_functions/requirements.txt @@ -0,0 +1,2 @@ +Flask>1 +slack_bolt \ No newline at end of file From 33fa9ee1874880c2d09ec789e093c0719c43c50e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 26 Aug 2020 18:27:00 +0900 Subject: [PATCH 009/865] version 0.3.0a0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 0412dd0aa..01b04d220 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.2.3a0" +__version__ = "0.3.0a0" From a1525288b9d1195573f7908b58bb2c000549f0b0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 27 Aug 2020 07:44:36 +0900 Subject: [PATCH 010/865] Tweak scripts and apply black formatter --- samples/google_cloud_functions/main.py | 3 ++- scripts/build_pypi_package.sh | 4 ++-- scripts/deploy_to_prod_pypi_org.sh | 4 ++-- scripts/deploy_to_test_pypi_org.sh | 4 ++-- setup.py | 9 ++------- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/samples/google_cloud_functions/main.py b/samples/google_cloud_functions/main.py index 76b9c7a9e..edea87604 100644 --- a/samples/google_cloud_functions/main.py +++ b/samples/google_cloud_functions/main.py @@ -39,6 +39,7 @@ def hello_bolt_app(request): """ return handler.handle(request) + # Step1: Create a new Slack App: https://api.slack.com/apps # Bot Token Scopes: chat:write, commands, app_mentions:read @@ -57,4 +58,4 @@ def hello_bolt_app(request): # Step5: Set Request URL # Set https://us-central1-YOUR_PROJECT_NAME.cloudfunctions.net/hello_bolt_app to the following: # * slash command: /hello-bolt-python-gcp -# * Events Subscriptions & add `app_mention` event \ No newline at end of file +# * Events Subscriptions & add `app_mention` event diff --git a/scripts/build_pypi_package.sh b/scripts/build_pypi_package.sh index eb073b6b0..668f31439 100755 --- a/scripts/build_pypi_package.sh +++ b/scripts/build_pypi_package.sh @@ -1,7 +1,7 @@ #!/bin/bash -./scripts/run_tests.sh && \ - pip install -U pip && \ +pip install -U pip && \ + python setup.py test && \ pip install twine wheel && \ rm -rf dist/ build/ slack_bolt.egg-info/ && \ python setup.py sdist bdist_wheel && \ diff --git a/scripts/deploy_to_prod_pypi_org.sh b/scripts/deploy_to_prod_pypi_org.sh index 8c9ef5bca..73093dd10 100755 --- a/scripts/deploy_to_prod_pypi_org.sh +++ b/scripts/deploy_to_prod_pypi_org.sh @@ -1,7 +1,7 @@ #!/bin/bash -./scripts/run_tests.sh && \ - pip install -U pip && \ +pip install -U pip && \ + python setup.py test && \ pip install twine wheel && \ rm -rf dist/ build/ slack_bolt.egg-info/ && \ python setup.py sdist bdist_wheel && \ diff --git a/scripts/deploy_to_test_pypi_org.sh b/scripts/deploy_to_test_pypi_org.sh index 37583036f..74971811c 100755 --- a/scripts/deploy_to_test_pypi_org.sh +++ b/scripts/deploy_to_test_pypi_org.sh @@ -1,7 +1,7 @@ #!/bin/bash -./scripts/run_tests.sh && \ - pip install -U pip && \ +pip install -U pip && \ + python setup.py test && \ pip install twine wheel && \ rm -rf dist/ build/ slack_bolt.egg-info/ && \ python setup.py sdist bdist_wheel && \ diff --git a/setup.py b/setup.py index e0a63dfda..f4ced04d7 100755 --- a/setup.py +++ b/setup.py @@ -30,15 +30,10 @@ long_description_content_type="text/markdown", url="https://github.com/slackapi/bolt-python", packages=setuptools.find_packages( - exclude=[ - "samples", - "integration_tests", - "tests", - "tests.*", - ] + exclude=["samples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk==3.0.0a4", ], + install_requires=["slack_sdk==3.0.0a4",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", From de5e3a198da44c2730aaf7956257b669eb3cc727 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 27 Aug 2020 07:46:59 +0900 Subject: [PATCH 011/865] Add DeepSource config --- .deepsource.toml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .deepsource.toml diff --git a/.deepsource.toml b/.deepsource.toml new file mode 100644 index 000000000..6740e2f9a --- /dev/null +++ b/.deepsource.toml @@ -0,0 +1,24 @@ +# https://deepsource.io/docs/config/deepsource-toml.html +# https://deepsource.io/docs/analyzer/python.html + +version = 1 + +test_patterns = [ + "tests/*.py", + "tests/**/*.py" +] + +exclude_patterns = [ + "setup.py", + "samples/**", + "tests/**", +] + +[[analyzers]] +name = "python" +enabled = true + + [analyzers.meta] + runtime_version = "3.x.x" + max_line_length = 125 + From d1b1204a24b78cdd669e00ad45924d9a05e65cf0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 27 Aug 2020 16:11:02 +0900 Subject: [PATCH 012/865] Fix OAuth failure pages for AWS Lambda --- slack_bolt/adapter/aws_lambda/chalice_handler.py | 2 ++ slack_bolt/adapter/aws_lambda/handler.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/slack_bolt/adapter/aws_lambda/chalice_handler.py b/slack_bolt/adapter/aws_lambda/chalice_handler.py index a5f078ffc..3c7fee983 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_handler.py +++ b/slack_bolt/adapter/aws_lambda/chalice_handler.py @@ -19,6 +19,8 @@ def __init__(self, app: App, chalice: Chalice): # type: ignore self.chalice = chalice self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler) self.app.lazy_listener_runner = ChaliceLazyListenerRunner(logger=self.logger) + if self.app.oauth_flow is not None: + self.app.oauth_flow.redirect_uri_page_renderer.install_path = "?" @classmethod def clear_all_log_handlers(cls): diff --git a/slack_bolt/adapter/aws_lambda/handler.py b/slack_bolt/adapter/aws_lambda/handler.py index 7dbcefa96..7b7adbecb 100644 --- a/slack_bolt/adapter/aws_lambda/handler.py +++ b/slack_bolt/adapter/aws_lambda/handler.py @@ -16,6 +16,8 @@ def __init__(self, app: App): # type: ignore self.app = app self.logger = get_bolt_app_logger(app.name, SlackRequestHandler) self.app.lazy_listener_runner = LambdaLazyListenerRunner(self.logger) + if self.app.oauth_flow is not None: + self.app.oauth_flow.redirect_uri_page_renderer.install_path = "?" @classmethod def clear_all_log_handlers(cls): From e93c62f888bd3eb751d409da263b48304e3695e4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 27 Aug 2020 22:50:24 +0900 Subject: [PATCH 013/865] Add tests for flask/lambda/starlette adapters (#49) * Add tests for flask/lambda/starlette adapters * Add requests * Update tests * Make boto3 client lazy --- .travis.yml | 9 +- samples/aws_lambda/requirements.txt | 2 +- samples/aws_lambda/requirements_oauth.txt | 2 +- scripts/install_all_and_run_tests.sh | 2 + setup.py | 2 + .../chalice_lazy_listener_runner.py | 9 +- .../aws_lambda/lazy_listener_runner.py | 9 +- tests/adapter_tests/__init__.py | 0 tests/adapter_tests/test_async_fastapi.py | 190 +++++++++++++++++ tests/adapter_tests/test_async_starlette.py | 200 ++++++++++++++++++ tests/adapter_tests/test_aws_lambda.py | 184 ++++++++++++++++ tests/adapter_tests/test_fastapi.py | 190 +++++++++++++++++ tests/adapter_tests/test_flask.py | 179 ++++++++++++++++ tests/adapter_tests/test_starlette.py | 199 +++++++++++++++++ 14 files changed, 1166 insertions(+), 11 deletions(-) create mode 100644 tests/adapter_tests/__init__.py create mode 100644 tests/adapter_tests/test_async_fastapi.py create mode 100644 tests/adapter_tests/test_async_starlette.py create mode 100644 tests/adapter_tests/test_aws_lambda.py create mode 100644 tests/adapter_tests/test_fastapi.py create mode 100644 tests/adapter_tests/test_flask.py create mode 100644 tests/adapter_tests/test_starlette.py diff --git a/.travis.yml b/.travis.yml index 974b6b9b5..564e64c18 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,14 +10,17 @@ install: # https://discuss.python.org/t/announcement-pip-20-2-release/4863 - pip config set global.use-feature 2020-resolver - pip install "pytest>=5,<6" - - pip install "pytype" script: # testing without aiohttp - travis_retry pytest tests/scenario_tests/ # testing with aiohttp - - pip install "pytest-asyncio<1" "aiohttp>=3,<4" + - pip install -e ".[async]" + - pip install "pytest-asyncio<1" - travis_retry pytest tests/async_scenario_tests/ + # testing for adapters + - pip install -e ".[adapter]" + - travis_retry pytest tests/adapter_tests/ # run all tests just in case - travis_retry python setup.py test # Run pytype only for Python 3.8 - - if [ ${TRAVIS_PYTHON_VERSION:0:3} == "3.8" ]; then pip install -e ".[adapter]" && pytype slack_bolt/; fi + - if [ ${TRAVIS_PYTHON_VERSION:0:3} == "3.8" ]; then pip install "pytype" && pytype slack_bolt/; fi diff --git a/samples/aws_lambda/requirements.txt b/samples/aws_lambda/requirements.txt index 050991ad6..bb964f6ea 100644 --- a/samples/aws_lambda/requirements.txt +++ b/samples/aws_lambda/requirements.txt @@ -1 +1 @@ -slack_sdk==3.0.0a3 \ No newline at end of file +slack_sdk \ No newline at end of file diff --git a/samples/aws_lambda/requirements_oauth.txt b/samples/aws_lambda/requirements_oauth.txt index 050991ad6..bb964f6ea 100644 --- a/samples/aws_lambda/requirements_oauth.txt +++ b/samples/aws_lambda/requirements_oauth.txt @@ -1 +1 @@ -slack_sdk==3.0.0a3 \ No newline at end of file +slack_sdk \ No newline at end of file diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index a1dbb8e2c..61774fb20 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -11,10 +11,12 @@ test_target="$1" if [[ $test_target != "" ]] then pip install -e ".[testing]" && \ + pip install -e ".[adapter]" && \ black slack_bolt/ tests/ && \ pytest $1 else pip install -e ".[testing]" && \ + pip install -e ".[adapter]" && \ black slack_bolt/ tests/ && \ pytest && \ pytype slack_bolt/ diff --git a/setup.py b/setup.py index f4ced04d7..d9a2d56f5 100755 --- a/setup.py +++ b/setup.py @@ -49,6 +49,7 @@ "aiohttp>=3,<4", # used only under src/slack_bolt/adapter "boto3<=2", + "moto<=2", # For AWS tests "bottle>=0.12,<1", "chalice>=1,<2", "click>=7,<8", # for chalice @@ -59,6 +60,7 @@ "pyramid>=1,<2", "sanic>=20,<21", "starlette>=0.13,<1", + "requests>=2,<3", # For starlette's TestClient "tornado>=6,<7", # server "uvicorn<1", diff --git a/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py b/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py index bb61cc169..da71bef6d 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py +++ b/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py @@ -1,6 +1,6 @@ import json from logging import Logger -from typing import Callable +from typing import Callable, Optional, Any import boto3 @@ -9,11 +9,14 @@ class ChaliceLazyListenerRunner(LazyListenerRunner): - def __init__(self, logger: Logger): - self.lambda_client = boto3.client("lambda") + def __init__(self, logger: Logger, lambda_client: Optional[Any] = None): + self.lambda_client = lambda_client self.logger = logger def start(self, function: Callable[..., None], request: BoltRequest) -> None: + if self.lambda_client is None: + self.lambda_client = boto3.client("lambda") + chalice_request: dict = request.context["chalice_request"] request.headers["x-slack-bolt-lazy-only"] = ["1"] request.headers["x-slack-bolt-lazy-function-name"] = [ diff --git a/slack_bolt/adapter/aws_lambda/lazy_listener_runner.py b/slack_bolt/adapter/aws_lambda/lazy_listener_runner.py index ca95e7fe8..6e0e7e9a9 100644 --- a/slack_bolt/adapter/aws_lambda/lazy_listener_runner.py +++ b/slack_bolt/adapter/aws_lambda/lazy_listener_runner.py @@ -1,6 +1,6 @@ import json from logging import Logger -from typing import Callable +from typing import Callable, Optional, Any import boto3 @@ -9,11 +9,14 @@ class LambdaLazyListenerRunner(LazyListenerRunner): - def __init__(self, logger: Logger): - self.lambda_client = boto3.client("lambda") + def __init__(self, logger: Logger, lambda_client: Optional[Any] = None): + self.lambda_client = lambda_client self.logger = logger def start(self, function: Callable[..., None], request: BoltRequest) -> None: + if self.lambda_client is None: + self.lambda_client = boto3.client("lambda") + event: dict = request.context["lambda_request"] headers = event["headers"] headers["x-slack-bolt-lazy-only"] = "1" # not an array diff --git a/tests/adapter_tests/__init__.py b/tests/adapter_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_async_fastapi.py b/tests/adapter_tests/test_async_fastapi.py new file mode 100644 index 000000000..0adb57115 --- /dev/null +++ b/tests/adapter_tests/test_async_fastapi.py @@ -0,0 +1,190 @@ +import json +import re +from time import time + +from fastapi import FastAPI +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient +from starlette.requests import Request +from starlette.testclient import TestClient + +from slack_bolt.adapter.fastapi import AsyncSlackRequestHandler +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 + + +class TestFastAPI: + 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): + return { + "content-type": "application/x-www-form-urlencoded", + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + def test_events(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + + async def event_handler(): + pass + + app.event("app_mention")(event_handler) + + payload = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(payload) + + api = FastAPI() + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_shortcuts(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + + async def shortcut_handler(ack): + await ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + payload = { + "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())), json.dumps(payload) + + api = FastAPI() + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_commands(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + + async def command_handler(ack): + await ack() + + app.command("/hello-world")(command_handler) + + payload = ( + "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())), json.dumps(payload) + + api = FastAPI() + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_oauth(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + api = FastAPI() + app_handler = AsyncSlackRequestHandler(app) + + @api.get("/slack/install") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.get("/slack/install", allow_redirects=False) + assert response.status_code == 302 + assert re.match( + "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", + response.headers["Location"], + ) diff --git a/tests/adapter_tests/test_async_starlette.py b/tests/adapter_tests/test_async_starlette.py new file mode 100644 index 000000000..b2b541972 --- /dev/null +++ b/tests/adapter_tests/test_async_starlette.py @@ -0,0 +1,200 @@ +import json +import re +from time import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.routing import Route +from starlette.testclient import TestClient + +from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler +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 + + +class TestAsyncStarlette: + 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): + return { + "content-type": "application/x-www-form-urlencoded", + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + def test_events(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + + async def event_handler(): + pass + + app.event("app_mention")(event_handler) + + payload = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(payload) + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])], + ) + app_handler = AsyncSlackRequestHandler(app) + + client = TestClient(api) + response = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_shortcuts(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + + async def shortcut_handler(ack): + await ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + payload = { + "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())), json.dumps(payload) + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])], + ) + app_handler = AsyncSlackRequestHandler(app) + + client = TestClient(api) + response = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_commands(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + + async def command_handler(ack): + await ack() + + app.command("/hello-world")(command_handler) + + payload = ( + "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())), json.dumps(payload) + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])], + ) + app_handler = AsyncSlackRequestHandler(app) + + client = TestClient(api) + response = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_oauth(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + app_handler = AsyncSlackRequestHandler(app) + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/install", endpoint=endpoint, methods=["GET"])], + ) + + client = TestClient(api) + response = client.get("/slack/install", allow_redirects=False) + assert response.status_code == 302 + assert re.match( + "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", + response.headers["Location"], + ) diff --git a/tests/adapter_tests/test_aws_lambda.py b/tests/adapter_tests/test_aws_lambda.py new file mode 100644 index 000000000..ad5679aff --- /dev/null +++ b/tests/adapter_tests/test_aws_lambda.py @@ -0,0 +1,184 @@ +import json +from time import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.aws_lambda import SlackRequestHandler +from slack_bolt.app import App +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 moto import mock_lambda + + +class LambdaContext: + function_name: str + + def __init__(self, function_name: str): + self.function_name = function_name + + +class TestAWSLambda: + 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,) + + context = LambdaContext(function_name="test-function") + + 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): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + @mock_lambda + 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) + + payload = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(payload) + event = { + "body": body, + "queryStringParameters": {}, + "headers": self.build_headers(timestamp, body), + "requestContext": {"http": {"method": "POST"}}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + @mock_lambda + 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) + + payload = { + "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())), json.dumps(payload) + event = { + "body": body, + "queryStringParameters": {}, + "headers": self.build_headers(timestamp, body), + "requestContext": {"http": {"method": "POST"}}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + @mock_lambda + 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) + + payload = ( + "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())), json.dumps(payload) + event = { + "body": body, + "queryStringParameters": {}, + "headers": self.build_headers(timestamp, body), + "requestContext": {"http": {"method": "POST"}}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + @mock_lambda + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + + event = { + "body": "", + "queryStringParameters": {}, + "headers": {}, + "requestContext": {"http": {"method": "GET"}}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 302 diff --git a/tests/adapter_tests/test_fastapi.py b/tests/adapter_tests/test_fastapi.py new file mode 100644 index 000000000..066f6ddb6 --- /dev/null +++ b/tests/adapter_tests/test_fastapi.py @@ -0,0 +1,190 @@ +import json +import re +from time import time + +from fastapi import FastAPI +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient +from starlette.requests import Request +from starlette.testclient import TestClient + +from slack_bolt.adapter.fastapi import SlackRequestHandler +from slack_bolt.app import App +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 TestFastAPI: + 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_headers(self, timestamp: str, body: str): + return { + "content-type": "application/x-www-form-urlencoded", + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + 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) + + payload = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(payload) + + api = FastAPI() + app_handler = SlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 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) + + payload = { + "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())), json.dumps(payload) + + api = FastAPI() + app_handler = SlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + 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) + + payload = ( + "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())), json.dumps(payload) + + api = FastAPI() + app_handler = SlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + api = FastAPI() + app_handler = SlackRequestHandler(app) + + @api.get("/slack/install") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.get("/slack/install", allow_redirects=False) + assert response.status_code == 302 + assert re.match( + "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", + response.headers["Location"], + ) diff --git a/tests/adapter_tests/test_flask.py b/tests/adapter_tests/test_flask.py new file mode 100644 index 000000000..0c0fa6ef5 --- /dev/null +++ b/tests/adapter_tests/test_flask.py @@ -0,0 +1,179 @@ +import json +from time import time + +from flask import Flask, request +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.flask import SlackRequestHandler +from slack_bolt.app import App +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 TestFlask: + 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_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + 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) + + payload = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(payload) + + flask_app = Flask(__name__) + + @flask_app.route("/slack/events", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 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) + + payload = { + "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())), json.dumps(payload) + + flask_app = Flask(__name__) + + @flask_app.route("/slack/events", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + 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) + + payload = ( + "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())), json.dumps(payload) + + flask_app = Flask(__name__) + + @flask_app.route("/slack/events", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + flask_app = Flask(__name__) + + @flask_app.route("/slack/install", methods=["GET"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.get("/slack/install") + assert rv.status_code == 302 diff --git a/tests/adapter_tests/test_starlette.py b/tests/adapter_tests/test_starlette.py new file mode 100644 index 000000000..292ce632a --- /dev/null +++ b/tests/adapter_tests/test_starlette.py @@ -0,0 +1,199 @@ +import json +import re +from time import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.routing import Route +from starlette.testclient import TestClient + +from slack_bolt.adapter.starlette import SlackRequestHandler +from slack_bolt.app import App +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 TestStarlette: + 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_headers(self, timestamp: str, body: str): + return { + "content-type": "application/x-www-form-urlencoded", + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + 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) + + app_handler = SlackRequestHandler(app) + + payload = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(payload) + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])], + ) + client = TestClient(api) + response = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 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) + + app_handler = SlackRequestHandler(app) + + payload = { + "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())), json.dumps(payload) + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])], + ) + client = TestClient(api) + response = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + 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) + + app_handler = SlackRequestHandler(app) + + payload = ( + "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())), json.dumps(payload) + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])], + ) + client = TestClient(api) + response = client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + app_handler = SlackRequestHandler(app) + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/install", endpoint=endpoint, methods=["GET"])], + ) + client = TestClient(api) + response = client.get("/slack/install", allow_redirects=False) + assert response.status_code == 302 + assert re.match( + "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", + response.headers["Location"], + ) From becee21abd85d3b3f2122291bba271acfe3ebcaf Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 28 Aug 2020 01:52:44 +0900 Subject: [PATCH 014/865] Add CherryPy adapter (#47) --- samples/bottle/oauth_app.py | 2 +- samples/cherrypy/app.py | 54 +++++++ samples/cherrypy/oauth_app.py | 68 +++++++++ samples/cherrypy/requirements.txt | 1 + setup.py | 1 + slack_bolt/adapter/cherrypy/__init__.py | 1 + slack_bolt/adapter/cherrypy/handler.py | 77 ++++++++++ tests/adapter_tests/test_cherrypy.py | 159 +++++++++++++++++++++ tests/adapter_tests/test_cherrypy_oauth.py | 53 +++++++ 9 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 samples/cherrypy/app.py create mode 100644 samples/cherrypy/oauth_app.py create mode 100644 samples/cherrypy/requirements.txt create mode 100644 slack_bolt/adapter/cherrypy/__init__.py create mode 100644 slack_bolt/adapter/cherrypy/handler.py create mode 100644 tests/adapter_tests/test_cherrypy.py create mode 100644 tests/adapter_tests/test_cherrypy_oauth.py diff --git a/samples/bottle/oauth_app.py b/samples/bottle/oauth_app.py index 4a1b30dd7..ce5f7dbd9 100644 --- a/samples/bottle/oauth_app.py +++ b/samples/bottle/oauth_app.py @@ -57,4 +57,4 @@ def oauth_redirect(): # export SLACK_CLIENT_SECRET=*** # export SLACK_SCOPES=app_mentions:read,chat:write -# FLASK_APP=oauth_app.py FLASK_ENV=development flask run -p 3000 +# python oauth_app.py diff --git a/samples/cherrypy/app.py b/samples/cherrypy/app.py new file mode 100644 index 000000000..863e54f49 --- /dev/null +++ b/samples/cherrypy/app.py @@ -0,0 +1,54 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "../..") +# ------------------------------------------------ + +import logging + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App +from slack_bolt.adapter.cherrypy import SlackRequestHandler + +app = App() + + +@app.middleware # or app.use(log_request) +def log_request(logger, payload, next): + logger.debug(payload) + return next() + + +@app.command("/hello-bolt-python") +def hello_command(ack): + ack("Hi from CherryPy") + + +@app.event("app_mention") +def event_test(payload, say, logger): + logger.info(payload) + say("What's up?") + + +import cherrypy + +handler = SlackRequestHandler(app) + + +class SlackApp(object): + @cherrypy.expose + @cherrypy.tools.slack_in() + def events(self, **kwargs): + return handler.handle() + + +if __name__ == "__main__": + cherrypy.config.update({"server.socket_port": 3000}) + cherrypy.quickstart(SlackApp(), "/slack") + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python app.py diff --git a/samples/cherrypy/oauth_app.py b/samples/cherrypy/oauth_app.py new file mode 100644 index 000000000..af293e683 --- /dev/null +++ b/samples/cherrypy/oauth_app.py @@ -0,0 +1,68 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "../../src") +# ------------------------------------------------ + +import logging +from slack_bolt import App +from slack_bolt.adapter.cherrypy import SlackRequestHandler + +logging.basicConfig(level=logging.DEBUG) +app = App() + + +@app.middleware # or app.use(log_request) +def log_request(logger, payload, next): + logger.debug(payload) + return next() + + +@app.command("/hello-bolt-python") +def hello_command(ack): + ack("Hi from CherryPy") + + +@app.event("app_mention") +def event_test(payload, say, logger): + logger.info(payload) + say("What's up?") + + +import cherrypy + +handler = SlackRequestHandler(app) + + +class SlackApp(object): + @cherrypy.expose + @cherrypy.tools.slack_in() + def events(self, **kwargs): + return handler.handle() + + @cherrypy.expose + @cherrypy.tools.slack_in() + def install(self, **kwargs): + return handler.handle() + + @cherrypy.expose + @cherrypy.tools.slack_in() + def oauth_redirect(self, **kwargs): + return handler.handle() + + +if __name__ == "__main__": + cherrypy.config.update({"server.socket_port": 3000}) + cherrypy.quickstart(SlackApp(), "/slack") + +# pip install -r requirements.txt + +# # -- OAuth flow -- # +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,chat:write + +# python oauth_app.py diff --git a/samples/cherrypy/requirements.txt b/samples/cherrypy/requirements.txt new file mode 100644 index 000000000..42fd09e99 --- /dev/null +++ b/samples/cherrypy/requirements.txt @@ -0,0 +1 @@ +CherryPy>=18,<19 diff --git a/setup.py b/setup.py index d9a2d56f5..c2674aa3f 100755 --- a/setup.py +++ b/setup.py @@ -53,6 +53,7 @@ "bottle>=0.12,<1", "chalice>=1,<2", "click>=7,<8", # for chalice + "CherryPy>=18,<19", "Django>=3,<4", "falcon>=2,<3", "fastapi<1", diff --git a/slack_bolt/adapter/cherrypy/__init__.py b/slack_bolt/adapter/cherrypy/__init__.py new file mode 100644 index 000000000..f08c97a5f --- /dev/null +++ b/slack_bolt/adapter/cherrypy/__init__.py @@ -0,0 +1 @@ +from .handler import SlackRequestHandler diff --git a/slack_bolt/adapter/cherrypy/handler.py b/slack_bolt/adapter/cherrypy/handler.py new file mode 100644 index 000000000..ae6029179 --- /dev/null +++ b/slack_bolt/adapter/cherrypy/handler.py @@ -0,0 +1,77 @@ +from typing import Optional + +import cherrypy + +from slack_bolt.app import App +from slack_bolt.oauth import OAuthFlow +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +def build_bolt_request() -> BoltRequest: + req = cherrypy.request + body = req.raw_body if hasattr(req, "raw_body") else "" + return BoltRequest(body=body, query=req.query_string, headers=req.headers,) + + +def set_response_status_and_headers(bolt_resp: BoltResponse) -> None: + cherrypy.response.status = bolt_resp.status + for k, v in bolt_resp.first_headers_without_set_cookie().items(): + cherrypy.response.headers[k] = v + for cookie in bolt_resp.cookies(): + for name, c in cookie.items(): + str_max_age: Optional[str] = c.get("max-age", None) + max_age: Optional[int] = int(str_max_age) if str_max_age else None + cherrypy_cookie = cherrypy.response.cookie + cherrypy_cookie[name] = c.value + cherrypy_cookie[name]["expires"] = c.get("expires", None) + cherrypy_cookie[name]["max-age"] = max_age + cherrypy_cookie[name]["domain"] = c.get("domain", None) + cherrypy_cookie[name]["path"] = c.get("path", None) + cherrypy_cookie[name]["secure"] = True + cherrypy_cookie[name]["httponly"] = True + + +@cherrypy.tools.register("on_start_resource") +def slack_in(): + request = cherrypy.serving.request + + def slack_processor(entity): + try: + if request.process_request_body: + body = entity.fp.read() + body = body.decode("utf-8") if isinstance(body, bytes) else "" + request.raw_body = body + except ValueError: + raise cherrypy.HTTPError(400, "Invalid request body") + + request.body.processors.clear() + request.body.processors["application/json"] = slack_processor + request.body.processors["application/x-www-form-urlencoded"] = slack_processor + + +class SlackRequestHandler: + def __init__(self, app: App): # type: ignore + self.app = app + + def handle(self) -> bytes: + req = cherrypy.request + if req.method == "GET": + if self.app.oauth_flow is not None: + oauth_flow: OAuthFlow = self.app.oauth_flow + request_path = req.wsgi_environ["REQUEST_URI"].split("?")[0] + if request_path == oauth_flow.install_path: + bolt_resp = oauth_flow.handle_installation(build_bolt_request()) + set_response_status_and_headers(bolt_resp) + return (bolt_resp.body or "").encode("utf-8") + elif request_path == oauth_flow.redirect_uri_path: + bolt_resp = oauth_flow.handle_callback(build_bolt_request()) + set_response_status_and_headers(bolt_resp) + return (bolt_resp.body or "").encode("utf-8") + elif req.method == "POST": + bolt_resp: BoltResponse = self.app.dispatch(build_bolt_request()) + set_response_status_and_headers(bolt_resp) + return (bolt_resp.body or "").encode("utf-8") + + cherrypy.response.status = 404 + return "Not Found".encode("utf-8") diff --git a/tests/adapter_tests/test_cherrypy.py b/tests/adapter_tests/test_cherrypy.py new file mode 100644 index 000000000..8054f3ac3 --- /dev/null +++ b/tests/adapter_tests/test_cherrypy.py @@ -0,0 +1,159 @@ +import json +from time import time + +import cherrypy +from cherrypy.test import helper +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.cherrypy import SlackRequestHandler +from slack_bolt.app import App +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 TestCherryPy(helper.CPWebCase): + helper.CPWebCase.interactive = False + signing_secret = "secret" + signature_verifier = SignatureVerifier(signing_secret) + + @classmethod + def setup_server(cls): + cls.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(cls) + + 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,) + app = App(client=web_client, signing_secret=signing_secret,) + + def event_handler(): + pass + + def shortcut_handler(ack): + ack() + + def command_handler(ack): + ack() + + app.event("app_mention")(event_handler) + app.shortcut("test-shortcut")(shortcut_handler) + app.command("/hello-world")(command_handler) + + handler = SlackRequestHandler(app) + + class SlackApp(object): + @cherrypy.expose + @cherrypy.tools.slack_in() + def events(self, **kwargs): + return handler.handle() + + cherrypy.tree.mount(SlackApp(), "/slack") + + @classmethod + def teardown_class(cls): + cls.supervisor.stop() + cleanup_mock_web_api_server(cls) + restore_os_env(cls.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): + return [ + ("content-length", str(len(body))), + ("x-slack-signature", self.generate_signature(body, timestamp)), + ("x-slack-request-timestamp", timestamp), + ] + + def test_events(self): + payload = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(payload) + cherrypy.request.process_request_body = True + self.getPage( + "/slack/events", + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + self.assertStatus("200 OK") + self.assertBody("") + + def test_shortcuts(self): + payload = { + "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())), json.dumps(payload) + cherrypy.request.process_request_body = True + self.getPage( + "/slack/events", + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + self.assertStatus("200 OK") + self.assertBody("") + + def test_commands(self): + payload = ( + "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())), json.dumps(payload) + cherrypy.request.process_request_body = True + self.getPage( + "/slack/events", + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + self.assertStatus("200 OK") + self.assertBody("") diff --git a/tests/adapter_tests/test_cherrypy_oauth.py b/tests/adapter_tests/test_cherrypy_oauth.py new file mode 100644 index 000000000..c56e24f98 --- /dev/null +++ b/tests/adapter_tests/test_cherrypy_oauth.py @@ -0,0 +1,53 @@ +import cherrypy +from cherrypy.test import helper +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.cherrypy import SlackRequestHandler +from slack_bolt.app import App +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 TestCherryPy(helper.CPWebCase): + helper.CPWebCase.interactive = False + signing_secret = "secret" + signature_verifier = SignatureVerifier(signing_secret) + + @classmethod + def setup_server(cls): + cls.old_os_env = remove_os_env_temporarily() + + 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,) + app = App( + client=web_client, + signing_secret=signing_secret, + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + handler = SlackRequestHandler(app) + + class SlackApp(object): + @cherrypy.expose + @cherrypy.tools.slack_in() + def install(self, **kwargs): + return handler.handle() + + cherrypy.tree.mount(SlackApp(), "/slack") + + @classmethod + def teardown_class(cls): + cls.supervisor.stop() + restore_os_env(cls.old_os_env) + + def test_oauth(self): + cherrypy.request.process_request_body = False + self.getPage("/slack/install", method="GET") + self.assertStatus("302 Found") From ca2e25fe7c00b185fa9112e7bd98574bb66a92d7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 28 Aug 2020 01:52:59 +0900 Subject: [PATCH 015/865] Get rid of os.environment access in default arguments (#48) --- .../aws_lambda/lambda_s3_oauth_flow.py | 41 ++++++++++++++----- slack_bolt/oauth/async_oauth_flow.py | 15 ++++--- slack_bolt/oauth/oauth_flow.py | 15 ++++--- 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py index 6d3784995..19b62d7fd 100644 --- a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py +++ b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py @@ -19,19 +19,17 @@ def __init__( *, client: Optional[WebClient] = None, logger: Optional[Logger] = None, - oauth_state_bucket_name: str = os.environ["SLACK_STATE_S3_BUCKET_NAME"], - installation_bucket_name: str = os.environ["SLACK_INSTALLATION_S3_BUCKET_NAME"], + oauth_state_bucket_name: Optional[str] = None, # required + installation_bucket_name: Optional[str] = None, # required oauth_state_cookie_name: str = "slack-app-oauth-state", oauth_state_expiration_seconds: int = 60 * 10, # 10 minutes - client_id: str = os.environ["SLACK_CLIENT_ID"], - client_secret: str = os.environ["SLACK_CLIENT_SECRET"], - scopes: Optional[str] = os.environ.get("SLACK_SCOPES", None), - user_scopes: Optional[str] = os.environ.get("SLACK_USER_SCOPES", None), - redirect_uri: Optional[str] = os.environ.get("SLACK_REDIRECT_URI", None), - install_path: str = os.environ.get("SLACK_LAMBDA_PATH", "/slack/install"), - redirect_uri_path: str = os.environ.get( - "SLACK_LAMBDA_PATH", "/slack/oauth_redirect" - ), + client_id: Optional[str] = None, # required + client_secret: Optional[str] = None, # required + scopes: Optional[str] = None, # required + user_scopes: Optional[str] = None, + redirect_uri: Optional[str] = None, + install_path: Optional[str] = None, # required + redirect_uri_path: Optional[str] = None, # required success_url: Optional[str] = None, failure_url: Optional[str] = None, ): @@ -40,6 +38,27 @@ def __init__( self.s3_client = boto3.client("s3") + oauth_state_bucket_name = ( + oauth_state_bucket_name + or os.environ["SLACK_STATE_S3_BUCKET_NAME"] # required + ) + installation_bucket_name = ( + installation_bucket_name + or os.environ["SLACK_INSTALLATION_S3_BUCKET_NAME"] # required + ) + + client_id = client_id or os.environ["SLACK_CLIENT_ID"] # required + client_secret = client_secret or os.environ["SLACK_CLIENT_SECRET"] # required + scopes = scopes or os.environ.get("SLACK_SCOPES", None) + user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", None) + redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) + install_path = install_path or os.environ.get( + "SLACK_LAMBDA_PATH", "/slack/install" + ) + redirect_uri_path = redirect_uri_path or os.environ.get( + "SLACK_LAMBDA_PATH", "/slack/oauth_redirect" + ) + self.oauth_state_store = AmazonS3OAuthStateStore( logger=self.logger, s3_client=self.s3_client, diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 034b6b029..1b26128c0 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -124,16 +124,21 @@ def _init_internal_utils(self): def sqlite3( cls, database: str, - client_id: Optional[str] = os.environ.get("SLACK_CLIENT_ID", None), - client_secret: Optional[str] = os.environ.get("SLACK_CLIENT_SECRET", None), - scopes: List[str] = os.environ.get("SLACK_SCOPES", "").split(","), - user_scopes: List[str] = os.environ.get("SLACK_USER_SCOPES", "").split(","), - redirect_uri: Optional[str] = os.environ.get("SLACK_REDIRECT_URI", None), + client_id: Optional[str] = None, # required + client_secret: Optional[str] = None, # required + scopes: Optional[List[str]] = None, + user_scopes: Optional[List[str]] = None, + redirect_uri: Optional[str] = None, oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, logger: Optional[Logger] = None, ) -> "AsyncOAuthFlow": + client_id = client_id or os.environ["SLACK_CLIENT_ID"] # required + client_secret = client_secret or os.environ["SLACK_CLIENT_SECRET"] # required + scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",") + redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) return AsyncOAuthFlow( client=create_async_web_client(), logger=logger, diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index 6c6289708..d94ccb6ed 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -120,16 +120,21 @@ def _init_internal_utils(self): def sqlite3( cls, database: str, - client_id: Optional[str] = os.environ.get("SLACK_CLIENT_ID", None), - client_secret: Optional[str] = os.environ.get("SLACK_CLIENT_SECRET", None), - scopes: List[str] = os.environ.get("SLACK_SCOPES", "").split(","), - user_scopes: List[str] = os.environ.get("SLACK_USER_SCOPES", "").split(","), - redirect_uri: Optional[str] = os.environ.get("SLACK_REDIRECT_URI", None), + client_id: Optional[str] = None, # required + client_secret: Optional[str] = None, # required + scopes: Optional[List[str]] = None, + user_scopes: Optional[List[str]] = None, + redirect_uri: Optional[str] = None, oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, logger: Optional[Logger] = None, ) -> "OAuthFlow": + client_id = client_id or os.environ["SLACK_CLIENT_ID"] # required + client_secret = client_secret or os.environ["SLACK_CLIENT_SECRET"] # required + scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",") + redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) return OAuthFlow( client=WebClient(), logger=logger, From 7e5ec30390ca008931938a8e55b8812023f3fa6a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 28 Aug 2020 13:30:22 +0900 Subject: [PATCH 016/865] Improve the error message when token is missing --- slack_bolt/app/app.py | 2 +- slack_bolt/app/async_app.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index b211c1c72..66a73fcc7 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -212,7 +212,7 @@ def _init_middleware_list(self): self._middleware_list.append(SingleTeamAuthorization()) else: raise BoltError( - "OAuthFlow not found, so could not initialize the Bolt app." + "Either an env variable SLACK_BOT_TOKEN or token argument in constructor is required." ) else: self._middleware_list.append( diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index d5a8b5f22..d3474cafc 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -233,7 +233,7 @@ def _init_async_middleware_list(self): self._async_middleware_list.append(AsyncSingleTeamAuthorization()) else: raise BoltError( - "AsyncOAuthFlow not found, so could not initialize the Bolt app." + "Either an env variable SLACK_BOT_TOKEN or token argument in constructor is required." ) else: self._async_middleware_list.append( From aa7f058a812bd8ed29065977b645fb14a8f7b822 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 28 Aug 2020 16:52:27 +0900 Subject: [PATCH 017/865] Update say args to have any and update event samples (#50) --- samples/events_app.py | 27 -------- samples/message_events.py | 101 ++++++++++++++++++++++++++++ slack_bolt/context/say/async_say.py | 4 ++ slack_bolt/context/say/say.py | 4 ++ 4 files changed, 109 insertions(+), 27 deletions(-) create mode 100644 samples/message_events.py diff --git a/samples/events_app.py b/samples/events_app.py index 11827fdc9..dc26e4fa2 100644 --- a/samples/events_app.py +++ b/samples/events_app.py @@ -37,33 +37,6 @@ def mention_bug(logger, payload): logger.info(payload) -@app.event( - event={"type": "message", "subtype": "message_deleted"}, - matchers=[ - lambda payload: payload["event"]["previous_message"].get("bot_id", None) is None - ], -) -def deleted(payload, say): - message = payload["event"]["previous_message"]["text"] - say(f"I've noticed you deleted: {message}") - - -def print_bot(req, resp, next): - bot_id = req.global_shortcut_payload["event"]["previous_message"]["bot_id"] - logger = logging.getLogger(__name__) - logger.info(f"bot_id surely exists here: {bot_id}") - return next() - - -@app.event( - event={"type": "message", "subtype": "message_deleted"}, - matchers=[lambda payload: payload["event"]["previous_message"].get("bot_id", None)], - middleware=[print_bot], -) -def bot_message_deleted(logger): - logger.info("A bot message has been deleted") - - if __name__ == "__main__": app.start(3000) diff --git a/samples/message_events.py b/samples/message_events.py new file mode 100644 index 000000000..faedf9bae --- /dev/null +++ b/samples/message_events.py @@ -0,0 +1,101 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging +import re +from typing import Callable + +from slack_bolt import App, Say, BoltContext +from slack_sdk import WebClient + +logging.basicConfig(level=logging.DEBUG) + +app = App() + + +@app.middleware +def log_request(logger: logging.Logger, payload: dict, next: Callable): + logger.debug(payload) + return next() + + +@app.message("test") +def reply_to_test(say): + say("Yes, tests are important!") + + +@app.message(re.compile("bug")) +def mention_bug(say): + say("Do you mind filing a ticket?") + + +# middleware function +def extract_subtype(payload: dict, context: BoltContext, next: Callable): + context["subtype"] = payload.get("event", {}).get("subtype", None) + next() + + +# https://api.slack.com/events/message +# Newly posted messages only +# or @app.event("message") +@app.event({"type": "message", "subtype": None}) +def reply_in_thread(payload: dict, say: Say): + event = payload["event"] + thread_ts = event.get("thread_ts", None) or event["ts"] + say(text="Hey, what's up?", thread_ts=thread_ts) + + +@app.event( + event={"type": "message", "subtype": "message_deleted"}, + matchers=[ + # Skip the deletion of messages by this listener + lambda payload: "You've deleted a message: " not in payload["event"]["previous_message"]["text"] + ] +) +def detect_deletion(say: Say, payload: dict): + text = payload["event"]["previous_message"]["text"] + say(f"You've deleted a message: {text}") + + +# https://api.slack.com/events/message/file_share +# https://api.slack.com/events/message/bot_message +@app.event( + event={"type": "message", "subtype": re.compile("(me_message)|(file_share)")}, + middleware=[extract_subtype], +) +def add_reaction( + payload: dict, + client: WebClient, + context: BoltContext, + logger: logging.Logger +): + subtype = context["subtype"] # by extract_subtype + logger.info(f"subtype: {subtype}") + message_ts = payload["event"]["ts"] + api_response = client.reactions_add( + channel=context.channel_id, + timestamp=message_ts, + name="eyes", + ) + logger.info(f"api_response: {api_response}") + + +# This listener handles all uncaught message events +# (The position in source code matters) +@app.event({"type": "message"}, middleware=[extract_subtype]) +def just_ack(logger, context): + subtype = context["subtype"] # by extract_subtype + logger.info(f"{subtype} is ignored") + + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python message_events.py diff --git a/slack_bolt/context/say/async_say.py b/slack_bolt/context/say/async_say.py index 3a7cb9b59..8b9eae3df 100644 --- a/slack_bolt/context/say/async_say.py +++ b/slack_bolt/context/say/async_say.py @@ -23,6 +23,8 @@ async def __call__( blocks: Optional[List[Union[Dict, Block]]] = None, attachments: Optional[List[Union[Dict, Attachment]]] = None, channel: Optional[str] = None, + thread_ts: Optional[str] = None, + **kwargs, ) -> AsyncSlackResponse: if _can_say(self, channel): text_or_whole_response: Union[str, dict] = text @@ -33,6 +35,8 @@ async def __call__( text=text, blocks=blocks, attachments=attachments, + thread_ts=thread_ts, + **kwargs, ) elif isinstance(text_or_whole_response, dict): message: dict = text_or_whole_response diff --git a/slack_bolt/context/say/say.py b/slack_bolt/context/say/say.py index 2a41078c6..d8dfe145b 100644 --- a/slack_bolt/context/say/say.py +++ b/slack_bolt/context/say/say.py @@ -24,6 +24,8 @@ def __call__( blocks: Optional[List[Union[Dict, Block]]] = None, attachments: Optional[List[Union[Dict, Attachment]]] = None, channel: Optional[str] = None, + thread_ts: Optional[str] = None, + **kwargs, ) -> SlackResponse: if _can_say(self, channel): text_or_whole_response: Union[str, dict] = text @@ -34,6 +36,8 @@ def __call__( text=text, blocks=blocks, attachments=attachments, + thread_ts=thread_ts, + **kwargs, ) elif isinstance(text_or_whole_response, dict): message: dict = text_or_whole_response From 7a8b6240e5c8714a7c8ae13c71aaeeb7338482c7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 28 Aug 2020 17:46:38 +0900 Subject: [PATCH 018/865] version 0.3.1a0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 01b04d220..d72438438 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.3.0a0" +__version__ = "0.3.1a0" From 443720ddb809dd99f02c25bb26cc0c469aa33a07 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 28 Aug 2020 20:25:05 +0900 Subject: [PATCH 019/865] Simplify App#error method's args --- slack_bolt/app/app.py | 10 ++++------ slack_bolt/app/async_app.py | 10 ++++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 66a73fcc7..f4559af08 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -458,12 +458,10 @@ def middleware(self, *args): # ------------------------- # global error handler - def error(self, *args): - if len(args) > 0: - func = args[0] - self._listener_error_handler = CustomListenerErrorHandler( - logger=self._framework_logger, func=func, - ) + def error(self, func: Callable[..., None]): + self._listener_error_handler = CustomListenerErrorHandler( + logger=self._framework_logger, func=func, + ) # ------------------------- # events diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index d3474cafc..95ff3880a 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -492,12 +492,10 @@ def middleware(self, *args): # ------------------------- # global error handler - def error(self, *args): - if len(args) > 0: - func = args[0] - self._async_listener_error_handler = AsyncCustomListenerErrorHandler( - logger=self._framework_logger, func=func, - ) + def error(self, func: Callable[..., Awaitable[None]]): + self._async_listener_error_handler = AsyncCustomListenerErrorHandler( + logger=self._framework_logger, func=func, + ) # ------------------------- # events From f9db4de35e63a10ba94947b665779994a7f414bb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 31 Aug 2020 16:37:08 +0900 Subject: [PATCH 020/865] Fix fastapi adapter to work without aiohttp installation --- samples/fastapi/async_app.py | 2 +- samples/fastapi/async_oauth_app.py | 2 +- slack_bolt/adapter/fastapi/__init__.py | 2 +- slack_bolt/adapter/fastapi/async_handler.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 slack_bolt/adapter/fastapi/async_handler.py diff --git a/samples/fastapi/async_app.py b/samples/fastapi/async_app.py index dee09de17..724c05e7f 100644 --- a/samples/fastapi/async_app.py +++ b/samples/fastapi/async_app.py @@ -6,7 +6,7 @@ # ------------------------------------------------ from slack_bolt.async_app import AsyncApp -from slack_bolt.adapter.fastapi import AsyncSlackRequestHandler +from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler app = AsyncApp() app_handler = AsyncSlackRequestHandler(app) diff --git a/samples/fastapi/async_oauth_app.py b/samples/fastapi/async_oauth_app.py index 0dc3a62e3..59d28e3a4 100644 --- a/samples/fastapi/async_oauth_app.py +++ b/samples/fastapi/async_oauth_app.py @@ -6,7 +6,7 @@ # ------------------------------------------------ from slack_bolt.async_app import AsyncApp -from slack_bolt.adapter.fastapi import AsyncSlackRequestHandler +from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler app = AsyncApp() app_handler = AsyncSlackRequestHandler(app) diff --git a/slack_bolt/adapter/fastapi/__init__.py b/slack_bolt/adapter/fastapi/__init__.py index 8342fbb03..9ef794b02 100644 --- a/slack_bolt/adapter/fastapi/__init__.py +++ b/slack_bolt/adapter/fastapi/__init__.py @@ -1,2 +1,2 @@ -from ..starlette.async_handler import AsyncSlackRequestHandler # noqa +# Don't add async module imports here from ..starlette.handler import SlackRequestHandler # noqa diff --git a/slack_bolt/adapter/fastapi/async_handler.py b/slack_bolt/adapter/fastapi/async_handler.py new file mode 100644 index 000000000..718f81874 --- /dev/null +++ b/slack_bolt/adapter/fastapi/async_handler.py @@ -0,0 +1 @@ +from ..starlette.async_handler import AsyncSlackRequestHandler # noqa From c402e906100ae6abdd3131b96e29a3bc4879b6bb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 31 Aug 2020 16:46:09 +0900 Subject: [PATCH 021/865] Fix broken tests --- tests/adapter_tests/test_async_fastapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/adapter_tests/test_async_fastapi.py b/tests/adapter_tests/test_async_fastapi.py index 0adb57115..c6acca066 100644 --- a/tests/adapter_tests/test_async_fastapi.py +++ b/tests/adapter_tests/test_async_fastapi.py @@ -8,7 +8,7 @@ from starlette.requests import Request from starlette.testclient import TestClient -from slack_bolt.adapter.fastapi import AsyncSlackRequestHandler +from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler from slack_bolt.app.async_app import AsyncApp from tests.mock_web_api_server import ( setup_mock_web_api_server, From d895f8b15cfd84bf55b052c352694e9e1330d627 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 1 Sep 2020 06:45:46 +0900 Subject: [PATCH 022/865] Organize built-in middleware by putting them into packages (#55) --- slack_bolt/app/async_app.py | 4 +--- slack_bolt/middleware/async_builtins.py | 16 ++++++++++------ .../middleware/ignoring_self_events/__init__.py | 1 + .../async_ignoring_self_events.py | 4 ++-- .../ignoring_self_events.py | 2 +- .../message_listener_matches/__init__.py | 1 + .../async_message_listener_matches.py | 2 +- .../message_listener_matches.py | 2 +- .../middleware/request_verification/__init__.py | 1 + .../async_request_verification.py | 4 ++-- .../request_verification.py | 2 +- slack_bolt/middleware/ssl_check/__init__.py | 1 + .../{ => ssl_check}/async_ssl_check.py | 4 ++-- .../middleware/{ => ssl_check}/ssl_check.py | 2 +- .../middleware/url_verification/__init__.py | 1 + .../async_url_verification.py | 4 ++-- .../{ => url_verification}/url_verification.py | 2 +- 17 files changed, 30 insertions(+), 23 deletions(-) create mode 100644 slack_bolt/middleware/ignoring_self_events/__init__.py rename slack_bolt/middleware/{ => ignoring_self_events}/async_ignoring_self_events.py (84%) rename slack_bolt/middleware/{ => ignoring_self_events}/ignoring_self_events.py (95%) create mode 100644 slack_bolt/middleware/message_listener_matches/__init__.py rename slack_bolt/middleware/{ => message_listener_matches}/async_message_listener_matches.py (92%) rename slack_bolt/middleware/{ => message_listener_matches}/message_listener_matches.py (93%) create mode 100644 slack_bolt/middleware/request_verification/__init__.py rename slack_bolt/middleware/{ => request_verification}/async_request_verification.py (87%) rename slack_bolt/middleware/{ => request_verification}/request_verification.py (96%) create mode 100644 slack_bolt/middleware/ssl_check/__init__.py rename slack_bolt/middleware/{ => ssl_check}/async_ssl_check.py (86%) rename slack_bolt/middleware/{ => ssl_check}/ssl_check.py (96%) create mode 100644 slack_bolt/middleware/url_verification/__init__.py rename slack_bolt/middleware/{ => url_verification}/async_url_verification.py (86%) rename slack_bolt/middleware/{ => url_verification}/url_verification.py (94%) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 95ff3880a..684e3cc42 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -36,14 +36,12 @@ AsyncRequestVerification, AsyncIgnoringSelfEvents, AsyncUrlVerification, + AsyncMessageListenerMatches, ) from slack_bolt.middleware.async_custom_middleware import ( AsyncMiddleware, AsyncCustomMiddleware, ) -from slack_bolt.middleware.async_message_listener_matches import ( - AsyncMessageListenerMatches, -) from slack_bolt.middleware.authorization.async_multi_teams_authorization import ( AsyncMultiTeamsAuthorization, ) diff --git a/slack_bolt/middleware/async_builtins.py b/slack_bolt/middleware/async_builtins.py index 3b9536f0e..09b5338e0 100644 --- a/slack_bolt/middleware/async_builtins.py +++ b/slack_bolt/middleware/async_builtins.py @@ -1,7 +1,11 @@ -from .async_ignoring_self_events import AsyncIgnoringSelfEvents # noqa -from .async_request_verification import AsyncRequestVerification # noqa -from .async_ssl_check import AsyncSslCheck # noqa -from .async_url_verification import AsyncUrlVerification # noqa -from .authorization.async_single_team_authorization import ( - AsyncSingleTeamAuthorization, +from .ignoring_self_events.async_ignoring_self_events import ( + AsyncIgnoringSelfEvents, +) # noqa +from .request_verification.async_request_verification import ( + AsyncRequestVerification, +) # noqa +from .ssl_check.async_ssl_check import AsyncSslCheck # noqa +from .url_verification.async_url_verification import AsyncUrlVerification # noqa +from .message_listener_matches.async_message_listener_matches import ( + AsyncMessageListenerMatches, ) # noqa diff --git a/slack_bolt/middleware/ignoring_self_events/__init__.py b/slack_bolt/middleware/ignoring_self_events/__init__.py new file mode 100644 index 000000000..b679760bb --- /dev/null +++ b/slack_bolt/middleware/ignoring_self_events/__init__.py @@ -0,0 +1 @@ +from .ignoring_self_events import IgnoringSelfEvents # noqa diff --git a/slack_bolt/middleware/async_ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py similarity index 84% rename from slack_bolt/middleware/async_ignoring_self_events.py rename to slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py index 220ba4f6e..2e68cfcab 100644 --- a/slack_bolt/middleware/async_ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py @@ -2,8 +2,8 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse -from . import IgnoringSelfEvents -from .async_middleware import AsyncMiddleware +from .ignoring_self_events import IgnoringSelfEvents +from slack_bolt.middleware.async_middleware import AsyncMiddleware class AsyncIgnoringSelfEvents(IgnoringSelfEvents, AsyncMiddleware): diff --git a/slack_bolt/middleware/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py similarity index 95% rename from slack_bolt/middleware/ignoring_self_events.py rename to slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py index 5252a1876..d48180005 100644 --- a/slack_bolt/middleware/ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -5,7 +5,7 @@ from slack_bolt.logger import get_bolt_logger from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from .middleware import Middleware +from slack_bolt.middleware.middleware import Middleware class IgnoringSelfEvents(Middleware): diff --git a/slack_bolt/middleware/message_listener_matches/__init__.py b/slack_bolt/middleware/message_listener_matches/__init__.py new file mode 100644 index 000000000..d6825675e --- /dev/null +++ b/slack_bolt/middleware/message_listener_matches/__init__.py @@ -0,0 +1 @@ +from .message_listener_matches import MessageListenerMatches # noqa diff --git a/slack_bolt/middleware/async_message_listener_matches.py b/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py similarity index 92% rename from slack_bolt/middleware/async_message_listener_matches.py rename to slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py index 9d95a5cc1..b54ef5b33 100644 --- a/slack_bolt/middleware/async_message_listener_matches.py +++ b/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py @@ -3,7 +3,7 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse -from .async_middleware import AsyncMiddleware +from slack_bolt.middleware.async_middleware import AsyncMiddleware class AsyncMessageListenerMatches(AsyncMiddleware): diff --git a/slack_bolt/middleware/message_listener_matches.py b/slack_bolt/middleware/message_listener_matches/message_listener_matches.py similarity index 93% rename from slack_bolt/middleware/message_listener_matches.py rename to slack_bolt/middleware/message_listener_matches/message_listener_matches.py index a63e458e1..7210344ea 100644 --- a/slack_bolt/middleware/message_listener_matches.py +++ b/slack_bolt/middleware/message_listener_matches/message_listener_matches.py @@ -3,7 +3,7 @@ from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from .middleware import Middleware +from slack_bolt.middleware.middleware import Middleware class MessageListenerMatches(Middleware): # type: ignore diff --git a/slack_bolt/middleware/request_verification/__init__.py b/slack_bolt/middleware/request_verification/__init__.py new file mode 100644 index 000000000..f2e70fda7 --- /dev/null +++ b/slack_bolt/middleware/request_verification/__init__.py @@ -0,0 +1 @@ +from .request_verification import RequestVerification # noqa diff --git a/slack_bolt/middleware/async_request_verification.py b/slack_bolt/middleware/request_verification/async_request_verification.py similarity index 87% rename from slack_bolt/middleware/async_request_verification.py rename to slack_bolt/middleware/request_verification/async_request_verification.py index 60512cfea..f1a7556c2 100644 --- a/slack_bolt/middleware/async_request_verification.py +++ b/slack_bolt/middleware/request_verification/async_request_verification.py @@ -1,7 +1,7 @@ from typing import Callable, Awaitable -from . import RequestVerification -from .async_middleware import AsyncMiddleware +from slack_bolt.middleware import RequestVerification +from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse diff --git a/slack_bolt/middleware/request_verification.py b/slack_bolt/middleware/request_verification/request_verification.py similarity index 96% rename from slack_bolt/middleware/request_verification.py rename to slack_bolt/middleware/request_verification/request_verification.py index 6623d4aac..1dab84f57 100644 --- a/slack_bolt/middleware/request_verification.py +++ b/slack_bolt/middleware/request_verification/request_verification.py @@ -3,7 +3,7 @@ from slack_sdk.signature import SignatureVerifier from slack_bolt.logger import get_bolt_logger -from .middleware import Middleware +from slack_bolt.middleware.middleware import Middleware from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse diff --git a/slack_bolt/middleware/ssl_check/__init__.py b/slack_bolt/middleware/ssl_check/__init__.py new file mode 100644 index 000000000..3b2a137d7 --- /dev/null +++ b/slack_bolt/middleware/ssl_check/__init__.py @@ -0,0 +1 @@ +from .ssl_check import SslCheck # noqa diff --git a/slack_bolt/middleware/async_ssl_check.py b/slack_bolt/middleware/ssl_check/async_ssl_check.py similarity index 86% rename from slack_bolt/middleware/async_ssl_check.py rename to slack_bolt/middleware/ssl_check/async_ssl_check.py index 141a13bdf..3423d40a0 100644 --- a/slack_bolt/middleware/async_ssl_check.py +++ b/slack_bolt/middleware/ssl_check/async_ssl_check.py @@ -1,7 +1,7 @@ from typing import Callable, Awaitable -from . import SslCheck -from .async_middleware import AsyncMiddleware +from .ssl_check import SslCheck +from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse diff --git a/slack_bolt/middleware/ssl_check.py b/slack_bolt/middleware/ssl_check/ssl_check.py similarity index 96% rename from slack_bolt/middleware/ssl_check.py rename to slack_bolt/middleware/ssl_check/ssl_check.py index 5e80cf446..8d4c82e3d 100644 --- a/slack_bolt/middleware/ssl_check.py +++ b/slack_bolt/middleware/ssl_check/ssl_check.py @@ -1,7 +1,7 @@ from typing import Callable from slack_bolt.logger import get_bolt_logger -from .middleware import Middleware +from slack_bolt.middleware.middleware import Middleware from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse diff --git a/slack_bolt/middleware/url_verification/__init__.py b/slack_bolt/middleware/url_verification/__init__.py new file mode 100644 index 000000000..73c2e2952 --- /dev/null +++ b/slack_bolt/middleware/url_verification/__init__.py @@ -0,0 +1 @@ +from .url_verification import UrlVerification # noqa diff --git a/slack_bolt/middleware/async_url_verification.py b/slack_bolt/middleware/url_verification/async_url_verification.py similarity index 86% rename from slack_bolt/middleware/async_url_verification.py rename to slack_bolt/middleware/url_verification/async_url_verification.py index 9bbbf2ec9..3ea27334e 100644 --- a/slack_bolt/middleware/async_url_verification.py +++ b/slack_bolt/middleware/url_verification/async_url_verification.py @@ -1,8 +1,8 @@ from typing import Callable, Awaitable from slack_bolt.logger import get_bolt_logger -from . import UrlVerification -from .async_middleware import AsyncMiddleware +from .url_verification import UrlVerification +from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse diff --git a/slack_bolt/middleware/url_verification.py b/slack_bolt/middleware/url_verification/url_verification.py similarity index 94% rename from slack_bolt/middleware/url_verification.py rename to slack_bolt/middleware/url_verification/url_verification.py index 01366179b..383d7b3bb 100644 --- a/slack_bolt/middleware/url_verification.py +++ b/slack_bolt/middleware/url_verification/url_verification.py @@ -1,7 +1,7 @@ from typing import Callable from slack_bolt.logger import get_bolt_logger -from .middleware import Middleware +from slack_bolt.middleware.middleware import Middleware from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse From a57da8c902474f46be7b78236b41140d33991323 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 1 Sep 2020 07:03:42 +0900 Subject: [PATCH 023/865] Return 404 when missing next() calls in global middleware (#53) --- slack_bolt/app/app.py | 4 + slack_bolt/app/async_app.py | 4 + tests/async_scenario_tests/test_middleware.py | 97 +++++++++++++++++++ tests/scenario_tests/test_middleware.py | 90 +++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 tests/async_scenario_tests/test_middleware.py create mode 100644 tests/scenario_tests/test_middleware.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index f4559af08..737f7d5bb 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -283,6 +283,10 @@ def middleware_next(): self._framework_logger.debug(f"Applying {middleware.name}") resp = middleware.process(req=req, resp=resp, next=middleware_next) if not middleware_state["next_called"]: + if resp is None: + return BoltResponse( + status=404, body={"error": "no next() calls in middleware"} + ) return resp for listener in self._listeners: diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 684e3cc42..947392b2c 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -302,6 +302,10 @@ async def async_middleware_next(): req=req, resp=resp, next=async_middleware_next ) if not middleware_state["next_called"]: + if resp is None: + return BoltResponse( + status=404, body={"error": "no next() calls in middleware"} + ) return resp for listener in self._async_listeners: diff --git a/tests/async_scenario_tests/test_middleware.py b/tests/async_scenario_tests/test_middleware.py new file mode 100644 index 000000000..241ba04c5 --- /dev/null +++ b/tests/async_scenario_tests/test_middleware.py @@ -0,0 +1,97 @@ +import asyncio +import json +from time import time + +import pytest + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient +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 TestAsyncMiddleware: + 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,) + + @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) + + def build_request(self) -> AsyncBoltRequest: + payload = { + "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())), json.dumps(payload) + return AsyncBoltRequest( + body=body, + headers={ + "content-type": ["application/json"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + + @pytest.mark.asyncio + async def test_no_next_call(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.use(no_next) + app.shortcut("test-shortcut")(just_ack) + + response = await app.async_dispatch(self.build_request()) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + @pytest.mark.asyncio + async def test_next_call(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.use(just_next) + app.shortcut("test-shortcut")(just_ack) + + response = await app.async_dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "acknowledged!" + assert self.mock_received_requests["/auth.test"] == 1 + + +async def just_ack(ack): + await ack("acknowledged!") + + +async def no_next(): + pass + + +async def just_next(next): + await next() diff --git a/tests/scenario_tests/test_middleware.py b/tests/scenario_tests/test_middleware.py new file mode 100644 index 000000000..f81b0e135 --- /dev/null +++ b/tests/scenario_tests/test_middleware.py @@ -0,0 +1,90 @@ +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.app import App +from slack_bolt.request import BoltRequest +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 TestMiddleware: + 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 build_request(self) -> BoltRequest: + payload = { + "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())), json.dumps(payload) + return BoltRequest( + body=body, + headers={ + "content-type": ["application/json"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + + def test_no_next_call(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + app.use(no_next) + app.shortcut("test-shortcut")(just_ack) + + response = app.dispatch(self.build_request()) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_next_call(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + app.use(just_next) + app.shortcut("test-shortcut")(just_ack) + + response = app.dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "acknowledged!" + assert self.mock_received_requests["/auth.test"] == 1 + + +def just_ack(ack): + ack("acknowledged!") + + +def no_next(): + pass + + +def just_next(next): + next() From a4a8634c600e89891c053120e4cb414db69dc56a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 1 Sep 2020 10:55:45 +0900 Subject: [PATCH 024/865] Add Docker samples (#52) * Add Docker samples * Add a missing file * Update Google Cloud Run samples to be more optimal * Tweak Dockerfile and add more samples * Update fastapi sample --- samples/docker/aiohttp/Dockerfile | 19 ++++++++++++ samples/docker/aiohttp/main.py | 17 +++++++++++ samples/docker/aiohttp/requirements.txt | 2 ++ samples/docker/fastapi-gunicorn/Dockerfile | 12 ++++++++ samples/docker/fastapi-gunicorn/main.py | 26 ++++++++++++++++ .../docker/fastapi-gunicorn/requirements.txt | 3 ++ samples/docker/flask-gunicorn/Dockerfile | 18 +++++++++++ samples/docker/flask-gunicorn/main.py | 24 +++++++++++++++ .../docker/flask-gunicorn/requirements.txt | 3 ++ samples/docker/flask-uwsgi/Dockerfile | 22 ++++++++++++++ samples/docker/flask-uwsgi/main.py | 24 +++++++++++++++ samples/docker/flask-uwsgi/requirements.txt | 3 ++ samples/docker/flask-uwsgi/uwsgi.ini | 7 +++++ samples/docker/sanic/Dockerfile | 18 +++++++++++ samples/docker/sanic/main.py | 30 +++++++++++++++++++ samples/docker/sanic/requirements.txt | 3 ++ samples/google_cloud_run/aiohttp/Dockerfile | 28 +++++++++++++++++ samples/google_cloud_run/aiohttp/main.py | 17 +++++++++++ .../google_cloud_run/aiohttp/requirements.txt | 2 ++ .../flask-gunicorn/Dockerfile | 28 +++++++++++++++++ .../google_cloud_run/flask-gunicorn/main.py | 30 +++++++++++++++++++ .../flask-gunicorn/requirements.txt | 3 ++ samples/google_cloud_run/sanic/Dockerfile | 28 +++++++++++++++++ samples/google_cloud_run/sanic/main.py | 30 +++++++++++++++++++ .../google_cloud_run/sanic/requirements.txt | 3 ++ samples/sanic/async_app.py | 7 ++++- samples/sanic/async_oauth_app.py | 7 ++++- 27 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 samples/docker/aiohttp/Dockerfile create mode 100644 samples/docker/aiohttp/main.py create mode 100644 samples/docker/aiohttp/requirements.txt create mode 100644 samples/docker/fastapi-gunicorn/Dockerfile create mode 100644 samples/docker/fastapi-gunicorn/main.py create mode 100644 samples/docker/fastapi-gunicorn/requirements.txt create mode 100644 samples/docker/flask-gunicorn/Dockerfile create mode 100644 samples/docker/flask-gunicorn/main.py create mode 100644 samples/docker/flask-gunicorn/requirements.txt create mode 100644 samples/docker/flask-uwsgi/Dockerfile create mode 100644 samples/docker/flask-uwsgi/main.py create mode 100644 samples/docker/flask-uwsgi/requirements.txt create mode 100644 samples/docker/flask-uwsgi/uwsgi.ini create mode 100644 samples/docker/sanic/Dockerfile create mode 100644 samples/docker/sanic/main.py create mode 100644 samples/docker/sanic/requirements.txt create mode 100644 samples/google_cloud_run/aiohttp/Dockerfile create mode 100644 samples/google_cloud_run/aiohttp/main.py create mode 100644 samples/google_cloud_run/aiohttp/requirements.txt create mode 100644 samples/google_cloud_run/flask-gunicorn/Dockerfile create mode 100644 samples/google_cloud_run/flask-gunicorn/main.py create mode 100644 samples/google_cloud_run/flask-gunicorn/requirements.txt create mode 100644 samples/google_cloud_run/sanic/Dockerfile create mode 100644 samples/google_cloud_run/sanic/main.py create mode 100644 samples/google_cloud_run/sanic/requirements.txt diff --git a/samples/docker/aiohttp/Dockerfile b/samples/docker/aiohttp/Dockerfile new file mode 100644 index 000000000..c1fd13af5 --- /dev/null +++ b/samples/docker/aiohttp/Dockerfile @@ -0,0 +1,19 @@ +# +# docker build . -t your-repo/hello-bolt +# +FROM python:3.8.5-slim-buster as builder +RUN apt-get update && apt-get clean +COPY requirements.txt /build/ +WORKDIR /build/ +RUN pip install -U pip && pip install -r requirements.txt + +FROM python:3.8.5-slim-buster as app +COPY --from=builder /src/ /app/ +COPY --from=builder /usr/local/lib/ /usr/local/lib/ +WORKDIR /app/ +COPY *.py /app/ +ENTRYPOINT python main.py + +# +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e PORT=3000 -p 3000:3000 -it your-repo/hello-bolt +# \ No newline at end of file diff --git a/samples/docker/aiohttp/main.py b/samples/docker/aiohttp/main.py new file mode 100644 index 000000000..ebd1d1f1d --- /dev/null +++ b/samples/docker/aiohttp/main.py @@ -0,0 +1,17 @@ +import os +import logging + +from slack_bolt.async_app import AsyncApp + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() + + +@app.command("/hello-bolt-python") +async def hello(payload, ack): + user_id = payload["user_id"] + await ack(f"Hi <@{user_id}>!") + + +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", int(os.environ.get("PORT", 3000))))) diff --git a/samples/docker/aiohttp/requirements.txt b/samples/docker/aiohttp/requirements.txt new file mode 100644 index 000000000..9ffe0006a --- /dev/null +++ b/samples/docker/aiohttp/requirements.txt @@ -0,0 +1,2 @@ +slack_bolt +aiohttp>=3,<4 \ No newline at end of file diff --git a/samples/docker/fastapi-gunicorn/Dockerfile b/samples/docker/fastapi-gunicorn/Dockerfile new file mode 100644 index 000000000..eacd2c57d --- /dev/null +++ b/samples/docker/fastapi-gunicorn/Dockerfile @@ -0,0 +1,12 @@ +# +# docker build . -t your-repo/hello-bolt +# +FROM tiangolo/uvicorn-gunicorn:python3.8-slim +WORKDIR /app/ +COPY *.py /app/ +COPY requirements.txt /app/ +RUN pip install -U pip && pip install -r requirements.txt + +# +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e VARIABLE_NAME="api" -p 80:80 -it your-repo/hello-bolt +# \ No newline at end of file diff --git a/samples/docker/fastapi-gunicorn/main.py b/samples/docker/fastapi-gunicorn/main.py new file mode 100644 index 000000000..dc3a1d28d --- /dev/null +++ b/samples/docker/fastapi-gunicorn/main.py @@ -0,0 +1,26 @@ +import logging + +from slack_bolt.async_app import AsyncApp + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() + + +@app.command("/hello-bolt-python") +async def hello(payload, ack): + user_id = payload["user_id"] + await ack(f"Hi <@{user_id}>!") + + +from fastapi import FastAPI, Request + +api = FastAPI() + +from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler + +app_handler = AsyncSlackRequestHandler(app) + + +@api.post("/slack/events") +async def endpoint(req: Request): + return await app_handler.handle(req) diff --git a/samples/docker/fastapi-gunicorn/requirements.txt b/samples/docker/fastapi-gunicorn/requirements.txt new file mode 100644 index 000000000..e005ac5e5 --- /dev/null +++ b/samples/docker/fastapi-gunicorn/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +fastapi<1 +aiohttp>=3,<4 \ No newline at end of file diff --git a/samples/docker/flask-gunicorn/Dockerfile b/samples/docker/flask-gunicorn/Dockerfile new file mode 100644 index 000000000..e788cb388 --- /dev/null +++ b/samples/docker/flask-gunicorn/Dockerfile @@ -0,0 +1,18 @@ +# +# docker build . -t your-repo/hello-bolt +# +FROM python:3.8.5-slim-buster as builder +COPY requirements.txt /build/ +WORKDIR /build/ +RUN pip install -U pip && pip install -r requirements.txt + +FROM python:3.8.5-slim-buster as app +WORKDIR /app/ +COPY --from=builder /usr/local/bin/ /usr/local/bin/ +COPY --from=builder /usr/local/lib/ /usr/local/lib/ +COPY *.py /app/ +ENTRYPOINT gunicorn --bind :$PORT --workers 1 --threads 2 --timeout 0 main:flask_app + +# +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e PORT=3000 -p 3000:3000 -it your-repo/hello-bolt +# \ No newline at end of file diff --git a/samples/docker/flask-gunicorn/main.py b/samples/docker/flask-gunicorn/main.py new file mode 100644 index 000000000..1fdad9545 --- /dev/null +++ b/samples/docker/flask-gunicorn/main.py @@ -0,0 +1,24 @@ +import logging + +from slack_bolt import App + +logging.basicConfig(level=logging.DEBUG) +app = App() + + +@app.command("/hello-bolt-python") +def hello(payload, ack): + user_id = payload["user_id"] + ack(f"Hi <@{user_id}>!") + + +from flask import Flask, request +from slack_bolt.adapter.flask import SlackRequestHandler + +flask_app = Flask(__name__) +handler = SlackRequestHandler(app) + + +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + return handler.handle(request) diff --git a/samples/docker/flask-gunicorn/requirements.txt b/samples/docker/flask-gunicorn/requirements.txt new file mode 100644 index 000000000..24cc3e223 --- /dev/null +++ b/samples/docker/flask-gunicorn/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +Flask>=1.1 +gunicorn>=20 \ No newline at end of file diff --git a/samples/docker/flask-uwsgi/Dockerfile b/samples/docker/flask-uwsgi/Dockerfile new file mode 100644 index 000000000..14ddfb41b --- /dev/null +++ b/samples/docker/flask-uwsgi/Dockerfile @@ -0,0 +1,22 @@ +# +# docker build . -t your-repo/hello-bolt +# +FROM python:3.8.5-slim-buster as builder +RUN apt-get update \ + && apt-get -y install build-essential libpcre3-dev \ + && apt-get clean +COPY requirements.txt /build/ +WORKDIR /build/ +RUN pip install -U pip && pip install -r requirements.txt + +FROM python:3.8.5-slim-buster as app +WORKDIR /app/ +COPY --from=builder /usr/local/bin/ /usr/local/bin/ +COPY --from=builder /usr/local/lib/ /usr/local/lib/ +COPY *.py /app/ +COPY uwsgi.ini /app/ +ENTRYPOINT uwsgi --ini uwsgi.ini --http :$PORT + +# +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e PORT=3000 -p 3000:3000 -it your-repo/hello-bolt +# \ No newline at end of file diff --git a/samples/docker/flask-uwsgi/main.py b/samples/docker/flask-uwsgi/main.py new file mode 100644 index 000000000..1fdad9545 --- /dev/null +++ b/samples/docker/flask-uwsgi/main.py @@ -0,0 +1,24 @@ +import logging + +from slack_bolt import App + +logging.basicConfig(level=logging.DEBUG) +app = App() + + +@app.command("/hello-bolt-python") +def hello(payload, ack): + user_id = payload["user_id"] + ack(f"Hi <@{user_id}>!") + + +from flask import Flask, request +from slack_bolt.adapter.flask import SlackRequestHandler + +flask_app = Flask(__name__) +handler = SlackRequestHandler(app) + + +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + return handler.handle(request) diff --git a/samples/docker/flask-uwsgi/requirements.txt b/samples/docker/flask-uwsgi/requirements.txt new file mode 100644 index 000000000..f768869b4 --- /dev/null +++ b/samples/docker/flask-uwsgi/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +Flask>1 +uWSGI>=2,<3 \ No newline at end of file diff --git a/samples/docker/flask-uwsgi/uwsgi.ini b/samples/docker/flask-uwsgi/uwsgi.ini new file mode 100644 index 000000000..c2a089723 --- /dev/null +++ b/samples/docker/flask-uwsgi/uwsgi.ini @@ -0,0 +1,7 @@ +[uwsgi] +module = main:flask_app +master = true +uid = nobody +socket = /tmp/uwsgi.sock +die-on-term = true +enable-threads = true \ No newline at end of file diff --git a/samples/docker/sanic/Dockerfile b/samples/docker/sanic/Dockerfile new file mode 100644 index 000000000..4606e68bd --- /dev/null +++ b/samples/docker/sanic/Dockerfile @@ -0,0 +1,18 @@ +# +# docker build . -t your-repo/hello-bolt +# +FROM python:3.8.5-slim-buster as builder +COPY requirements.txt /build/ +WORKDIR /build/ +RUN pip install -U pip && pip install -r requirements.txt + +FROM python:3.8.5-slim-buster as app +WORKDIR /app/ +COPY *.py /app/ +COPY --from=builder /usr/local/bin/ /usr/local/bin/ +COPY --from=builder /usr/local/lib/ /usr/local/lib/ +ENTRYPOINT python main.py + +# +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e PORT=3000 -p 3000:3000 -it your-repo/hello-bolt +# diff --git a/samples/docker/sanic/main.py b/samples/docker/sanic/main.py new file mode 100644 index 000000000..92da3239c --- /dev/null +++ b/samples/docker/sanic/main.py @@ -0,0 +1,30 @@ +import logging +import os + +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.sanic import AsyncSlackRequestHandler + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() +app_handler = AsyncSlackRequestHandler(app) + + +@app.command("/hello-bolt-python") +async def hello(payload, ack): + user_id = payload["user_id"] + await ack(f"Hi <@{user_id}>!") + + +from sanic import Sanic +from sanic.request import Request + +api = Sanic(name="awesome-slack-app") + + +@api.post("/slack/events") +async def endpoint(req: Request): + return await app_handler.handle(req) + + +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) diff --git a/samples/docker/sanic/requirements.txt b/samples/docker/sanic/requirements.txt new file mode 100644 index 000000000..f72449c0e --- /dev/null +++ b/samples/docker/sanic/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +aiohttp>=3,<4 +sanic>=20,<21 \ No newline at end of file diff --git a/samples/google_cloud_run/aiohttp/Dockerfile b/samples/google_cloud_run/aiohttp/Dockerfile new file mode 100644 index 000000000..e4432e3a2 --- /dev/null +++ b/samples/google_cloud_run/aiohttp/Dockerfile @@ -0,0 +1,28 @@ +# +# https://cloud.google.com/run/docs/quickstarts/build-and-deploy +# +# export PROJECT_ID=`gcloud config get-value project` +# export SLACK_SIGNING_SECRET= +# export SLACK_BOT_TOKEN= +# gcloud builds submit --tag gcr.io/$PROJECT_ID/helloworld +# gcloud run deploy helloworld --image gcr.io/$PROJECT_ID/helloworld --platform managed --update-env-vars SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET,SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN +# + +# ---------------------------------------------- +# Use the official lightweight Python image. +# https://hub.docker.com/_/python +FROM python:3.8.5-slim-buster + +# Allow statements and log messages to immediately appear in the Knative logs +ENV PYTHONUNBUFFERED True + +# Copy local code to the container image. +ENV APP_HOME /app +WORKDIR $APP_HOME +COPY . ./ + +# Install production dependencies. +RUN pip install -U pip && pip install -r requirements.txt + +# Start AIOHTTP server +ENTRYPOINT python main.py diff --git a/samples/google_cloud_run/aiohttp/main.py b/samples/google_cloud_run/aiohttp/main.py new file mode 100644 index 000000000..5602d4754 --- /dev/null +++ b/samples/google_cloud_run/aiohttp/main.py @@ -0,0 +1,17 @@ +import logging +import os + +from slack_bolt.async_app import AsyncApp + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() + + +@app.command("/hey-google") +async def hello(payload, ack): + user_id = payload["user_id"] + await ack(f"Hi <@{user_id}>!") + + +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) diff --git a/samples/google_cloud_run/aiohttp/requirements.txt b/samples/google_cloud_run/aiohttp/requirements.txt new file mode 100644 index 000000000..9ffe0006a --- /dev/null +++ b/samples/google_cloud_run/aiohttp/requirements.txt @@ -0,0 +1,2 @@ +slack_bolt +aiohttp>=3,<4 \ No newline at end of file diff --git a/samples/google_cloud_run/flask-gunicorn/Dockerfile b/samples/google_cloud_run/flask-gunicorn/Dockerfile new file mode 100644 index 000000000..8342e3a8b --- /dev/null +++ b/samples/google_cloud_run/flask-gunicorn/Dockerfile @@ -0,0 +1,28 @@ +# +# https://cloud.google.com/run/docs/quickstarts/build-and-deploy +# +# export PROJECT_ID=`gcloud config get-value project` +# export SLACK_SIGNING_SECRET= +# export SLACK_BOT_TOKEN= +# gcloud builds submit --tag gcr.io/$PROJECT_ID/helloworld +# gcloud run deploy helloworld --image gcr.io/$PROJECT_ID/helloworld --platform managed --update-env-vars SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET,SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN +# + +# ---------------------------------------------- +# Use the official lightweight Python image. +# https://hub.docker.com/_/python +FROM python:3.8.5-slim-buster + +# Allow statements and log messages to immediately appear in the Knative logs +ENV PYTHONUNBUFFERED True + +# Copy local code to the container image. +ENV APP_HOME /app +WORKDIR $APP_HOME +COPY . ./ + +# Install production dependencies. +RUN pip install -U pip && pip install -r requirements.txt + +# Run the web service on container startup. +ENTRYPOINT gunicorn --bind :$PORT --workers 1 --threads 2 --timeout 0 main:flask_app diff --git a/samples/google_cloud_run/flask-gunicorn/main.py b/samples/google_cloud_run/flask-gunicorn/main.py new file mode 100644 index 000000000..552e4e877 --- /dev/null +++ b/samples/google_cloud_run/flask-gunicorn/main.py @@ -0,0 +1,30 @@ +import logging +import os + +from slack_bolt import App + +logging.basicConfig(level=logging.DEBUG) +app = App() + + +@app.command("/hey-google") +def hello(payload, ack): + user_id = payload["user_id"] + ack(f"Hi <@{user_id}>!") + + +from flask import Flask, request +from slack_bolt.adapter.flask import SlackRequestHandler + +flask_app = Flask(__name__) +handler = SlackRequestHandler(app) + + +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + return handler.handle(request) + + +# Only for local debug +if __name__ == "__main__": + flask_app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) diff --git a/samples/google_cloud_run/flask-gunicorn/requirements.txt b/samples/google_cloud_run/flask-gunicorn/requirements.txt new file mode 100644 index 000000000..24cc3e223 --- /dev/null +++ b/samples/google_cloud_run/flask-gunicorn/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +Flask>=1.1 +gunicorn>=20 \ No newline at end of file diff --git a/samples/google_cloud_run/sanic/Dockerfile b/samples/google_cloud_run/sanic/Dockerfile new file mode 100644 index 000000000..d71ddef67 --- /dev/null +++ b/samples/google_cloud_run/sanic/Dockerfile @@ -0,0 +1,28 @@ +# +# https://cloud.google.com/run/docs/quickstarts/build-and-deploy +# +# export PROJECT_ID=`gcloud config get-value project` +# export SLACK_SIGNING_SECRET= +# export SLACK_BOT_TOKEN= +# gcloud builds submit --tag gcr.io/$PROJECT_ID/helloworld +# gcloud run deploy helloworld --image gcr.io/$PROJECT_ID/helloworld --platform managed --update-env-vars SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET,SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN +# + +# ---------------------------------------------- +# Use the official lightweight Python image. +# https://hub.docker.com/_/python +FROM python:3.8.5-slim-buster + +# Allow statements and log messages to immediately appear in the Knative logs +ENV PYTHONUNBUFFERED True + +# Copy local code to the container image. +ENV APP_HOME /app +WORKDIR $APP_HOME +COPY . ./ + +# Install production dependencies. +RUN pip install -U pip && pip install -r requirements.txt + +# Start Sanic server +ENTRYPOINT python main.py diff --git a/samples/google_cloud_run/sanic/main.py b/samples/google_cloud_run/sanic/main.py new file mode 100644 index 000000000..47afb27eb --- /dev/null +++ b/samples/google_cloud_run/sanic/main.py @@ -0,0 +1,30 @@ +import logging +import os + +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.sanic import AsyncSlackRequestHandler + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() +app_handler = AsyncSlackRequestHandler(app) + + +@app.command("/hey-google") +async def hello(payload, ack): + user_id = payload["user_id"] + await ack(f"Hi <@{user_id}>!") + + +from sanic import Sanic +from sanic.request import Request + +api = Sanic(name="awesome-slack-app") + + +@api.post("/slack/events") +async def endpoint(req: Request): + return await app_handler.handle(req) + + +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) diff --git a/samples/google_cloud_run/sanic/requirements.txt b/samples/google_cloud_run/sanic/requirements.txt new file mode 100644 index 000000000..f72449c0e --- /dev/null +++ b/samples/google_cloud_run/sanic/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +aiohttp>=3,<4 +sanic>=20,<21 \ No newline at end of file diff --git a/samples/sanic/async_app.py b/samples/sanic/async_app.py index 02b0b20cf..8797e4358 100644 --- a/samples/sanic/async_app.py +++ b/samples/sanic/async_app.py @@ -5,6 +5,7 @@ sys.path.insert(1, "../..") # ------------------------------------------------ +import os from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.sanic import AsyncSlackRequestHandler @@ -21,7 +22,7 @@ async def handle_app_mentions(payload, say, logger): from sanic import Sanic from sanic.request import Request -api = Sanic() +api = Sanic(name="awesome-slack-app") @api.post("/slack/events") @@ -29,6 +30,10 @@ async def endpoint(req: Request): return await app_handler.handle(req) +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) + + # pip install -r requirements.txt # export SLACK_SIGNING_SECRET=*** # export SLACK_BOT_TOKEN=xoxb-*** diff --git a/samples/sanic/async_oauth_app.py b/samples/sanic/async_oauth_app.py index dc6205732..b332fe438 100644 --- a/samples/sanic/async_oauth_app.py +++ b/samples/sanic/async_oauth_app.py @@ -5,6 +5,7 @@ sys.path.insert(1, "../..") # ------------------------------------------------ +import os from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.sanic import AsyncSlackRequestHandler @@ -21,7 +22,7 @@ async def handle_app_mentions(payload, say, logger): from sanic import Sanic from sanic.request import Request -api = Sanic() +api = Sanic(name="awesome-slack-app") @api.post("/slack/events") @@ -39,6 +40,10 @@ async def oauth_redirect(req: Request): return await app_handler.handle(req) +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) + + # pip install -r requirements.txt # # -- OAuth flow -- # From 41326ee6b72763c05680cdb0fcfababbf88907e7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 1 Sep 2020 10:56:03 +0900 Subject: [PATCH 025/865] Update app.start()'s message and docstring (#54) --- samples/app.py | 6 ++++++ samples/async_app.py | 5 +++-- slack_bolt/app/app.py | 20 +++++++++++++++----- slack_bolt/app/async_server.py | 5 +++++ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/samples/app.py b/samples/app.py index 54739356a..a7d051e6c 100644 --- a/samples/app.py +++ b/samples/app.py @@ -20,6 +20,12 @@ def log_request(logger, payload, next): return next() +@app.command("/hello-bolt-python") +def hello_command(ack, payload): + user_id = payload["user_id"] + ack(f"Hi <@{user_id}>!") + + @app.event("app_mention") def event_test(payload, say, logger): logger.info(payload) diff --git a/samples/async_app.py b/samples/async_app.py index 67a726df3..07d25e802 100644 --- a/samples/async_app.py +++ b/samples/async_app.py @@ -28,8 +28,9 @@ async def event_test(payload, say, logger): @app.command("/hello-bolt-python") # or app.command(re.compile(r"/hello-.+"))(test_command) -async def command(ack): - await ack("Thanks!") +async def command(ack, payload): + user_id = payload["user_id"] + await ack(f"Hi <@{user_id}>!") if __name__ == "__main__": diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 737f7d5bb..e87d0377f 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -260,10 +260,10 @@ def listener_error_handler(self) -> ListenerErrorHandler: # standalone server def start(self, port: int = 3000, path: str = "/slack/events") -> None: - self.server = SlackAppServer( + self._development_server = SlackAppDevelopmentServer( port=port, path=path, app=self, oauth_flow=self.oauth_flow, ) - self.server.start() + self._development_server.start() # ------------------------- # main dispatcher @@ -803,10 +803,20 @@ def _register_listener( # ------------------------- -class SlackAppServer: +class SlackAppDevelopmentServer: def __init__( self, port: int, path: str, app: App, oauth_flow: Optional[OAuthFlow] = None, ): + """Slack App Development Server + + This is a thin wrapper of http.server.HTTPServer and is good enough + for your local development or prototyping. + + However, as mentioned in Python official documents, using http.server module in production + is not recommended. Please consider using an adapter (refer to slack_bolt.adapter.*) + along with a production-grade server when running the app for end users. + https://docs.python.org/3/library/http.server.html#http.server.HTTPServer + """ self._port: int = port self._bolt_endpoint_path: str = path self._bolt_app: App = app @@ -881,9 +891,9 @@ def _send_response( def start(self): if self._bolt_app.logger.level > logging.INFO: - print("⚡️ Bolt app is running!") + print("⚡️ Bolt app is running! (development server)") else: - self._bolt_app.logger.info("⚡️ Bolt app is running!") + self._bolt_app.logger.info("⚡️ Bolt app is running! (development server)") try: self._server.serve_forever(0.05) diff --git a/slack_bolt/app/async_server.py b/slack_bolt/app/async_server.py index 642f7f37e..7dc74cdf4 100644 --- a/slack_bolt/app/async_server.py +++ b/slack_bolt/app/async_server.py @@ -10,6 +10,11 @@ class AsyncSlackAppServer: def __init__( self, port: int, path: str, app, # AsyncApp ): + """Standalone AIOHTTP Web Server + + Refer to AIOHTTP documents for details. + https://docs.aiohttp.org/en/stable/web.html + """ self._port = port self._endpoint_path = path self._bolt_app: "AsyncApp" = app From 25367151d6bcc57c278127a8c3594065fdd840dd Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 3 Sep 2020 14:42:26 +0900 Subject: [PATCH 026/865] Add "workflow_step_edit" action support and samples (#58) --- samples/async_steps_from_apps.py | 178 +++++++++++++++++++++++ samples/steps_from_apps.py | 180 ++++++++++++++++++++++++ slack_bolt/listener_matcher/builtins.py | 18 +++ 3 files changed, 376 insertions(+) create mode 100644 samples/async_steps_from_apps.py create mode 100644 samples/steps_from_apps.py diff --git a/samples/async_steps_from_apps.py b/samples/async_steps_from_apps.py new file mode 100644 index 000000000..d1c7b8dd8 --- /dev/null +++ b/samples/async_steps_from_apps.py @@ -0,0 +1,178 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging + +from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient +from slack_bolt.async_app import AsyncApp, AsyncAck + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = AsyncApp() + + +# https://api.slack.com/tutorials/workflow-builder-steps + +@app.action({"type": "workflow_step_edit", "callback_id": "copy_review"}) +async def edit(body: dict, ack: AsyncAck, client: AsyncWebClient): + await ack() + new_modal: AsyncSlackResponse = await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "workflow_step", + "callback_id": "copy_review_view", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps." + } + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name" + } + }, + "label": { + "type": "plain_text", + "text": "Task name" + } + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task" + } + }, + "label": { + "type": "plain_text", + "text": "Task description" + } + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name" + } + }, + "label": { + "type": "plain_text", + "text": "Task author" + } + }, + ] + }, + ) + + +@app.view("copy_review_view") +async def save(ack: AsyncAck, client: AsyncWebClient, body: dict): + state_values = body["view"]["state"]["values"] + response: AsyncSlackResponse = await client.api_call( + api_method="workflows.updateStep", + json={ + "workflow_step_edit_id": body["workflow_step"]["workflow_step_edit_id"], + "inputs": { + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + } + }, + "outputs": [ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ] + } + ) + await ack() + + +pseudo_database = {} + + +@app.event("workflow_step_execute") +async def execute(body: dict, client: AsyncWebClient): + step = body["event"]["workflow_step"] + completion: AsyncSlackResponse = await client.api_call( + api_method="workflows.stepCompleted", + json={ + "workflow_step_execute_id": step["workflow_step_execute_id"], + "outputs": { + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + } + ) + user: AsyncSlackResponse = await client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append({"type": "section", "text": {"type": "plain_text", "text": task["task_name"]}}) + blocks.append({"type": "divider"}) + + home_tab_update: AsyncSlackResponse = await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": { + "type": 'plain_text', + "text": 'Your tasks!' + }, + "blocks": blocks + } + ) + + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/samples/steps_from_apps.py b/samples/steps_from_apps.py new file mode 100644 index 000000000..15cc3e2a9 --- /dev/null +++ b/samples/steps_from_apps.py @@ -0,0 +1,180 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt import App, Ack + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = App() + + +# https://api.slack.com/tutorials/workflow-builder-steps + +@app.action({"type": "workflow_step_edit", "callback_id": "copy_review"}) +def edit(body: dict, ack: Ack, client: WebClient): + ack() + new_modal: SlackResponse = client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "workflow_step", + "callback_id": "copy_review_view", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps." + } + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name" + } + }, + "label": { + "type": "plain_text", + "text": "Task name" + } + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task" + } + }, + "label": { + "type": "plain_text", + "text": "Task description" + } + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name" + } + }, + "label": { + "type": "plain_text", + "text": "Task author" + } + }, + ] + }, + ) + + +@app.view("copy_review_view") +def save(ack: Ack, client: WebClient, body: dict): + state_values = body["view"]["state"]["values"] + response: SlackResponse = client.api_call( + api_method="workflows.updateStep", + json={ + "workflow_step_edit_id": body["workflow_step"]["workflow_step_edit_id"], + "inputs": { + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + } + }, + "outputs": [ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ] + } + ) + ack() + + +pseudo_database = {} + + +@app.event("workflow_step_execute") +def execute(body: dict, client: WebClient): + step = body["event"]["workflow_step"] + completion: SlackResponse = client.api_call( + api_method="workflows.stepCompleted", + json={ + "workflow_step_execute_id": step["workflow_step_execute_id"], + "outputs": { + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + } + ) + user: SlackResponse = client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append({"type": "section", "text": {"type": "plain_text", "text": task["task_name"]}}) + blocks.append({"type": "divider"}) + + home_tab_update: SlackResponse = client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": { + "type": 'plain_text', + "text": 'Your tasks!' + }, + "blocks": blocks + } + ) + + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 40f1c7a66..0fc0e8692 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -203,6 +203,11 @@ def action( if action_type == "dialog_cancellation": return dialog_cancellation(constraints["callback_id"], asyncio) + # Still in beta + # https://api.slack.com/workflows/steps + if action_type == "workflow_step_edit": + return workflow_step_edit(constraints["callback_id"], asyncio) + raise BoltError(f"type: {action_type} is unsupported") raise BoltError( @@ -264,6 +269,19 @@ def func(payload: dict) -> bool: return build_listener_matcher(func, asyncio) +def workflow_step_edit( + callback_id: Union[str, Pattern], asyncio: bool = False, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + def func(payload: dict) -> bool: + return ( + payload + and _is_expected_type(payload, "workflow_step_edit") + and _matches(callback_id, payload["callback_id"]) + ) + + return build_listener_matcher(func, asyncio) + + # ------------------------- # view From 839ba95d7b99e31c98d85926dbf30dbc587f468a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 4 Sep 2020 06:20:19 +0900 Subject: [PATCH 027/865] Fix #57 by GAE samples (#60) --- samples/google_app_engine/flask/.gcloudignore | 19 ++++++++++ samples/google_app_engine/flask/.gitignore | 1 + samples/google_app_engine/flask/app.yaml | 16 +++++++++ .../flask/env_variables.yaml.sample | 3 ++ samples/google_app_engine/flask/main.py | 36 +++++++++++++++++++ .../google_app_engine/flask/requirements.txt | 2 ++ 6 files changed, 77 insertions(+) create mode 100644 samples/google_app_engine/flask/.gcloudignore create mode 100644 samples/google_app_engine/flask/.gitignore create mode 100644 samples/google_app_engine/flask/app.yaml create mode 100644 samples/google_app_engine/flask/env_variables.yaml.sample create mode 100644 samples/google_app_engine/flask/main.py create mode 100644 samples/google_app_engine/flask/requirements.txt diff --git a/samples/google_app_engine/flask/.gcloudignore b/samples/google_app_engine/flask/.gcloudignore new file mode 100644 index 000000000..a987f1123 --- /dev/null +++ b/samples/google_app_engine/flask/.gcloudignore @@ -0,0 +1,19 @@ +# This file specifies files that are *not* uploaded to Google Cloud Platform +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +# Python pycache: +__pycache__/ +# Ignored by the build system +/setup.cfg \ No newline at end of file diff --git a/samples/google_app_engine/flask/.gitignore b/samples/google_app_engine/flask/.gitignore new file mode 100644 index 000000000..c9a0737e2 --- /dev/null +++ b/samples/google_app_engine/flask/.gitignore @@ -0,0 +1 @@ +env_variables.yaml \ No newline at end of file diff --git a/samples/google_app_engine/flask/app.yaml b/samples/google_app_engine/flask/app.yaml new file mode 100644 index 000000000..dfe7de530 --- /dev/null +++ b/samples/google_app_engine/flask/app.yaml @@ -0,0 +1,16 @@ +runtime: python38 + +inbound_services: + - warmup + +automatic_scaling: + min_idle_instances: 1 + + +handlers: + - url: /slack/events + secure: always + script: auto + +includes: + - env_variables.yaml diff --git a/samples/google_app_engine/flask/env_variables.yaml.sample b/samples/google_app_engine/flask/env_variables.yaml.sample new file mode 100644 index 000000000..da4c287e2 --- /dev/null +++ b/samples/google_app_engine/flask/env_variables.yaml.sample @@ -0,0 +1,3 @@ +env_variables: + SLACK_BOT_TOKEN: "xoxb-xxx" + SLACK_SIGNING_SECRET: "yyy" \ No newline at end of file diff --git a/samples/google_app_engine/flask/main.py b/samples/google_app_engine/flask/main.py new file mode 100644 index 000000000..2e52c0577 --- /dev/null +++ b/samples/google_app_engine/flask/main.py @@ -0,0 +1,36 @@ +import logging +import os + +from slack_bolt import App + +logging.basicConfig(level=logging.DEBUG) +bolt_app = App() + + +@bolt_app.command("/hey-google-app-engine") +def hello(payload, ack): + user_id = payload["user_id"] + ack(f"Hi <@{user_id}>!") + + +from flask import Flask, request +from slack_bolt.adapter.flask import SlackRequestHandler + +app = Flask(__name__) +handler = SlackRequestHandler(bolt_app) + + +@app.route('/_ah/warmup') +def warmup(): + # Handle your warmup logic here, e.g. set up a database connection pool + return "", 200, {} + + +@app.route("/slack/events", methods=["POST"]) +def slack_events(): + return handler.handle(request) + + +# Only for local debug +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) diff --git a/samples/google_app_engine/flask/requirements.txt b/samples/google_app_engine/flask/requirements.txt new file mode 100644 index 000000000..6b56c96ae --- /dev/null +++ b/samples/google_app_engine/flask/requirements.txt @@ -0,0 +1,2 @@ +slack_bolt +Flask>=1.1,<2 \ No newline at end of file From b8c70d2a98864b434239efaa37f6a7044f99c5c6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 4 Sep 2020 06:42:35 +0900 Subject: [PATCH 028/865] Fix #56 by adding aliases to payload in listener/middleware args (#59) * Fix #56 by adding aliases to payload in listener/middleware args * Remove duplicated value assignments --- slack_bolt/kwargs_injection/args.py | 28 ++- slack_bolt/kwargs_injection/async_args.py | 28 ++- slack_bolt/kwargs_injection/async_utils.py | 19 ++ slack_bolt/kwargs_injection/utils.py | 19 ++ slack_bolt/listener_matcher/builtins.py | 167 +++++--------- slack_bolt/util/payload_utils.py | 210 ++++++++++++++++++ .../test_attachment_actions.py | 3 +- .../test_block_actions.py | 4 +- .../test_block_suggestion.py | 6 +- tests/async_scenario_tests/test_dialogs.py | 6 +- tests/async_scenario_tests/test_events.py | 6 +- tests/async_scenario_tests/test_shortcut.py | 3 +- .../test_slash_command.py | 3 +- .../async_scenario_tests/test_view_closed.py | 5 +- .../test_view_submission.py | 5 +- .../scenario_tests/test_attachment_actions.py | 3 +- tests/scenario_tests/test_block_actions.py | 4 +- tests/scenario_tests/test_block_suggestion.py | 6 +- tests/scenario_tests/test_dialogs.py | 6 +- tests/scenario_tests/test_events.py | 6 +- tests/scenario_tests/test_message.py | 3 +- tests/scenario_tests/test_shortcut.py | 3 +- tests/scenario_tests/test_slash_command.py | 3 +- tests/scenario_tests/test_view_closed.py | 5 +- tests/scenario_tests/test_view_submission.py | 5 +- 25 files changed, 419 insertions(+), 137 deletions(-) create mode 100644 slack_bolt/util/payload_utils.py diff --git a/slack_bolt/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index 7215be1ae..88c961ab1 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -1,7 +1,7 @@ # pytype: skip-file import logging from logging import Logger -from typing import Callable, Dict, Any +from typing import Callable, Dict, Any, Optional from slack_bolt.context import BoltContext from slack_bolt.context.ack import Ack @@ -20,10 +20,20 @@ class Args: request: BoltRequest response: BoltResponse context: BoltContext + # payload payload: Dict[str, Any] + options: Optional[Dict[str, Any]] + shortcut: Optional[Dict[str, Any]] + action: Optional[Dict[str, Any]] + view: Optional[Dict[str, Any]] + command: Optional[Dict[str, Any]] + event: Optional[Dict[str, Any]] + message: Optional[Dict[str, Any]] + # utilities ack: Ack say: Say respond: Respond + # middleware next: Callable[[], None] def __init__( @@ -35,6 +45,13 @@ def __init__( resp: BoltResponse, context: BoltContext, payload: Dict[str, Any], + options: Optional[Dict[str, Any]] = None, + shortcut: Optional[Dict[str, Any]] = None, + action: Optional[Dict[str, Any]] = None, + view: Optional[Dict[str, Any]] = None, + command: Optional[Dict[str, Any]] = None, + event: Optional[Dict[str, Any]] = None, + message: Optional[Dict[str, Any]] = None, ack: Ack, say: Say, respond: Respond, @@ -46,8 +63,17 @@ def __init__( self.request = self.req = req self.response = self.resp = resp self.context: BoltContext = context + self.payload: Dict[str, Any] = payload self.body: Dict[str, Any] = payload + self.options: Optional[Dict[str, Any]] = options + self.shortcut: Optional[Dict[str, Any]] = shortcut + self.action: Optional[Dict[str, Any]] = action + self.view: Optional[Dict[str, Any]] = view + self.command: Optional[Dict[str, Any]] = command + self.event: Optional[Dict[str, Any]] = event + self.message: Optional[Dict[str, Any]] = message + self.ack: Ack = ack self.say: Say = say self.respond: Respond = respond diff --git a/slack_bolt/kwargs_injection/async_args.py b/slack_bolt/kwargs_injection/async_args.py index 68e5565ad..c4abe4724 100644 --- a/slack_bolt/kwargs_injection/async_args.py +++ b/slack_bolt/kwargs_injection/async_args.py @@ -1,6 +1,6 @@ # pytype: skip-file from logging import Logger -from typing import Callable, Awaitable, Dict, Any +from typing import Callable, Awaitable, Dict, Any, Optional from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.context.async_context import AsyncBoltContext @@ -19,10 +19,20 @@ class AsyncArgs: request: AsyncBoltRequest response: BoltResponse context: AsyncBoltContext + # payload payload: Dict[str, Any] + options: Optional[Dict[str, Any]] + shortcut: Optional[Dict[str, Any]] + action: Optional[Dict[str, Any]] + view: Optional[Dict[str, Any]] + command: Optional[Dict[str, Any]] + event: Optional[Dict[str, Any]] + message: Optional[Dict[str, Any]] + # utilities ack: AsyncAck say: AsyncSay respond: AsyncRespond + # middleware next: Callable[[], Awaitable[None]] def __init__( @@ -34,6 +44,13 @@ def __init__( resp: BoltResponse, context: AsyncBoltContext, payload: Dict[str, Any], + options: Optional[Dict[str, Any]] = None, + shortcut: Optional[Dict[str, Any]] = None, + action: Optional[Dict[str, Any]] = None, + view: Optional[Dict[str, Any]] = None, + command: Optional[Dict[str, Any]] = None, + event: Optional[Dict[str, Any]] = None, + message: Optional[Dict[str, Any]] = None, ack: AsyncAck, say: AsyncSay, respond: AsyncRespond, @@ -45,8 +62,17 @@ def __init__( self.request = self.req = req self.response = self.resp = resp self.context: AsyncBoltContext = context + self.payload: Dict[str, Any] = payload self.body: Dict[str, Any] = payload + self.options: Optional[Dict[str, Any]] = options + self.shortcut: Optional[Dict[str, Any]] = shortcut + self.action: Optional[Dict[str, Any]] = action + self.view: Optional[Dict[str, Any]] = view + self.command: Optional[Dict[str, Any]] = command + self.event: Optional[Dict[str, Any]] = event + self.message: Optional[Dict[str, Any]] = message + self.ack: AsyncAck = ack self.say: AsyncSay = say self.respond: AsyncRespond = respond diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index 5e8298704..26e743d2d 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -5,6 +5,15 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from .async_args import AsyncArgs +from slack_bolt.util.payload_utils import ( + to_options, + to_shortcut, + to_action, + to_view, + to_command, + to_event, + to_message, +) def build_async_required_kwargs( @@ -23,11 +32,21 @@ def build_async_required_kwargs( "resp": response, "response": response, "context": request.context, + # payload "payload": request.payload, "body": request.payload, + "options": to_options(request.payload), + "shortcut": to_shortcut(request.payload), + "action": to_action(request.payload), + "view": to_view(request.payload), + "command": to_command(request.payload), + "event": to_event(request.payload), + "message": to_message(request.payload), + # utilities "ack": request.context.ack, "say": request.context.say, "respond": request.context.respond, + # middleware "next": next_func, } kwargs: Dict[str, Any] = { diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 40f754e0d..2074a6c20 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -5,6 +5,15 @@ from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from .args import Args +from slack_bolt.util.payload_utils import ( + to_options, + to_shortcut, + to_action, + to_view, + to_command, + to_event, + to_message, +) def build_required_kwargs( @@ -23,11 +32,21 @@ def build_required_kwargs( "resp": response, "response": response, "context": request.context, + # payload "payload": request.payload, "body": request.payload, + "options": to_options(request.payload), + "shortcut": to_shortcut(request.payload), + "action": to_action(request.payload), + "view": to_view(request.payload), + "command": to_command(request.payload), + "event": to_event(request.payload), + "message": to_message(request.payload), + # utilities "ack": request.context.ack, "say": request.context.say, "respond": request.context.respond, + # middleware "next": next_func, } kwargs: Dict[str, Any] = { diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 0fc0e8692..19a94d8f2 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -3,12 +3,29 @@ import sys from ..error import BoltError +from ..util.payload_utils import ( + is_block_actions, + is_global_shortcut, + is_message_shortcut, + is_attachment_action, + is_dialog_submission, + is_dialog_cancellation, + is_workflow_step_edit, + is_slash_command, + is_event, + is_view_submission, + is_view_closed, + is_block_suggestion, + is_dialog_suggestion, + is_shortcut, + to_action, +) if sys.version_info.major == 3 and sys.version_info.minor <= 6: from re import _pattern_type as Pattern else: from re import Pattern -from typing import Callable, Awaitable +from typing import Callable, Awaitable, Any from typing import Union, Optional, Dict from slack_bolt.kwargs_injection import build_required_kwargs @@ -42,7 +59,7 @@ def build_listener_matcher( if asyncio: from .async_builtins import AsyncBuiltinListenerMatcher - async def async_fun(payload: dict) -> bool: + async def async_fun(payload: Dict[str, Any]) -> bool: return func(payload) return AsyncBuiltinListenerMatcher(func=async_fun) @@ -64,17 +81,15 @@ def event( if isinstance(constraints, (str, Pattern)): event_type: Union[str, Pattern] = constraints - def func(payload: dict) -> bool: - return _is_valid_event_payload(payload) and _matches( - event_type, payload["event"]["type"] - ) + def func(payload: Dict[str, Any]) -> bool: + return is_event(payload) and _matches(event_type, payload["event"]["type"]) return build_listener_matcher(func, asyncio) elif "type" in constraints: - def func(payload: dict) -> bool: - if _is_valid_event_payload(payload): + def func(payload: Dict[str, Any]) -> bool: + if is_event(payload): event = payload["event"] if not _matches(constraints["type"], event["type"]): return False @@ -104,10 +119,8 @@ def func(payload: dict) -> bool: def command( command: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload and "command" in payload and _matches(command, payload["command"]) - ) + def func(payload: Dict[str, Any]) -> bool: + return is_slash_command(payload) and _matches(command, payload["command"]) return build_listener_matcher(func, asyncio) @@ -123,22 +136,9 @@ def shortcut( if isinstance(constraints, (str, Pattern)): callback_id: Union[str, Pattern] = constraints - def func(payload: dict) -> bool: - return ( - payload - and "callback_id" in payload - and ( - ( - # global shortcut - _is_expected_type(payload, "shortcut") - and _matches(callback_id, payload["callback_id"]) - ) - or ( - # message shortcut - _is_expected_type(payload, "message_action") - and _matches(callback_id, payload["callback_id"]) - ) - ) + def func(payload: Dict[str, Any]) -> bool: + return is_shortcut(payload) and _matches( + callback_id, payload["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -157,12 +157,9 @@ def func(payload: dict) -> bool: def global_shortcut( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "shortcut") - and "callback_id" in payload - and _matches(callback_id, payload["callback_id"]) + def func(payload: Dict[str, Any]) -> bool: + return is_global_shortcut(payload) and _matches( + callback_id, payload["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -171,12 +168,9 @@ def func(payload: dict) -> bool: def message_shortcut( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "message_action") - and "callback_id" in payload - and _matches(callback_id, payload["callback_id"]) + def func(payload: Dict[str, Any]) -> bool: + return is_message_shortcut(payload) and _matches( + callback_id, payload["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -218,12 +212,9 @@ def action( def block_action( action_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "block_actions") - and "actions" in payload - and _matches(action_id, payload["actions"][0]["action_id"]) + def func(payload: Dict[str, Any]) -> bool: + return is_block_actions(payload) and _matches( + action_id, to_action(payload)["action_id"] ) return build_listener_matcher(func, asyncio) @@ -232,12 +223,9 @@ def func(payload: dict) -> bool: def attachment_action( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "interactive_message") - and "actions" in payload - and _matches(callback_id, payload["callback_id"]) + def func(payload: Dict[str, Any]) -> bool: + return is_attachment_action(payload) and _matches( + callback_id, payload["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -246,11 +234,9 @@ def func(payload: dict) -> bool: def dialog_submission( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "dialog_submission") - and _matches(callback_id, payload["callback_id"]) + def func(payload: Dict[str, Any]) -> bool: + return is_dialog_submission(payload) and _matches( + callback_id, payload["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -259,11 +245,9 @@ def func(payload: dict) -> bool: def dialog_cancellation( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "dialog_cancellation") - and _matches(callback_id, payload["callback_id"]) + def func(payload: Dict[str, Any]) -> bool: + return is_dialog_cancellation(payload) and _matches( + callback_id, payload["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -272,11 +256,9 @@ def func(payload: dict) -> bool: def workflow_step_edit( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "workflow_step_edit") - and _matches(callback_id, payload["callback_id"]) + def func(payload: Dict[str, Any]) -> bool: + return is_workflow_step_edit(payload) and _matches( + callback_id, payload["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -306,13 +288,9 @@ def view( def view_submission( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "view_submission") - and "view" in payload - and "callback_id" in payload["view"] - and _matches(callback_id, payload["view"]["callback_id"]) + def func(payload: Dict[str, Any]) -> bool: + return is_view_submission(payload) and _matches( + callback_id, payload["view"]["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -321,13 +299,9 @@ def func(payload: dict) -> bool: def view_closed( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "view_closed") - and "view" in payload - and "callback_id" in payload["view"] - and _matches(callback_id, payload["view"]["callback_id"]) + def func(payload: Dict[str, Any]) -> bool: + return is_view_closed(payload) and _matches( + callback_id, payload["view"]["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -356,12 +330,9 @@ def options( def block_suggestion( action_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "block_suggestion") - and "action_id" in payload - and _matches(action_id, payload["action_id"]) + def func(payload: Dict[str, Any]) -> bool: + return is_block_suggestion(payload) and _matches( + action_id, payload["action_id"] ) return build_listener_matcher(func, asyncio) @@ -370,12 +341,9 @@ def func(payload: dict) -> bool: def dialog_suggestion( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "dialog_suggestion") - and "callback_id" in payload - and _matches(callback_id, payload["callback_id"]) + def func(payload: Dict[str, Any]) -> bool: + return is_dialog_suggestion(payload) and _matches( + callback_id, payload["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -384,19 +352,6 @@ def func(payload: dict) -> bool: # ------------------------- -def _is_valid_event_payload(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "event_callback") - and "event" in payload - and "type" in payload["event"] - ) - - -def _is_expected_type(payload: dict, expected: str) -> bool: - return payload and "type" in payload and payload["type"] == expected - - def _matches(str_or_pattern: Union[str, Pattern], input: Optional[str]) -> bool: if str_or_pattern is None or input is None: return False diff --git a/slack_bolt/util/payload_utils.py b/slack_bolt/util/payload_utils.py new file mode 100644 index 000000000..8bf557491 --- /dev/null +++ b/slack_bolt/util/payload_utils.py @@ -0,0 +1,210 @@ +from typing import Dict, Any, Optional + + +# ------------------------------------------ +# Public Utilities +# ------------------------------------------ + +# ------------------- +# Events API +# ------------------- + + +def to_event(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return payload["event"] if is_event(payload) else None + + +def to_message(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_event(payload) and payload["event"]["type"] == "message": + return to_event(payload) + return None + + +def is_event(payload: Dict[str, Any]) -> bool: + return ( + payload is not None + and _is_expected_type(payload, "event_callback") + and "event" in payload + and "type" in payload["event"] + ) + + +# ------------------- +# Slash Commands +# ------------------- + + +def to_command(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return payload if is_slash_command(payload) else None + + +def is_slash_command(payload: Dict[str, Any]) -> bool: + return payload is not None and "command" in payload + + +# ------------------- +# Actions +# ------------------- + + +def to_action(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_action(payload): + if is_block_actions(payload): + return payload["actions"][0] + else: + return payload + return None + + +def is_action(payload: Dict[str, Any]) -> bool: + return ( + is_attachment_action(payload) + or is_block_actions(payload) + or is_dialog_submission(payload) + or is_dialog_cancellation(payload) + or is_workflow_step_edit(payload) + ) + + +def is_attachment_action(payload: Dict[str, Any]) -> bool: + return ( + payload is not None + and _is_expected_type(payload, "interactive_message") + and "callback_id" in payload + ) + + +def is_block_actions(payload: Dict[str, Any]) -> bool: + return ( + payload is not None + and _is_expected_type(payload, "block_actions") + and "actions" in payload + ) + + +def is_dialog_submission(payload: Dict[str, Any]) -> bool: + return ( + payload is not None + and _is_expected_type(payload, "dialog_submission") + and "callback_id" in payload + ) + + +def is_dialog_cancellation(payload: Dict[str, Any]) -> bool: + return ( + payload is not None + and _is_expected_type(payload, "dialog_cancellation") + and "callback_id" in payload + ) + + +def is_workflow_step_edit(payload: Dict[str, Any]) -> bool: + return ( + payload is not None + and _is_expected_type(payload, "workflow_step_edit") + and "callback_id" in payload + ) + + +# ------------------- +# Options +# ------------------- + + +def to_options(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_options(payload): + return payload + return None + + +def is_options(payload: Dict[str, Any]) -> bool: + return is_block_suggestion(payload) or is_dialog_suggestion(payload) + + +def is_block_suggestion(payload: Dict[str, Any]) -> bool: + return ( + payload is not None + and _is_expected_type(payload, "block_suggestion") + and "action_id" in payload + ) + + +def is_dialog_suggestion(payload: Dict[str, Any]) -> bool: + return ( + payload is not None + and _is_expected_type(payload, "dialog_suggestion") + and "callback_id" in payload + ) + + +# ------------------- +# Shortcut +# ------------------- + + +def to_shortcut(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_shortcut(payload): + return payload + return None + + +def is_shortcut(payload: Dict[str, Any]) -> bool: + return is_global_shortcut(payload) or is_message_shortcut(payload) + + +def is_global_shortcut(payload: Dict[str, Any]) -> bool: + return ( + payload is not None + and _is_expected_type(payload, "shortcut") + and "callback_id" in payload + ) + + +def is_message_shortcut(payload: Dict[str, Any]) -> bool: + return ( + payload is not None + and _is_expected_type(payload, "message_action") + and "callback_id" in payload + ) + + +# ------------------- +# View +# ------------------- + + +def to_view(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_view(payload): + return payload["view"] + return None + + +def is_view(payload: Dict[str, Any]) -> bool: + return is_view_submission(payload) or is_view_closed(payload) + + +def is_view_submission(payload: Dict[str, Any]) -> bool: + return ( + payload is not None + and _is_expected_type(payload, "view_submission") + and "view" in payload + and "callback_id" in payload["view"] + ) + + +def is_view_closed(payload: Dict[str, Any]) -> bool: + return ( + payload is not None + and _is_expected_type(payload, "view_closed") + and "view" in payload + and "callback_id" in payload["view"] + ) + + +# ------------------------------------------ +# Internal Utilities +# ------------------------------------------ + + +def _is_expected_type(payload: dict, expected: str) -> bool: + return payload is not None and "type" in payload and payload["type"] == expected diff --git a/tests/async_scenario_tests/test_attachment_actions.py b/tests/async_scenario_tests/test_attachment_actions.py index bccb42dd7..b7080732f 100644 --- a/tests/async_scenario_tests/test_attachment_actions.py +++ b/tests/async_scenario_tests/test_attachment_actions.py @@ -191,6 +191,7 @@ async def test_failure_2(self): raw_body = f"payload={quote(json.dumps(payload))}" -async def simple_listener(ack, body): +async def simple_listener(ack, body, action): + assert body == action assert body["trigger_id"] == "111.222.valid" await ack() diff --git a/tests/async_scenario_tests/test_block_actions.py b/tests/async_scenario_tests/test_block_actions.py index 3fa579e7b..e661ad16b 100644 --- a/tests/async_scenario_tests/test_block_actions.py +++ b/tests/async_scenario_tests/test_block_actions.py @@ -173,6 +173,8 @@ async def test_failure_2(self): raw_body = f"payload={quote(json.dumps(payload))}" -async def simple_listener(ack, body): +async def simple_listener(ack, body, action): assert body["trigger_id"] == "111.222.valid" + assert body["actions"][0] == action + assert action["action_id"] == "a" await ack() diff --git a/tests/async_scenario_tests/test_block_suggestion.py b/tests/async_scenario_tests/test_block_suggestion.py index a998a382a..a7b890f75 100644 --- a/tests/async_scenario_tests/test_block_suggestion.py +++ b/tests/async_scenario_tests/test_block_suggestion.py @@ -278,9 +278,11 @@ async def test_failure_multi(self): expected_multi_response_body = json.dumps(multi_response) -async def show_options(ack): +async def show_options(ack, body, options): + assert body == options await ack(response) -async def show_multi_options(ack): +async def show_multi_options(ack, body, options): + assert body == options await ack(multi_response) diff --git a/tests/async_scenario_tests/test_dialogs.py b/tests/async_scenario_tests/test_dialogs.py index 3470be66a..934b43d7c 100644 --- a/tests/async_scenario_tests/test_dialogs.py +++ b/tests/async_scenario_tests/test_dialogs.py @@ -343,9 +343,11 @@ async def handle_submission(ack): } -async def handle_suggestion(ack): +async def handle_suggestion(ack, body, options): + assert body == options await ack(options_response) -async def handle_cancellation(ack): +async def handle_cancellation(ack, body, action): + assert body == action await ack() diff --git a/tests/async_scenario_tests/test_events.py b/tests/async_scenario_tests/test_events.py index aeee7e313..5bd6dfc70 100644 --- a/tests/async_scenario_tests/test_events.py +++ b/tests/async_scenario_tests/test_events.py @@ -136,15 +136,17 @@ async def test_simultaneous_requests(self): } -async def random_sleeper(payload, say): +async def random_sleeper(payload, say, event): assert payload == app_mention_payload + assert payload["event"] == event seconds = random() + 2 # 2-3 seconds await asyncio.sleep(seconds) await say(f"Sending this message after sleeping for {seconds} seconds") -async def whats_up(payload, say): +async def whats_up(payload, say, event): assert payload == app_mention_payload + assert payload["event"] == event await say("What's up?") diff --git a/tests/async_scenario_tests/test_shortcut.py b/tests/async_scenario_tests/test_shortcut.py index 9859729a0..2a07a3c8e 100644 --- a/tests/async_scenario_tests/test_shortcut.py +++ b/tests/async_scenario_tests/test_shortcut.py @@ -235,5 +235,6 @@ async def test_failure_2(self): message_shortcut_raw_body = f"payload={quote(json.dumps(message_shortcut_payload))}" -async def simple_listener(ack): +async def simple_listener(ack, body, shortcut): + assert body == shortcut await ack() diff --git a/tests/async_scenario_tests/test_slash_command.py b/tests/async_scenario_tests/test_slash_command.py index dcf0bd24b..cab5b2de3 100644 --- a/tests/async_scenario_tests/test_slash_command.py +++ b/tests/async_scenario_tests/test_slash_command.py @@ -110,5 +110,6 @@ async def test_failure(self): ) -async def commander(ack): +async def commander(ack, body, command): + assert body == command await ack() diff --git a/tests/async_scenario_tests/test_view_closed.py b/tests/async_scenario_tests/test_view_closed.py index 831712486..2d3e1a465 100644 --- a/tests/async_scenario_tests/test_view_closed.py +++ b/tests/async_scenario_tests/test_view_closed.py @@ -183,6 +183,7 @@ async def test_failure_2(self): raw_body = f"payload={quote(json.dumps(payload))}" -async def simple_listener(ack, body): - assert body["view"]["private_metadata"] == "This is for you!" +async def simple_listener(ack, body, view): + assert body["view"] == view + assert view["private_metadata"] == "This is for you!" await ack() diff --git a/tests/async_scenario_tests/test_view_submission.py b/tests/async_scenario_tests/test_view_submission.py index bcfbfe93d..bdfe65fd0 100644 --- a/tests/async_scenario_tests/test_view_submission.py +++ b/tests/async_scenario_tests/test_view_submission.py @@ -173,7 +173,8 @@ async def test_failure_2(self): raw_body = f"payload={quote(json.dumps(payload))}" -async def simple_listener(ack, body): +async def simple_listener(ack, body, view): assert body["trigger_id"] == "111.222.valid" - assert body["view"]["private_metadata"] == "This is for you!" + assert body["view"] == view + assert view["private_metadata"] == "This is for you!" await ack() diff --git a/tests/scenario_tests/test_attachment_actions.py b/tests/scenario_tests/test_attachment_actions.py index 17557fafa..e99e58600 100644 --- a/tests/scenario_tests/test_attachment_actions.py +++ b/tests/scenario_tests/test_attachment_actions.py @@ -165,6 +165,7 @@ def test_failure_2(self): raw_body = f"payload={quote(json.dumps(payload))}" -def simple_listener(ack, body): +def simple_listener(ack, body, action): + assert body == action assert body["trigger_id"] == "111.222.valid" ack() diff --git a/tests/scenario_tests/test_block_actions.py b/tests/scenario_tests/test_block_actions.py index 42c878e49..c4da77689 100644 --- a/tests/scenario_tests/test_block_actions.py +++ b/tests/scenario_tests/test_block_actions.py @@ -147,6 +147,8 @@ def test_failure_2(self): raw_body = f"payload={quote(json.dumps(payload))}" -def simple_listener(ack, body): +def simple_listener(ack, body, action): assert body["trigger_id"] == "111.222.valid" + assert body["actions"][0] == action + assert action["action_id"] == "a" ack() diff --git a/tests/scenario_tests/test_block_suggestion.py b/tests/scenario_tests/test_block_suggestion.py index 26409b8eb..25bb5d9fa 100644 --- a/tests/scenario_tests/test_block_suggestion.py +++ b/tests/scenario_tests/test_block_suggestion.py @@ -263,9 +263,11 @@ def test_failure_multi(self): expected_multi_response_body = json.dumps(multi_response) -def show_options(ack): +def show_options(ack, body, options): + assert body == options ack(response) -def show_multi_options(ack): +def show_multi_options(ack, body, options): + assert body == options ack(multi_response) diff --git a/tests/scenario_tests/test_dialogs.py b/tests/scenario_tests/test_dialogs.py index f2b3e47eb..265ad8868 100644 --- a/tests/scenario_tests/test_dialogs.py +++ b/tests/scenario_tests/test_dialogs.py @@ -326,9 +326,11 @@ def handle_submission(ack): } -def handle_suggestion(ack): +def handle_suggestion(ack, body, options): + assert body == options ack(options_response) -def handle_cancellation(ack): +def handle_cancellation(ack, body, action): + assert body == action ack() diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index c926d51c0..92725fbbb 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -68,8 +68,9 @@ def test_middleware(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @app.event("app_mention") - def handle_app_mention(payload, say): + def handle_app_mention(payload, say, event): assert payload == self.valid_event_payload + assert payload["event"] == event say("What's up?") timestamp, body = str(int(time())), json.dumps(self.valid_event_payload) @@ -90,7 +91,8 @@ def skip_middleware(req, resp, next): pass @app.event("app_mention", middleware=[skip_middleware]) - def handle_app_mention(payload, logger): + def handle_app_mention(payload, logger, event): + assert payload["event"] == event logger.info(payload) timestamp, body = str(int(time())), json.dumps(self.valid_event_payload) diff --git a/tests/scenario_tests/test_message.py b/tests/scenario_tests/test_message.py index 463c2621b..b2ecdc1ac 100644 --- a/tests/scenario_tests/test_message.py +++ b/tests/scenario_tests/test_message.py @@ -177,7 +177,8 @@ def whats_up(payload, say): } -def verify_matches(context, say): +def verify_matches(context, say, body, message): assert context["matches"] == ("103", "you") assert context.matches == ("103", "you") + assert body["event"] == message say("Thanks!") diff --git a/tests/scenario_tests/test_shortcut.py b/tests/scenario_tests/test_shortcut.py index 3ff2198c4..19e740785 100644 --- a/tests/scenario_tests/test_shortcut.py +++ b/tests/scenario_tests/test_shortcut.py @@ -220,5 +220,6 @@ def test_failure_2(self): message_shortcut_raw_body = f"payload={quote(json.dumps(message_shortcut_payload))}" -def simple_listener(ack): +def simple_listener(ack, body, shortcut): + assert body == shortcut ack() diff --git a/tests/scenario_tests/test_slash_command.py b/tests/scenario_tests/test_slash_command.py index 4e732a348..7b0fa737e 100644 --- a/tests/scenario_tests/test_slash_command.py +++ b/tests/scenario_tests/test_slash_command.py @@ -100,5 +100,6 @@ def test_failure(self): ) -def commander(ack): +def commander(ack, body, command): + assert body == command ack() diff --git a/tests/scenario_tests/test_view_closed.py b/tests/scenario_tests/test_view_closed.py index b2ab0d5ab..df1a57d94 100644 --- a/tests/scenario_tests/test_view_closed.py +++ b/tests/scenario_tests/test_view_closed.py @@ -170,6 +170,7 @@ def test_failure_2(self): raw_body = f"payload={quote(json.dumps(payload))}" -def simple_listener(ack, body): - assert body["view"]["private_metadata"] == "This is for you!" +def simple_listener(ack, body, view): + assert body["view"] == view + assert view["private_metadata"] == "This is for you!" ack() diff --git a/tests/scenario_tests/test_view_submission.py b/tests/scenario_tests/test_view_submission.py index 825c06781..683866a62 100644 --- a/tests/scenario_tests/test_view_submission.py +++ b/tests/scenario_tests/test_view_submission.py @@ -161,7 +161,8 @@ def test_failure_2(self): raw_body = f"payload={quote(json.dumps(payload))}" -def simple_listener(ack, body): +def simple_listener(ack, body, view): assert body["trigger_id"] == "111.222.valid" - assert body["view"]["private_metadata"] == "This is for you!" + assert body["view"] == view + assert view["private_metadata"] == "This is for you!" ack() From 034be74338edde6ba0017bc8e4c503f03bef12dc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 4 Sep 2020 06:53:52 +0900 Subject: [PATCH 029/865] version 0.3.2a0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index d72438438..09e7285bc 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.3.1a0" +__version__ = "0.3.2a0" From 6a2c5c2156ba9b1ee8162060a057b1aa76560f48 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 4 Sep 2020 21:30:23 +0900 Subject: [PATCH 030/865] Fix #61 by correcting payload in middleware/listener args --- slack_bolt/app/app.py | 2 +- slack_bolt/app/async_app.py | 2 +- slack_bolt/kwargs_injection/args.py | 4 +- slack_bolt/kwargs_injection/async_args.py | 4 +- slack_bolt/kwargs_injection/async_utils.py | 28 ++- slack_bolt/kwargs_injection/utils.py | 28 ++- .../listener/async_listener_error_handler.py | 34 +++- slack_bolt/listener/listener_error_handler.py | 34 +++- slack_bolt/listener_matcher/builtins.py | 92 ++++----- .../authorization/async_internals.py | 8 +- .../middleware/authorization/internals.py | 8 +- .../async_ignoring_self_events.py | 2 +- .../ignoring_self_events.py | 6 +- .../async_message_listener_matches.py | 2 +- .../message_listener_matches.py | 2 +- .../async_request_verification.py | 4 +- .../request_verification.py | 8 +- .../middleware/ssl_check/async_ssl_check.py | 4 +- slack_bolt/middleware/ssl_check/ssl_check.py | 12 +- .../async_url_verification.py | 4 +- .../url_verification/url_verification.py | 12 +- slack_bolt/request/async_request.py | 12 +- slack_bolt/request/internals.py | 2 +- slack_bolt/request/request.py | 14 +- slack_bolt/util/payload_utils.py | 176 +++++++++--------- tests/adapter_tests/test_async_fastapi.py | 12 +- tests/adapter_tests/test_async_starlette.py | 12 +- tests/adapter_tests/test_aws_lambda.py | 12 +- tests/adapter_tests/test_cherrypy.py | 12 +- tests/adapter_tests/test_fastapi.py | 12 +- tests/adapter_tests/test_flask.py | 12 +- tests/adapter_tests/test_starlette.py | 12 +- .../test_attachment_actions.py | 9 +- .../test_block_actions.py | 9 +- .../test_block_suggestion.py | 18 +- tests/async_scenario_tests/test_dialogs.py | 18 +- .../test_error_handler.py | 4 +- tests/async_scenario_tests/test_events.py | 18 +- tests/async_scenario_tests/test_lazy.py | 4 +- tests/async_scenario_tests/test_message.py | 12 +- tests/async_scenario_tests/test_middleware.py | 4 +- tests/async_scenario_tests/test_shortcut.py | 11 +- .../test_slash_command.py | 7 +- .../async_scenario_tests/test_view_closed.py | 7 +- .../test_view_submission.py | 9 +- .../scenario_tests/test_attachment_actions.py | 9 +- tests/scenario_tests/test_block_actions.py | 9 +- tests/scenario_tests/test_block_suggestion.py | 18 +- tests/scenario_tests/test_dialogs.py | 18 +- tests/scenario_tests/test_error_handler.py | 4 +- tests/scenario_tests/test_events.py | 18 +- tests/scenario_tests/test_lazy.py | 4 +- tests/scenario_tests/test_message.py | 16 +- tests/scenario_tests/test_middleware.py | 4 +- tests/scenario_tests/test_shortcut.py | 11 +- tests/scenario_tests/test_slash_command.py | 7 +- tests/scenario_tests/test_view_closed.py | 9 +- tests/scenario_tests/test_view_submission.py | 9 +- 58 files changed, 474 insertions(+), 380 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index e87d0377f..574a30d96 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -310,7 +310,7 @@ def middleware_next(): if listener_response is not None: return listener_response - self._framework_logger.warning(f"Unhandled request ({req.payload})") + self._framework_logger.warning(f"Unhandled request ({req.body})") return BoltResponse(status=404, body={"error": "unhandled request"}) def run_listener( diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 947392b2c..c8a1662a4 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -333,7 +333,7 @@ async def async_middleware_next(): if listener_response is not None: return listener_response - self._framework_logger.warning(f"Unhandled request ({req.payload})") + self._framework_logger.warning(f"Unhandled request ({req.body})") return BoltResponse(status=404, body={"error": "unhandled request"}) async def run_async_listener( diff --git a/slack_bolt/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index 88c961ab1..458235265 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -20,6 +20,7 @@ class Args: request: BoltRequest response: BoltResponse context: BoltContext + body: Dict[str, Any] # payload payload: Dict[str, Any] options: Optional[Dict[str, Any]] @@ -44,6 +45,7 @@ def __init__( req: BoltRequest, resp: BoltResponse, context: BoltContext, + body: Dict[str, Any], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None, shortcut: Optional[Dict[str, Any]] = None, @@ -64,8 +66,8 @@ def __init__( self.response = self.resp = resp self.context: BoltContext = context + self.body: Dict[str, Any] = body self.payload: Dict[str, Any] = payload - self.body: Dict[str, Any] = payload self.options: Optional[Dict[str, Any]] = options self.shortcut: Optional[Dict[str, Any]] = shortcut self.action: Optional[Dict[str, Any]] = action diff --git a/slack_bolt/kwargs_injection/async_args.py b/slack_bolt/kwargs_injection/async_args.py index c4abe4724..dd9d2db4d 100644 --- a/slack_bolt/kwargs_injection/async_args.py +++ b/slack_bolt/kwargs_injection/async_args.py @@ -19,6 +19,7 @@ class AsyncArgs: request: AsyncBoltRequest response: BoltResponse context: AsyncBoltContext + body: Dict[str, Any] # payload payload: Dict[str, Any] options: Optional[Dict[str, Any]] @@ -43,6 +44,7 @@ def __init__( req: AsyncBoltRequest, resp: BoltResponse, context: AsyncBoltContext, + body: Dict[str, Any], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None, shortcut: Optional[Dict[str, Any]] = None, @@ -63,8 +65,8 @@ def __init__( self.response = self.resp = resp self.context: AsyncBoltContext = context + self.body: Dict[str, Any] = body self.payload: Dict[str, Any] = payload - self.body: Dict[str, Any] = payload self.options: Optional[Dict[str, Any]] = options self.shortcut: Optional[Dict[str, Any]] = shortcut self.action: Optional[Dict[str, Any]] = action diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index 26e743d2d..08ff1d061 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -32,16 +32,15 @@ def build_async_required_kwargs( "resp": response, "response": response, "context": request.context, + "body": request.body, # payload - "payload": request.payload, - "body": request.payload, - "options": to_options(request.payload), - "shortcut": to_shortcut(request.payload), - "action": to_action(request.payload), - "view": to_view(request.payload), - "command": to_command(request.payload), - "event": to_event(request.payload), - "message": to_message(request.payload), + "options": to_options(request.body), + "shortcut": to_shortcut(request.body), + "action": to_action(request.body), + "view": to_view(request.body), + "command": to_command(request.body), + "event": to_event(request.body), + "message": to_message(request.body), # utilities "ack": request.context.ack, "say": request.context.say, @@ -49,6 +48,17 @@ def build_async_required_kwargs( # middleware "next": next_func, } + all_available_args["payload"] = ( + all_available_args["options"] + or all_available_args["shortcut"] + or all_available_args["action"] + or all_available_args["view"] + or all_available_args["command"] + or all_available_args["event"] + or all_available_args["message"] + or request.body + ) + 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 2074a6c20..ee6661a0c 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -33,15 +33,14 @@ def build_required_kwargs( "response": response, "context": request.context, # payload - "payload": request.payload, - "body": request.payload, - "options": to_options(request.payload), - "shortcut": to_shortcut(request.payload), - "action": to_action(request.payload), - "view": to_view(request.payload), - "command": to_command(request.payload), - "event": to_event(request.payload), - "message": to_message(request.payload), + "body": request.body, + "options": to_options(request.body), + "shortcut": to_shortcut(request.body), + "action": to_action(request.body), + "view": to_view(request.body), + "command": to_command(request.body), + "event": to_event(request.body), + "message": to_message(request.body), # utilities "ack": request.context.ack, "say": request.context.say, @@ -49,6 +48,17 @@ def build_required_kwargs( # middleware "next": next_func, } + all_available_args["payload"] = ( + all_available_args["options"] + or all_available_args["shortcut"] + or all_available_args["action"] + or all_available_args["view"] + or all_available_args["command"] + or all_available_args["event"] + or all_available_args["message"] + or request.body + ) + 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/listener/async_listener_error_handler.py b/slack_bolt/listener/async_listener_error_handler.py index 7416e2a96..dea19ca36 100644 --- a/slack_bolt/listener/async_listener_error_handler.py +++ b/slack_bolt/listener/async_listener_error_handler.py @@ -6,6 +6,16 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse +from slack_bolt.util.payload_utils import ( + to_options, + to_shortcut, + to_action, + to_view, + to_command, + to_event, + to_message, +) + class AsyncListenerErrorHandler(metaclass=ABCMeta): @abstractmethod @@ -39,11 +49,31 @@ async def handle( "resp": response, "response": response, "context": request.context, - "payload": request.payload, - "body": request.payload, + "body": request.body, + # payload + "body": request.body, + "options": to_options(request.body), + "shortcut": to_shortcut(request.body), + "action": to_action(request.body), + "view": to_view(request.body), + "command": to_command(request.body), + "event": to_event(request.body), + "message": to_message(request.body), + # utilities "say": request.context.say, "respond": request.context.respond, } + all_available_args["payload"] = ( + all_available_args["options"] + or all_available_args["shortcut"] + or all_available_args["action"] + or all_available_args["view"] + or all_available_args["command"] + or all_available_args["event"] + or all_available_args["message"] + or request.body + ) + kwargs: Dict[str, Any] = { # type: ignore k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore } diff --git a/slack_bolt/listener/listener_error_handler.py b/slack_bolt/listener/listener_error_handler.py index f283bd4c4..5232d6c3f 100644 --- a/slack_bolt/listener/listener_error_handler.py +++ b/slack_bolt/listener/listener_error_handler.py @@ -6,6 +6,16 @@ from slack_bolt.request.request import BoltRequest from slack_bolt.response.response import BoltResponse +from slack_bolt.util.payload_utils import ( + to_options, + to_shortcut, + to_action, + to_view, + to_command, + to_event, + to_message, +) + class ListenerErrorHandler(metaclass=ABCMeta): @abstractmethod @@ -33,11 +43,31 @@ def handle( "resp": response, "response": response, "context": request.context, - "payload": request.payload, - "body": request.payload, + "body": request.body, + # payload + "body": request.body, + "options": to_options(request.body), + "shortcut": to_shortcut(request.body), + "action": to_action(request.body), + "view": to_view(request.body), + "command": to_command(request.body), + "event": to_event(request.body), + "message": to_message(request.body), + # utilities "say": request.context.say, "respond": request.context.respond, } + all_available_args["payload"] = ( + all_available_args["options"] + or all_available_args["shortcut"] + or all_available_args["action"] + or all_available_args["view"] + or all_available_args["command"] + or all_available_args["event"] + or all_available_args["message"] + or request.body + ) + kwargs: Dict[str, Any] = { # type: ignore k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore } diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 19a94d8f2..297155163 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -59,8 +59,8 @@ def build_listener_matcher( if asyncio: from .async_builtins import AsyncBuiltinListenerMatcher - async def async_fun(payload: Dict[str, Any]) -> bool: - return func(payload) + async def async_fun(body: Dict[str, Any]) -> bool: + return func(body) return AsyncBuiltinListenerMatcher(func=async_fun) else: @@ -75,22 +75,22 @@ def event( constraints: Union[str, Pattern, Dict[str, str]], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if constraints == "message": - # matches message events that don't have subtype in payload + # matches message events that don't have subtype in body constraints = {"type": "message", "subtype": None} if isinstance(constraints, (str, Pattern)): event_type: Union[str, Pattern] = constraints - def func(payload: Dict[str, Any]) -> bool: - return is_event(payload) and _matches(event_type, payload["event"]["type"]) + def func(body: Dict[str, Any]) -> bool: + return is_event(body) and _matches(event_type, body["event"]["type"]) return build_listener_matcher(func, asyncio) elif "type" in constraints: - def func(payload: Dict[str, Any]) -> bool: - if is_event(payload): - event = payload["event"] + def func(body: Dict[str, Any]) -> bool: + if is_event(body): + event = body["event"] if not _matches(constraints["type"], event["type"]): return False if "subtype" in constraints: @@ -119,8 +119,8 @@ def func(payload: Dict[str, Any]) -> bool: def command( command: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: Dict[str, Any]) -> bool: - return is_slash_command(payload) and _matches(command, payload["command"]) + def func(body: Dict[str, Any]) -> bool: + return is_slash_command(body) and _matches(command, body["command"]) return build_listener_matcher(func, asyncio) @@ -136,10 +136,8 @@ def shortcut( if isinstance(constraints, (str, Pattern)): callback_id: Union[str, Pattern] = constraints - def func(payload: Dict[str, Any]) -> bool: - return is_shortcut(payload) and _matches( - callback_id, payload["callback_id"] - ) + def func(body: Dict[str, Any]) -> bool: + return is_shortcut(body) and _matches(callback_id, body["callback_id"]) return build_listener_matcher(func, asyncio) @@ -157,10 +155,8 @@ def func(payload: Dict[str, Any]) -> bool: def global_shortcut( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: Dict[str, Any]) -> bool: - return is_global_shortcut(payload) and _matches( - callback_id, payload["callback_id"] - ) + def func(body: Dict[str, Any]) -> bool: + return is_global_shortcut(body) and _matches(callback_id, body["callback_id"]) return build_listener_matcher(func, asyncio) @@ -168,10 +164,8 @@ def func(payload: Dict[str, Any]) -> bool: def message_shortcut( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: Dict[str, Any]) -> bool: - return is_message_shortcut(payload) and _matches( - callback_id, payload["callback_id"] - ) + def func(body: Dict[str, Any]) -> bool: + return is_message_shortcut(body) and _matches(callback_id, body["callback_id"]) return build_listener_matcher(func, asyncio) @@ -212,9 +206,9 @@ def action( def block_action( action_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: Dict[str, Any]) -> bool: - return is_block_actions(payload) and _matches( - action_id, to_action(payload)["action_id"] + def func(body: Dict[str, Any]) -> bool: + return is_block_actions(body) and _matches( + action_id, to_action(body)["action_id"] ) return build_listener_matcher(func, asyncio) @@ -223,10 +217,8 @@ def func(payload: Dict[str, Any]) -> bool: def attachment_action( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: Dict[str, Any]) -> bool: - return is_attachment_action(payload) and _matches( - callback_id, payload["callback_id"] - ) + def func(body: Dict[str, Any]) -> bool: + return is_attachment_action(body) and _matches(callback_id, body["callback_id"]) return build_listener_matcher(func, asyncio) @@ -234,10 +226,8 @@ def func(payload: Dict[str, Any]) -> bool: def dialog_submission( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: Dict[str, Any]) -> bool: - return is_dialog_submission(payload) and _matches( - callback_id, payload["callback_id"] - ) + def func(body: Dict[str, Any]) -> bool: + return is_dialog_submission(body) and _matches(callback_id, body["callback_id"]) return build_listener_matcher(func, asyncio) @@ -245,9 +235,9 @@ def func(payload: Dict[str, Any]) -> bool: def dialog_cancellation( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: Dict[str, Any]) -> bool: - return is_dialog_cancellation(payload) and _matches( - callback_id, payload["callback_id"] + def func(body: Dict[str, Any]) -> bool: + return is_dialog_cancellation(body) and _matches( + callback_id, body["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -256,9 +246,9 @@ def func(payload: Dict[str, Any]) -> bool: def workflow_step_edit( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: Dict[str, Any]) -> bool: - return is_workflow_step_edit(payload) and _matches( - callback_id, payload["callback_id"] + def func(body: Dict[str, Any]) -> bool: + return is_workflow_step_edit(body) and _matches( + callback_id, body["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -288,9 +278,9 @@ def view( def view_submission( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: Dict[str, Any]) -> bool: - return is_view_submission(payload) and _matches( - callback_id, payload["view"]["callback_id"] + def func(body: Dict[str, Any]) -> bool: + return is_view_submission(body) and _matches( + callback_id, body["view"]["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -299,9 +289,9 @@ def func(payload: Dict[str, Any]) -> bool: def view_closed( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: Dict[str, Any]) -> bool: - return is_view_closed(payload) and _matches( - callback_id, payload["view"]["callback_id"] + def func(body: Dict[str, Any]) -> bool: + return is_view_closed(body) and _matches( + callback_id, body["view"]["callback_id"] ) return build_listener_matcher(func, asyncio) @@ -330,10 +320,8 @@ def options( def block_suggestion( action_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: Dict[str, Any]) -> bool: - return is_block_suggestion(payload) and _matches( - action_id, payload["action_id"] - ) + def func(body: Dict[str, Any]) -> bool: + return is_block_suggestion(body) and _matches(action_id, body["action_id"]) return build_listener_matcher(func, asyncio) @@ -341,10 +329,8 @@ def func(payload: Dict[str, Any]) -> bool: def dialog_suggestion( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: Dict[str, Any]) -> bool: - return is_dialog_suggestion(payload) and _matches( - callback_id, payload["callback_id"] - ) + def func(body: Dict[str, Any]) -> bool: + return is_dialog_suggestion(body) and _matches(callback_id, body["callback_id"]) return build_listener_matcher(func, asyncio) diff --git a/slack_bolt/middleware/authorization/async_internals.py b/slack_bolt/middleware/authorization/async_internals.py index 9d5cc394a..376bc62b4 100644 --- a/slack_bolt/middleware/authorization/async_internals.py +++ b/slack_bolt/middleware/authorization/async_internals.py @@ -5,16 +5,16 @@ def _is_url_verification(req: AsyncBoltRequest) -> bool: return ( req is not None - and req.payload is not None - and req.payload.get("type", None) == "url_verification" + and req.body is not None + and req.body.get("type", None) == "url_verification" ) def _is_ssl_check(req: AsyncBoltRequest) -> bool: return ( req is not None - and req.payload is not None - and req.payload.get("type", None) == "ssl_check" + and req.body is not None + and req.body.get("type", None) == "ssl_check" ) diff --git a/slack_bolt/middleware/authorization/internals.py b/slack_bolt/middleware/authorization/internals.py index 06ea7e9a8..466166857 100644 --- a/slack_bolt/middleware/authorization/internals.py +++ b/slack_bolt/middleware/authorization/internals.py @@ -5,16 +5,16 @@ def _is_url_verification(req: BoltRequest) -> bool: return ( req is not None - and req.payload is not None - and req.payload.get("type", None) == "url_verification" + and req.body is not None + and req.body.get("type", None) == "url_verification" ) def _is_ssl_check(req: BoltRequest) -> bool: return ( req is not None - and req.payload is not None - and req.payload.get("type", None) == "ssl_check" + and req.body is not None + and req.body.get("type", None) == "ssl_check" ) diff --git a/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py index 2e68cfcab..c3f9ff3bd 100644 --- a/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py @@ -16,7 +16,7 @@ async def async_process( ) -> BoltResponse: auth_result = req.context.authorization_result if self._is_self_event(auth_result, req.context.user_id): - self._debug_log(req.payload) + self._debug_log(req.body) return await req.context.ack() else: return await next() diff --git a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py index d48180005..8f14347c4 100644 --- a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -17,7 +17,7 @@ def process( ) -> BoltResponse: auth_result = req.context.authorization_result if self._is_self_event(auth_result, req.context.user_id): - self._debug_log(req.payload) + self._debug_log(req.body) return req.context.ack() else: return next() @@ -28,7 +28,7 @@ def process( def _is_self_event(auth_result: AuthorizationResult, user_id: str): return auth_result is not None and user_id == auth_result.bot_user_id - def _debug_log(self, payload: dict): + def _debug_log(self, body: dict): if self.logger.level <= logging.DEBUG: - event = payload["event"] + event = body["event"] self.logger.debug(f"Skipped self event: {event}") diff --git a/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py b/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py index b54ef5b33..cfb911a92 100644 --- a/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py +++ b/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py @@ -17,7 +17,7 @@ async def async_process( resp: BoltResponse, next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: - text = req.payload.get("event", {}).get("text", "") + text = req.body.get("event", {}).get("text", "") if text: m = re.search(self.keyword, text) if m is not None: diff --git a/slack_bolt/middleware/message_listener_matches/message_listener_matches.py b/slack_bolt/middleware/message_listener_matches/message_listener_matches.py index 7210344ea..3ccfe461d 100644 --- a/slack_bolt/middleware/message_listener_matches/message_listener_matches.py +++ b/slack_bolt/middleware/message_listener_matches/message_listener_matches.py @@ -13,7 +13,7 @@ def __init__(self, keyword: Union[str, Pattern]): def process( self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], ) -> BoltResponse: - text = req.payload.get("event", {}).get("text", "") + text = req.body.get("event", {}).get("text", "") if text: m = re.search(self.keyword, text) if m is not None: diff --git a/slack_bolt/middleware/request_verification/async_request_verification.py b/slack_bolt/middleware/request_verification/async_request_verification.py index f1a7556c2..c62041b89 100644 --- a/slack_bolt/middleware/request_verification/async_request_verification.py +++ b/slack_bolt/middleware/request_verification/async_request_verification.py @@ -14,10 +14,10 @@ async def async_process( resp: BoltResponse, next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: - if self._can_skip(req.payload): + if self._can_skip(req.body): return await next() - body = req.body + body = req.raw_body timestamp = req.headers.get("x-slack-request-timestamp", ["0"])[0] signature = req.headers.get("x-slack-signature", [""])[0] if self.verifier.is_valid(body, timestamp, signature): diff --git a/slack_bolt/middleware/request_verification/request_verification.py b/slack_bolt/middleware/request_verification/request_verification.py index 1dab84f57..772426f6d 100644 --- a/slack_bolt/middleware/request_verification/request_verification.py +++ b/slack_bolt/middleware/request_verification/request_verification.py @@ -16,10 +16,10 @@ def __init__(self, signing_secret: str): def process( self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], ) -> BoltResponse: - if self._can_skip(req.payload): + if self._can_skip(req.body): return next() - body = req.body + body = req.raw_body timestamp = req.headers.get("x-slack-request-timestamp", ["0"])[0] signature = req.headers.get("x-slack-signature", [""])[0] if self.verifier.is_valid(body, timestamp, signature): @@ -31,8 +31,8 @@ def process( # ----------------------------------------- @staticmethod - def _can_skip(payload: Dict[str, Any]) -> bool: - return payload is not None and payload.get("ssl_check", None) == "1" + def _can_skip(body: Dict[str, Any]) -> bool: + return body is not None and body.get("ssl_check", None) == "1" @staticmethod def _build_error_response() -> BoltResponse: diff --git a/slack_bolt/middleware/ssl_check/async_ssl_check.py b/slack_bolt/middleware/ssl_check/async_ssl_check.py index 3423d40a0..a8a62c5e3 100644 --- a/slack_bolt/middleware/ssl_check/async_ssl_check.py +++ b/slack_bolt/middleware/ssl_check/async_ssl_check.py @@ -14,8 +14,8 @@ async def async_process( resp: BoltResponse, next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: - if self._is_ssl_check_request(req.payload): - if self._verify_token_if_needed(req.payload): + if self._is_ssl_check_request(req.body): + if self._verify_token_if_needed(req.body): return self._build_error_response() return self._build_success_response() else: diff --git a/slack_bolt/middleware/ssl_check/ssl_check.py b/slack_bolt/middleware/ssl_check/ssl_check.py index 8d4c82e3d..f2be29614 100644 --- a/slack_bolt/middleware/ssl_check/ssl_check.py +++ b/slack_bolt/middleware/ssl_check/ssl_check.py @@ -14,8 +14,8 @@ def __init__(self, verification_token: str = None): def process( self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], ) -> BoltResponse: - if self._is_ssl_check_request(req.payload): - if self._verify_token_if_needed(req.payload): + if self._is_ssl_check_request(req.body): + if self._verify_token_if_needed(req.body): return self._build_error_response() return self._build_success_response() else: @@ -24,11 +24,11 @@ def process( # ----------------------------------------- @staticmethod - def _is_ssl_check_request(payload: dict): - return "ssl_check" in payload and payload["ssl_check"] == "1" + def _is_ssl_check_request(body: dict): + return "ssl_check" in body and body["ssl_check"] == "1" - def _verify_token_if_needed(self, payload: dict): - return self.verification_token and self.verification_token == payload["token"] + def _verify_token_if_needed(self, body: dict): + return self.verification_token and self.verification_token == body["token"] @staticmethod def _build_success_response() -> BoltResponse: diff --git a/slack_bolt/middleware/url_verification/async_url_verification.py b/slack_bolt/middleware/url_verification/async_url_verification.py index 3ea27334e..91b4f2b63 100644 --- a/slack_bolt/middleware/url_verification/async_url_verification.py +++ b/slack_bolt/middleware/url_verification/async_url_verification.py @@ -18,7 +18,7 @@ async def async_process( resp: BoltResponse, next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: - if self._is_url_verification_request(req.payload): - return self._build_success_response(req.payload) + if self._is_url_verification_request(req.body): + return self._build_success_response(req.body) else: return await next() diff --git a/slack_bolt/middleware/url_verification/url_verification.py b/slack_bolt/middleware/url_verification/url_verification.py index 383d7b3bb..a06ac62dd 100644 --- a/slack_bolt/middleware/url_verification/url_verification.py +++ b/slack_bolt/middleware/url_verification/url_verification.py @@ -13,17 +13,17 @@ def __init__(self): def process( self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], ) -> BoltResponse: - if self._is_url_verification_request(req.payload): - return self._build_success_response(req.payload) + if self._is_url_verification_request(req.body): + return self._build_success_response(req.body) else: return next() # ----------------------------------------- @staticmethod - def _is_url_verification_request(payload: dict) -> bool: - return payload is not None and payload.get("type", None) == "url_verification" + def _is_url_verification_request(body: dict) -> bool: + return body is not None and body.get("type", None) == "url_verification" @staticmethod - def _build_success_response(payload: dict) -> BoltResponse: - return BoltResponse(status=200, body={"challenge": payload.get("challenge")}) + def _build_success_response(body: dict) -> BoltResponse: + return BoltResponse(status=200, body={"challenge": body.get("challenge")}) diff --git a/slack_bolt/request/async_request.py b/slack_bolt/request/async_request.py index d6fe634a1..7e14d7d38 100644 --- a/slack_bolt/request/async_request.py +++ b/slack_bolt/request/async_request.py @@ -4,18 +4,18 @@ from slack_bolt.request.async_internals import build_async_context from slack_bolt.request.internals import ( parse_query, - parse_payload, + parse_body, build_normalized_headers, extract_content_type, ) class AsyncBoltRequest: - body: str + raw_body: str + body: Dict[str, Any] query: Dict[str, List[str]] headers: Dict[str, List[str]] content_type: Optional[str] - payload: Dict[str, Any] context: AsyncBoltContext lazy_only: bool lazy_function_name: Optional[str] @@ -29,13 +29,13 @@ def __init__( headers: Optional[Dict[str, Union[str, List[str]]]] = None, context: Optional[Dict[str, str]] = None, ): - self.body = body + self.raw_body = body self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) - self.payload = parse_payload(self.body, self.content_type) + self.body = parse_body(self.raw_body, self.content_type) self.context = build_async_context( - AsyncBoltContext(context if context else {}), self.payload + AsyncBoltContext(context if context else {}), self.body ) self.lazy_only = self.headers.get("x-slack-bolt-lazy-only", [False])[0] self.lazy_function_name = self.headers.get( diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 905b8d691..e157cb7a3 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -28,7 +28,7 @@ def parse_query( raise ValueError(f"Unsupported type of query detected ({type(query)})") -def parse_payload(body: str, content_type: Optional[str]) -> Dict[str, Any]: +def parse_body(body: str, content_type: Optional[str]) -> Dict[str, Any]: if not body: return {} if content_type == "application/json" or body.startswith("{"): diff --git a/slack_bolt/request/request.py b/slack_bolt/request/request.py index ea4520024..fa960371f 100644 --- a/slack_bolt/request/request.py +++ b/slack_bolt/request/request.py @@ -3,7 +3,7 @@ from slack_bolt.context.context import BoltContext from slack_bolt.request.internals import ( parse_query, - parse_payload, + parse_body, build_normalized_headers, build_context, extract_content_type, @@ -11,11 +11,11 @@ class BoltRequest: - body: str + raw_body: str query: Dict[str, List[str]] headers: Dict[str, List[str]] content_type: Optional[str] - payload: Dict[str, Any] + body: Dict[str, Any] context: BoltContext lazy_only: bool lazy_function_name: Optional[str] @@ -29,14 +29,12 @@ def __init__( headers: Optional[Dict[str, Union[str, List[str]]]] = None, context: Optional[Dict[str, str]] = None, ): - self.body = body + self.raw_body = body self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) - self.payload = parse_payload(self.body, self.content_type) - self.context = build_context( - BoltContext(context if context else {}), self.payload - ) + self.body = parse_body(self.raw_body, self.content_type) + self.context = build_context(BoltContext(context if context else {}), self.body) self.lazy_only = self.headers.get("x-slack-bolt-lazy-only", [False])[0] self.lazy_function_name = self.headers.get( "x-slack-bolt-lazy-function-name", [None] diff --git a/slack_bolt/util/payload_utils.py b/slack_bolt/util/payload_utils.py index 8bf557491..a2ca8d204 100644 --- a/slack_bolt/util/payload_utils.py +++ b/slack_bolt/util/payload_utils.py @@ -10,22 +10,22 @@ # ------------------- -def to_event(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: - return payload["event"] if is_event(payload) else None +def to_event(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return body["event"] if is_event(body) else None -def to_message(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: - if is_event(payload) and payload["event"]["type"] == "message": - return to_event(payload) +def to_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_event(body) and body["event"]["type"] == "message": + return to_event(body) return None -def is_event(payload: Dict[str, Any]) -> bool: +def is_event(body: Dict[str, Any]) -> bool: return ( - payload is not None - and _is_expected_type(payload, "event_callback") - and "event" in payload - and "type" in payload["event"] + body is not None + and _is_expected_type(body, "event_callback") + and "event" in body + and "type" in body["event"] ) @@ -34,12 +34,12 @@ def is_event(payload: Dict[str, Any]) -> bool: # ------------------- -def to_command(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: - return payload if is_slash_command(payload) else None +def to_command(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return body if is_slash_command(body) else None -def is_slash_command(payload: Dict[str, Any]) -> bool: - return payload is not None and "command" in payload +def is_slash_command(body: Dict[str, Any]) -> bool: + return body is not None and "command" in body # ------------------- @@ -47,62 +47,62 @@ def is_slash_command(payload: Dict[str, Any]) -> bool: # ------------------- -def to_action(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: - if is_action(payload): - if is_block_actions(payload): - return payload["actions"][0] +def to_action(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_action(body): + if is_block_actions(body) or is_attachment_action(body): + return body["actions"][0] else: - return payload + return body return None -def is_action(payload: Dict[str, Any]) -> bool: +def is_action(body: Dict[str, Any]) -> bool: return ( - is_attachment_action(payload) - or is_block_actions(payload) - or is_dialog_submission(payload) - or is_dialog_cancellation(payload) - or is_workflow_step_edit(payload) + is_attachment_action(body) + or is_block_actions(body) + or is_dialog_submission(body) + or is_dialog_cancellation(body) + or is_workflow_step_edit(body) ) -def is_attachment_action(payload: Dict[str, Any]) -> bool: +def is_attachment_action(body: Dict[str, Any]) -> bool: return ( - payload is not None - and _is_expected_type(payload, "interactive_message") - and "callback_id" in payload + body is not None + and _is_expected_type(body, "interactive_message") + and "callback_id" in body ) -def is_block_actions(payload: Dict[str, Any]) -> bool: +def is_block_actions(body: Dict[str, Any]) -> bool: return ( - payload is not None - and _is_expected_type(payload, "block_actions") - and "actions" in payload + body is not None + and _is_expected_type(body, "block_actions") + and "actions" in body ) -def is_dialog_submission(payload: Dict[str, Any]) -> bool: +def is_dialog_submission(body: Dict[str, Any]) -> bool: return ( - payload is not None - and _is_expected_type(payload, "dialog_submission") - and "callback_id" in payload + body is not None + and _is_expected_type(body, "dialog_submission") + and "callback_id" in body ) -def is_dialog_cancellation(payload: Dict[str, Any]) -> bool: +def is_dialog_cancellation(body: Dict[str, Any]) -> bool: return ( - payload is not None - and _is_expected_type(payload, "dialog_cancellation") - and "callback_id" in payload + body is not None + and _is_expected_type(body, "dialog_cancellation") + and "callback_id" in body ) -def is_workflow_step_edit(payload: Dict[str, Any]) -> bool: +def is_workflow_step_edit(body: Dict[str, Any]) -> bool: return ( - payload is not None - and _is_expected_type(payload, "workflow_step_edit") - and "callback_id" in payload + body is not None + and _is_expected_type(body, "workflow_step_edit") + and "callback_id" in body ) @@ -111,29 +111,29 @@ def is_workflow_step_edit(payload: Dict[str, Any]) -> bool: # ------------------- -def to_options(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: - if is_options(payload): - return payload +def to_options(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_options(body): + return body return None -def is_options(payload: Dict[str, Any]) -> bool: - return is_block_suggestion(payload) or is_dialog_suggestion(payload) +def is_options(body: Dict[str, Any]) -> bool: + return is_block_suggestion(body) or is_dialog_suggestion(body) -def is_block_suggestion(payload: Dict[str, Any]) -> bool: +def is_block_suggestion(body: Dict[str, Any]) -> bool: return ( - payload is not None - and _is_expected_type(payload, "block_suggestion") - and "action_id" in payload + body is not None + and _is_expected_type(body, "block_suggestion") + and "action_id" in body ) -def is_dialog_suggestion(payload: Dict[str, Any]) -> bool: +def is_dialog_suggestion(body: Dict[str, Any]) -> bool: return ( - payload is not None - and _is_expected_type(payload, "dialog_suggestion") - and "callback_id" in payload + body is not None + and _is_expected_type(body, "dialog_suggestion") + and "callback_id" in body ) @@ -142,29 +142,29 @@ def is_dialog_suggestion(payload: Dict[str, Any]) -> bool: # ------------------- -def to_shortcut(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: - if is_shortcut(payload): - return payload +def to_shortcut(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_shortcut(body): + return body return None -def is_shortcut(payload: Dict[str, Any]) -> bool: - return is_global_shortcut(payload) or is_message_shortcut(payload) +def is_shortcut(body: Dict[str, Any]) -> bool: + return is_global_shortcut(body) or is_message_shortcut(body) -def is_global_shortcut(payload: Dict[str, Any]) -> bool: +def is_global_shortcut(body: Dict[str, Any]) -> bool: return ( - payload is not None - and _is_expected_type(payload, "shortcut") - and "callback_id" in payload + body is not None + and _is_expected_type(body, "shortcut") + and "callback_id" in body ) -def is_message_shortcut(payload: Dict[str, Any]) -> bool: +def is_message_shortcut(body: Dict[str, Any]) -> bool: return ( - payload is not None - and _is_expected_type(payload, "message_action") - and "callback_id" in payload + body is not None + and _is_expected_type(body, "message_action") + and "callback_id" in body ) @@ -173,31 +173,31 @@ def is_message_shortcut(payload: Dict[str, Any]) -> bool: # ------------------- -def to_view(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: - if is_view(payload): - return payload["view"] +def to_view(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_view(body): + return body["view"] return None -def is_view(payload: Dict[str, Any]) -> bool: - return is_view_submission(payload) or is_view_closed(payload) +def is_view(body: Dict[str, Any]) -> bool: + return is_view_submission(body) or is_view_closed(body) -def is_view_submission(payload: Dict[str, Any]) -> bool: +def is_view_submission(body: Dict[str, Any]) -> bool: return ( - payload is not None - and _is_expected_type(payload, "view_submission") - and "view" in payload - and "callback_id" in payload["view"] + body is not None + and _is_expected_type(body, "view_submission") + and "view" in body + and "callback_id" in body["view"] ) -def is_view_closed(payload: Dict[str, Any]) -> bool: +def is_view_closed(body: Dict[str, Any]) -> bool: return ( - payload is not None - and _is_expected_type(payload, "view_closed") - and "view" in payload - and "callback_id" in payload["view"] + body is not None + and _is_expected_type(body, "view_closed") + and "view" in body + and "callback_id" in body["view"] ) @@ -206,5 +206,5 @@ def is_view_closed(payload: Dict[str, Any]) -> bool: # ------------------------------------------ -def _is_expected_type(payload: dict, expected: str) -> bool: - return payload is not None and "type" in payload and payload["type"] == expected +def _is_expected_type(body: dict, expected: str) -> bool: + return body is not None and "type" in body and body["type"] == expected diff --git a/tests/adapter_tests/test_async_fastapi.py b/tests/adapter_tests/test_async_fastapi.py index c6acca066..5a7f8c7f3 100644 --- a/tests/adapter_tests/test_async_fastapi.py +++ b/tests/adapter_tests/test_async_fastapi.py @@ -52,7 +52,7 @@ async def event_handler(): app.event("app_mention")(event_handler) - payload = { + input = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -72,7 +72,7 @@ async def event_handler(): "event_time": 1595926230, "authed_users": ["W111"], } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) api = FastAPI() app_handler = AsyncSlackRequestHandler(app) @@ -96,7 +96,7 @@ async def shortcut_handler(ack): app.shortcut("test-shortcut")(shortcut_handler) - payload = { + input = { "type": "shortcut", "token": "verification_token", "action_ts": "111.111", @@ -111,7 +111,7 @@ async def shortcut_handler(ack): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) api = FastAPI() app_handler = AsyncSlackRequestHandler(app) @@ -135,7 +135,7 @@ async def command_handler(ack): app.command("/hello-world")(command_handler) - payload = ( + input = ( "token=verification_token" "&team_id=T111" "&team_domain=test-domain" @@ -150,7 +150,7 @@ async def command_handler(ack): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) api = FastAPI() app_handler = AsyncSlackRequestHandler(app) diff --git a/tests/adapter_tests/test_async_starlette.py b/tests/adapter_tests/test_async_starlette.py index b2b541972..f31fccfbb 100644 --- a/tests/adapter_tests/test_async_starlette.py +++ b/tests/adapter_tests/test_async_starlette.py @@ -53,7 +53,7 @@ async def event_handler(): app.event("app_mention")(event_handler) - payload = { + input = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -73,7 +73,7 @@ async def event_handler(): "event_time": 1595926230, "authed_users": ["W111"], } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) async def endpoint(req: Request): return await app_handler.handle(req) @@ -99,7 +99,7 @@ async def shortcut_handler(ack): app.shortcut("test-shortcut")(shortcut_handler) - payload = { + input = { "type": "shortcut", "token": "verification_token", "action_ts": "111.111", @@ -114,7 +114,7 @@ async def shortcut_handler(ack): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) async def endpoint(req: Request): return await app_handler.handle(req) @@ -140,7 +140,7 @@ async def command_handler(ack): app.command("/hello-world")(command_handler) - payload = ( + input = ( "token=verification_token" "&team_id=T111" "&team_domain=test-domain" @@ -155,7 +155,7 @@ async def command_handler(ack): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) async def endpoint(req: Request): return await app_handler.handle(req) diff --git a/tests/adapter_tests/test_aws_lambda.py b/tests/adapter_tests/test_aws_lambda.py index ad5679aff..e2fb22957 100644 --- a/tests/adapter_tests/test_aws_lambda.py +++ b/tests/adapter_tests/test_aws_lambda.py @@ -59,7 +59,7 @@ def event_handler(): app.event("app_mention")(event_handler) - payload = { + input = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -79,7 +79,7 @@ def event_handler(): "event_time": 1595926230, "authed_users": ["W111"], } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) event = { "body": body, "queryStringParameters": {}, @@ -100,7 +100,7 @@ def shortcut_handler(ack): app.shortcut("test-shortcut")(shortcut_handler) - payload = { + input = { "type": "shortcut", "token": "verification_token", "action_ts": "111.111", @@ -115,7 +115,7 @@ def shortcut_handler(ack): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) event = { "body": body, "queryStringParameters": {}, @@ -136,7 +136,7 @@ def command_handler(ack): app.command("/hello-world")(command_handler) - payload = ( + input = ( "token=verification_token" "&team_id=T111" "&team_domain=test-domain" @@ -151,7 +151,7 @@ def command_handler(ack): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) event = { "body": body, "queryStringParameters": {}, diff --git a/tests/adapter_tests/test_cherrypy.py b/tests/adapter_tests/test_cherrypy.py index 8054f3ac3..187aaed37 100644 --- a/tests/adapter_tests/test_cherrypy.py +++ b/tests/adapter_tests/test_cherrypy.py @@ -73,7 +73,7 @@ def build_headers(self, timestamp: str, body: str): ] def test_events(self): - payload = { + input = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -93,7 +93,7 @@ def test_events(self): "event_time": 1595926230, "authed_users": ["W111"], } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) cherrypy.request.process_request_body = True self.getPage( "/slack/events", @@ -105,7 +105,7 @@ def test_events(self): self.assertBody("") def test_shortcuts(self): - payload = { + input = { "type": "shortcut", "token": "verification_token", "action_ts": "111.111", @@ -120,7 +120,7 @@ def test_shortcuts(self): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) cherrypy.request.process_request_body = True self.getPage( "/slack/events", @@ -132,7 +132,7 @@ def test_shortcuts(self): self.assertBody("") def test_commands(self): - payload = ( + input = ( "token=verification_token" "&team_id=T111" "&team_domain=test-domain" @@ -147,7 +147,7 @@ def test_commands(self): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) cherrypy.request.process_request_body = True self.getPage( "/slack/events", diff --git a/tests/adapter_tests/test_fastapi.py b/tests/adapter_tests/test_fastapi.py index 066f6ddb6..a809704af 100644 --- a/tests/adapter_tests/test_fastapi.py +++ b/tests/adapter_tests/test_fastapi.py @@ -52,7 +52,7 @@ def event_handler(): app.event("app_mention")(event_handler) - payload = { + input = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -72,7 +72,7 @@ def event_handler(): "event_time": 1595926230, "authed_users": ["W111"], } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) api = FastAPI() app_handler = SlackRequestHandler(app) @@ -96,7 +96,7 @@ def shortcut_handler(ack): app.shortcut("test-shortcut")(shortcut_handler) - payload = { + input = { "type": "shortcut", "token": "verification_token", "action_ts": "111.111", @@ -111,7 +111,7 @@ def shortcut_handler(ack): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) api = FastAPI() app_handler = SlackRequestHandler(app) @@ -135,7 +135,7 @@ def command_handler(ack): app.command("/hello-world")(command_handler) - payload = ( + input = ( "token=verification_token" "&team_id=T111" "&team_domain=test-domain" @@ -150,7 +150,7 @@ def command_handler(ack): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) api = FastAPI() app_handler = SlackRequestHandler(app) diff --git a/tests/adapter_tests/test_flask.py b/tests/adapter_tests/test_flask.py index 0c0fa6ef5..9884cd2c8 100644 --- a/tests/adapter_tests/test_flask.py +++ b/tests/adapter_tests/test_flask.py @@ -49,7 +49,7 @@ def event_handler(): app.event("app_mention")(event_handler) - payload = { + input = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -69,7 +69,7 @@ def event_handler(): "event_time": 1595926230, "authed_users": ["W111"], } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) flask_app = Flask(__name__) @@ -92,7 +92,7 @@ def shortcut_handler(ack): app.shortcut("test-shortcut")(shortcut_handler) - payload = { + input = { "type": "shortcut", "token": "verification_token", "action_ts": "111.111", @@ -107,7 +107,7 @@ def shortcut_handler(ack): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) flask_app = Flask(__name__) @@ -130,7 +130,7 @@ def command_handler(ack): app.command("/hello-world")(command_handler) - payload = ( + input = ( "token=verification_token" "&team_id=T111" "&team_domain=test-domain" @@ -145,7 +145,7 @@ def command_handler(ack): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) flask_app = Flask(__name__) diff --git a/tests/adapter_tests/test_starlette.py b/tests/adapter_tests/test_starlette.py index 292ce632a..1c9090b79 100644 --- a/tests/adapter_tests/test_starlette.py +++ b/tests/adapter_tests/test_starlette.py @@ -55,7 +55,7 @@ def event_handler(): app_handler = SlackRequestHandler(app) - payload = { + input = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -75,7 +75,7 @@ def event_handler(): "event_time": 1595926230, "authed_users": ["W111"], } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) async def endpoint(req: Request): return await app_handler.handle(req) @@ -101,7 +101,7 @@ def shortcut_handler(ack): app_handler = SlackRequestHandler(app) - payload = { + input = { "type": "shortcut", "token": "verification_token", "action_ts": "111.111", @@ -116,7 +116,7 @@ def shortcut_handler(ack): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) async def endpoint(req: Request): return await app_handler.handle(req) @@ -142,7 +142,7 @@ def command_handler(ack): app_handler = SlackRequestHandler(app) - payload = ( + input = ( "token=verification_token" "&team_id=T111" "&team_domain=test-domain" @@ -157,7 +157,7 @@ def command_handler(ack): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(input) async def endpoint(req: Request): return await app_handler.handle(req) diff --git a/tests/async_scenario_tests/test_attachment_actions.py b/tests/async_scenario_tests/test_attachment_actions.py index b7080732f..d48211d93 100644 --- a/tests/async_scenario_tests/test_attachment_actions.py +++ b/tests/async_scenario_tests/test_attachment_actions.py @@ -140,7 +140,7 @@ async def test_failure_2(self): # https://api.slack.com/legacy/interactive-messages -payload = { +body = { "type": "interactive_message", "actions": [ { @@ -188,10 +188,11 @@ async def test_failure_2(self): "trigger_id": "111.222.valid", } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -async def simple_listener(ack, body, action): - assert body == action +async def simple_listener(ack, body, payload, action): + assert body != payload + assert payload == action assert body["trigger_id"] == "111.222.valid" await ack() diff --git a/tests/async_scenario_tests/test_block_actions.py b/tests/async_scenario_tests/test_block_actions.py index e661ad16b..4d753ba6f 100644 --- a/tests/async_scenario_tests/test_block_actions.py +++ b/tests/async_scenario_tests/test_block_actions.py @@ -133,7 +133,7 @@ async def test_failure_2(self): assert self.mock_received_requests["/auth.test"] == 2 -payload = { +body = { "type": "block_actions", "user": { "id": "W111", @@ -170,11 +170,12 @@ async def test_failure_2(self): ], } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -async def simple_listener(ack, body, action): +async def simple_listener(ack, body, payload, action): assert body["trigger_id"] == "111.222.valid" - assert body["actions"][0] == action + assert body["actions"][0] == payload + assert payload == action assert action["action_id"] == "a" await ack() diff --git a/tests/async_scenario_tests/test_block_suggestion.py b/tests/async_scenario_tests/test_block_suggestion.py index a7b890f75..ceb546e0f 100644 --- a/tests/async_scenario_tests/test_block_suggestion.py +++ b/tests/async_scenario_tests/test_block_suggestion.py @@ -173,7 +173,7 @@ async def test_failure_multi(self): assert self.mock_received_requests["/auth.test"] == 2 -payload = { +body = { "type": "block_suggestion", "user": { "id": "W111", @@ -246,12 +246,12 @@ async def test_failure_multi(self): }, } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -multi_payload = copy.deepcopy(payload) -multi_payload["block_id"] = "mes_b" -multi_payload["action_id"] = "mes_a" -raw_multi_body = f"payload={quote(json.dumps(multi_payload))}" +multi_body = copy.deepcopy(body) +multi_body["block_id"] = "mes_b" +multi_body["action_id"] = "mes_a" +raw_multi_body = f"payload={quote(json.dumps(multi_body))}" response = { "options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}] @@ -278,11 +278,13 @@ async def test_failure_multi(self): expected_multi_response_body = json.dumps(multi_response) -async def show_options(ack, body, options): +async def show_options(ack, body, payload, options): assert body == options + assert payload == options await ack(response) -async def show_multi_options(ack, body, options): +async def show_multi_options(ack, body, payload, options): assert body == options + assert payload == options await ack(multi_response) diff --git a/tests/async_scenario_tests/test_dialogs.py b/tests/async_scenario_tests/test_dialogs.py index 934b43d7c..b6a33f317 100644 --- a/tests/async_scenario_tests/test_dialogs.py +++ b/tests/async_scenario_tests/test_dialogs.py @@ -265,7 +265,7 @@ async def test_cancellation_failure_2(self): assert self.mock_received_requests["/auth.test"] == 2 -suggestion_payload = { +suggestion_body = { "type": "dialog_suggestion", "token": "verification_token", "action_ts": "1596603332.676855", @@ -283,7 +283,7 @@ async def test_cancellation_failure_2(self): "state": "Limo", } -submission_payload = { +submission_body = { "type": "dialog_submission", "token": "verification_token", "action_ts": "1596603334.328193", @@ -305,7 +305,7 @@ async def test_cancellation_failure_2(self): "state": "Limo", } -cancellation_payload = { +cancellation_body = { "type": "dialog_cancellation", "token": "verification_token", "action_ts": "1596603453.047897", @@ -322,9 +322,9 @@ async def test_cancellation_failure_2(self): "state": "Limo", } -suggestion_raw_body = f"payload={quote(json.dumps(suggestion_payload))}" -submission_raw_body = f"payload={quote(json.dumps(submission_payload))}" -cancellation_raw_body = f"payload={quote(json.dumps(cancellation_payload))}" +suggestion_raw_body = f"payload={quote(json.dumps(suggestion_body))}" +submission_raw_body = f"payload={quote(json.dumps(submission_body))}" +cancellation_raw_body = f"payload={quote(json.dumps(cancellation_body))}" async def handle_submission(ack): @@ -343,11 +343,13 @@ async def handle_submission(ack): } -async def handle_suggestion(ack, body, options): +async def handle_suggestion(ack, body, payload, options): assert body == options + assert payload == options await ack(options_response) -async def handle_cancellation(ack, body, action): +async def handle_cancellation(ack, body, payload, action): assert body == action + assert payload == action await ack() diff --git a/tests/async_scenario_tests/test_error_handler.py b/tests/async_scenario_tests/test_error_handler.py index 4fb0a7fab..6a599c5a0 100644 --- a/tests/async_scenario_tests/test_error_handler.py +++ b/tests/async_scenario_tests/test_error_handler.py @@ -52,7 +52,7 @@ def build_headers(self, timestamp: str, body: str): } def build_valid_request(self) -> AsyncBoltRequest: - payload = { + body = { "type": "block_actions", "user": {"id": "W111",}, "api_app_id": "A111", @@ -72,7 +72,7 @@ def build_valid_request(self) -> AsyncBoltRequest: } ], } - raw_body = f"payload={quote(json.dumps(payload))}" + raw_body = f"payload={quote(json.dumps(body))}" timestamp = str(int(time())) return AsyncBoltRequest( body=raw_body, headers=self.build_headers(timestamp, raw_body) diff --git a/tests/async_scenario_tests/test_events.py b/tests/async_scenario_tests/test_events.py index 5bd6dfc70..1a390ad0d 100644 --- a/tests/async_scenario_tests/test_events.py +++ b/tests/async_scenario_tests/test_events.py @@ -48,7 +48,7 @@ def build_headers(self, timestamp: str, body: str): } def build_valid_app_mention_request(self) -> AsyncBoltRequest: - timestamp, body = str(int(time())), json.dumps(app_mention_payload) + timestamp, body = str(int(time())), json.dumps(app_mention_body) return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) @pytest.mark.asyncio @@ -114,7 +114,7 @@ async def test_simultaneous_requests(self): assert self.mock_received_requests["/chat.postMessage"] == times -app_mention_payload = { +app_mention_body = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -136,17 +136,19 @@ async def test_simultaneous_requests(self): } -async def random_sleeper(payload, say, event): - assert payload == app_mention_payload - assert payload["event"] == event +async def random_sleeper(body, say, payload, event): + assert body == app_mention_body + assert body["event"] == payload + assert payload == event seconds = random() + 2 # 2-3 seconds await asyncio.sleep(seconds) await say(f"Sending this message after sleeping for {seconds} seconds") -async def whats_up(payload, say, event): - assert payload == app_mention_payload - assert payload["event"] == event +async def whats_up(body, say, payload, event): + assert body == app_mention_body + assert body["event"] == payload + assert payload == event await say("What's up?") diff --git a/tests/async_scenario_tests/test_lazy.py b/tests/async_scenario_tests/test_lazy.py index de7fa758e..229c7709b 100644 --- a/tests/async_scenario_tests/test_lazy.py +++ b/tests/async_scenario_tests/test_lazy.py @@ -52,7 +52,7 @@ def build_headers(self, timestamp: str, body: str): } def build_valid_request(self) -> AsyncBoltRequest: - payload = { + body = { "type": "block_actions", "user": {"id": "W111",}, "api_app_id": "A111", @@ -72,7 +72,7 @@ def build_valid_request(self) -> AsyncBoltRequest: } ], } - raw_body = f"payload={quote(json.dumps(payload))}" + raw_body = f"payload={quote(json.dumps(body))}" timestamp = str(int(time())) return AsyncBoltRequest( body=raw_body, headers=self.build_headers(timestamp, raw_body) diff --git a/tests/async_scenario_tests/test_message.py b/tests/async_scenario_tests/test_message.py index e04cf02e8..57936e2bc 100644 --- a/tests/async_scenario_tests/test_message.py +++ b/tests/async_scenario_tests/test_message.py @@ -48,11 +48,11 @@ def build_headers(self, timestamp: str, body: str): } def build_request(self) -> AsyncBoltRequest: - timestamp, body = str(int(time())), json.dumps(message_payload) + timestamp, body = str(int(time())), json.dumps(message_body) return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) def build_request2(self) -> AsyncBoltRequest: - timestamp, body = str(int(time())), json.dumps(message_payload2) + timestamp, body = str(int(time())), json.dumps(message_body2) return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) @pytest.mark.asyncio @@ -126,7 +126,7 @@ async def test_regexp_keyword_unmatched(self): assert self.mock_received_requests["/auth.test"] == 1 -message_payload = { +message_body = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -161,12 +161,12 @@ async def test_regexp_keyword_unmatched(self): } -async def whats_up(payload, say): - assert payload == message_payload +async def whats_up(body, say): + assert body == message_body await say("What's up?") -message_payload2 = { +message_body2 = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", diff --git a/tests/async_scenario_tests/test_middleware.py b/tests/async_scenario_tests/test_middleware.py index 241ba04c5..c3fd35da3 100644 --- a/tests/async_scenario_tests/test_middleware.py +++ b/tests/async_scenario_tests/test_middleware.py @@ -35,7 +35,7 @@ def event_loop(self): restore_os_env(old_os_env) def build_request(self) -> AsyncBoltRequest: - payload = { + body = { "type": "shortcut", "token": "verification_token", "action_ts": "111.111", @@ -49,7 +49,7 @@ def build_request(self) -> AsyncBoltRequest: "callback_id": "test-shortcut", "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(body) return AsyncBoltRequest( body=body, headers={ diff --git a/tests/async_scenario_tests/test_shortcut.py b/tests/async_scenario_tests/test_shortcut.py index 2a07a3c8e..dd4f97784 100644 --- a/tests/async_scenario_tests/test_shortcut.py +++ b/tests/async_scenario_tests/test_shortcut.py @@ -175,7 +175,7 @@ async def test_failure_2(self): assert self.mock_received_requests["/auth.test"] == 3 -global_shortcut_payload = { +global_shortcut_body = { "type": "shortcut", "token": "verification_token", "action_ts": "111.111", @@ -190,7 +190,7 @@ async def test_failure_2(self): "trigger_id": "111.111.xxxxxx", } -message_shortcut_payload = { +message_shortcut_body = { "type": "message_action", "token": "verification_token", "action_ts": "1583637157.207593", @@ -231,10 +231,11 @@ async def test_failure_2(self): "response_url": "https://hooks.slack.com/app/T111/111/xxx", } -global_shortcut_raw_body = f"payload={quote(json.dumps(global_shortcut_payload))}" -message_shortcut_raw_body = f"payload={quote(json.dumps(message_shortcut_payload))}" +global_shortcut_raw_body = f"payload={quote(json.dumps(global_shortcut_body))}" +message_shortcut_raw_body = f"payload={quote(json.dumps(message_shortcut_body))}" -async def simple_listener(ack, body, shortcut): +async def simple_listener(ack, body, payload, shortcut): assert body == shortcut + assert payload == shortcut await ack() diff --git a/tests/async_scenario_tests/test_slash_command.py b/tests/async_scenario_tests/test_slash_command.py index cab5b2de3..f8cd36f53 100644 --- a/tests/async_scenario_tests/test_slash_command.py +++ b/tests/async_scenario_tests/test_slash_command.py @@ -47,7 +47,7 @@ def build_headers(self, timestamp: str, body: str): } def build_valid_request(self) -> AsyncBoltRequest: - timestamp, body = str(int(time())), json.dumps(slash_command_payload) + timestamp, body = str(int(time())), json.dumps(slash_command_body) return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) @pytest.mark.asyncio @@ -93,7 +93,7 @@ async def test_failure(self): assert self.mock_received_requests["/auth.test"] == 2 -slash_command_payload = ( +slash_command_body = ( "token=verification_token" "&team_id=T111" "&team_domain=test-domain" @@ -110,6 +110,7 @@ async def test_failure(self): ) -async def commander(ack, body, command): +async def commander(ack, body, payload, command): assert body == command + assert payload == command await ack() diff --git a/tests/async_scenario_tests/test_view_closed.py b/tests/async_scenario_tests/test_view_closed.py index 2d3e1a465..4cc119a33 100644 --- a/tests/async_scenario_tests/test_view_closed.py +++ b/tests/async_scenario_tests/test_view_closed.py @@ -132,7 +132,7 @@ async def test_failure_2(self): assert self.mock_received_requests["/auth.test"] == 2 -payload = { +body = { "type": "view_closed", "team": { "id": "T111", @@ -180,10 +180,11 @@ async def test_failure_2(self): "response_urls": [], } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -async def simple_listener(ack, body, view): +async def simple_listener(ack, body, payload, view): assert body["view"] == view + assert payload == view assert view["private_metadata"] == "This is for you!" await ack() diff --git a/tests/async_scenario_tests/test_view_submission.py b/tests/async_scenario_tests/test_view_submission.py index bdfe65fd0..97653c249 100644 --- a/tests/async_scenario_tests/test_view_submission.py +++ b/tests/async_scenario_tests/test_view_submission.py @@ -119,7 +119,7 @@ async def test_failure_2(self): assert self.mock_received_requests["/auth.test"] == 2 -payload = { +body = { "type": "view_submission", "team": { "id": "T111", @@ -170,11 +170,12 @@ async def test_failure_2(self): "response_urls": [], } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -async def simple_listener(ack, body, view): +async def simple_listener(ack, body, payload, view): assert body["trigger_id"] == "111.222.valid" - assert body["view"] == view + assert body["view"] == payload + assert payload == view assert view["private_metadata"] == "This is for you!" await ack() diff --git a/tests/scenario_tests/test_attachment_actions.py b/tests/scenario_tests/test_attachment_actions.py index e99e58600..39403fdfa 100644 --- a/tests/scenario_tests/test_attachment_actions.py +++ b/tests/scenario_tests/test_attachment_actions.py @@ -114,7 +114,7 @@ def test_failure_2(self): # https://api.slack.com/legacy/interactive-messages -payload = { +body = { "type": "interactive_message", "actions": [ { @@ -162,10 +162,11 @@ def test_failure_2(self): "trigger_id": "111.222.valid", } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -def simple_listener(ack, body, action): - assert body == action +def simple_listener(ack, body, payload, action): + assert body != payload + assert payload == action assert body["trigger_id"] == "111.222.valid" ack() diff --git a/tests/scenario_tests/test_block_actions.py b/tests/scenario_tests/test_block_actions.py index c4da77689..e1a6e2891 100644 --- a/tests/scenario_tests/test_block_actions.py +++ b/tests/scenario_tests/test_block_actions.py @@ -107,7 +107,7 @@ def test_failure_2(self): assert self.mock_received_requests["/auth.test"] == 2 -payload = { +body = { "type": "block_actions", "user": { "id": "W111", @@ -144,11 +144,12 @@ def test_failure_2(self): ], } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -def simple_listener(ack, body, action): +def simple_listener(ack, body, payload, action): assert body["trigger_id"] == "111.222.valid" - assert body["actions"][0] == action + assert body["actions"][0] == payload + assert payload == action assert action["action_id"] == "a" ack() diff --git a/tests/scenario_tests/test_block_suggestion.py b/tests/scenario_tests/test_block_suggestion.py index 25bb5d9fa..86c5417f2 100644 --- a/tests/scenario_tests/test_block_suggestion.py +++ b/tests/scenario_tests/test_block_suggestion.py @@ -158,7 +158,7 @@ def test_failure_multi(self): assert self.mock_received_requests["/auth.test"] == 2 -payload = { +body = { "type": "block_suggestion", "user": { "id": "W111", @@ -231,12 +231,12 @@ def test_failure_multi(self): }, } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -multi_payload = copy.deepcopy(payload) -multi_payload["block_id"] = "mes_b" -multi_payload["action_id"] = "mes_a" -raw_multi_body = f"payload={quote(json.dumps(multi_payload))}" +multi_body = copy.deepcopy(body) +multi_body["block_id"] = "mes_b" +multi_body["action_id"] = "mes_a" +raw_multi_body = f"payload={quote(json.dumps(multi_body))}" response = { "options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}] @@ -263,11 +263,13 @@ def test_failure_multi(self): expected_multi_response_body = json.dumps(multi_response) -def show_options(ack, body, options): +def show_options(ack, body, payload, options): assert body == options + assert payload == options ack(response) -def show_multi_options(ack, body, options): +def show_multi_options(ack, body, payload, options): assert body == options + assert payload == options ack(multi_response) diff --git a/tests/scenario_tests/test_dialogs.py b/tests/scenario_tests/test_dialogs.py index 265ad8868..fd17907ad 100644 --- a/tests/scenario_tests/test_dialogs.py +++ b/tests/scenario_tests/test_dialogs.py @@ -248,7 +248,7 @@ def test_cancellation_failure_2(self): assert self.mock_received_requests["/auth.test"] == 2 -suggestion_payload = { +suggestion_body = { "type": "dialog_suggestion", "token": "verification_token", "action_ts": "1596603332.676855", @@ -266,7 +266,7 @@ def test_cancellation_failure_2(self): "state": "Limo", } -submission_payload = { +submission_body = { "type": "dialog_submission", "token": "verification_token", "action_ts": "1596603334.328193", @@ -288,7 +288,7 @@ def test_cancellation_failure_2(self): "state": "Limo", } -cancellation_payload = { +cancellation_body = { "type": "dialog_cancellation", "token": "verification_token", "action_ts": "1596603453.047897", @@ -305,9 +305,9 @@ def test_cancellation_failure_2(self): "state": "Limo", } -suggestion_raw_body = f"payload={quote(json.dumps(suggestion_payload))}" -submission_raw_body = f"payload={quote(json.dumps(submission_payload))}" -cancellation_raw_body = f"payload={quote(json.dumps(cancellation_payload))}" +suggestion_raw_body = f"payload={quote(json.dumps(suggestion_body))}" +submission_raw_body = f"payload={quote(json.dumps(submission_body))}" +cancellation_raw_body = f"payload={quote(json.dumps(cancellation_body))}" def handle_submission(ack): @@ -326,11 +326,13 @@ def handle_submission(ack): } -def handle_suggestion(ack, body, options): +def handle_suggestion(ack, body, payload, options): assert body == options + assert payload == options ack(options_response) -def handle_cancellation(ack, body, action): +def handle_cancellation(ack, body, payload, action): assert body == action + assert payload == action ack() diff --git a/tests/scenario_tests/test_error_handler.py b/tests/scenario_tests/test_error_handler.py index 1f6d1f526..131454633 100644 --- a/tests/scenario_tests/test_error_handler.py +++ b/tests/scenario_tests/test_error_handler.py @@ -46,7 +46,7 @@ def build_headers(self, timestamp: str, body: str): } def build_valid_request(self) -> BoltRequest: - payload = { + body = { "type": "block_actions", "user": {"id": "W111",}, "api_app_id": "A111", @@ -66,7 +66,7 @@ def build_valid_request(self) -> BoltRequest: } ], } - raw_body = f"payload={quote(json.dumps(payload))}" + raw_body = f"payload={quote(json.dumps(body))}" timestamp = str(int(time())) return BoltRequest( body=raw_body, headers=self.build_headers(timestamp, raw_body) diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index 92725fbbb..36b66d1c0 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -39,7 +39,7 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - valid_event_payload = { + valid_event_body = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -68,12 +68,13 @@ def test_middleware(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @app.event("app_mention") - def handle_app_mention(payload, say, event): - assert payload == self.valid_event_payload - assert payload["event"] == event + def handle_app_mention(body, say, payload, event): + assert body == self.valid_event_body + assert body["event"] == payload + assert payload == event say("What's up?") - timestamp, body = str(int(time())), json.dumps(self.valid_event_payload) + timestamp, body = str(int(time())), json.dumps(self.valid_event_body) request: BoltRequest = BoltRequest( body=body, headers=self.build_headers(timestamp, body) ) @@ -91,11 +92,12 @@ def skip_middleware(req, resp, next): pass @app.event("app_mention", middleware=[skip_middleware]) - def handle_app_mention(payload, logger, event): - assert payload["event"] == event + def handle_app_mention(body, logger, payload, event): + assert body["event"] == payload + assert payload == event logger.info(payload) - timestamp, body = str(int(time())), json.dumps(self.valid_event_payload) + timestamp, body = str(int(time())), json.dumps(self.valid_event_body) request: BoltRequest = BoltRequest( body=body, headers=self.build_headers(timestamp, body) ) diff --git a/tests/scenario_tests/test_lazy.py b/tests/scenario_tests/test_lazy.py index facd2cc6a..0a81b3488 100644 --- a/tests/scenario_tests/test_lazy.py +++ b/tests/scenario_tests/test_lazy.py @@ -46,7 +46,7 @@ def build_headers(self, timestamp: str, body: str): } def build_valid_request(self) -> BoltRequest: - payload = { + body = { "type": "block_actions", "user": {"id": "W111",}, "api_app_id": "A111", @@ -66,7 +66,7 @@ def build_valid_request(self) -> BoltRequest: } ], } - raw_body = f"payload={quote(json.dumps(payload))}" + raw_body = f"payload={quote(json.dumps(body))}" timestamp = str(int(time.time())) return BoltRequest( body=raw_body, headers=self.build_headers(timestamp, raw_body) diff --git a/tests/scenario_tests/test_message.py b/tests/scenario_tests/test_message.py index b2ecdc1ac..1acfe56b7 100644 --- a/tests/scenario_tests/test_message.py +++ b/tests/scenario_tests/test_message.py @@ -42,11 +42,11 @@ def build_headers(self, timestamp: str, body: str): } def build_request(self) -> BoltRequest: - timestamp, body = str(int(time.time())), json.dumps(message_payload) + timestamp, body = str(int(time.time())), json.dumps(message_body) return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) def build_request2(self) -> BoltRequest: - timestamp, body = str(int(time.time())), json.dumps(message_payload2) + timestamp, body = str(int(time.time())), json.dumps(message_body2) return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) def test_string_keyword(self): @@ -114,7 +114,7 @@ def test_regexp_keyword_unmatched(self): assert self.mock_received_requests["/auth.test"] == 1 -message_payload = { +message_body = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -149,12 +149,13 @@ def test_regexp_keyword_unmatched(self): } -def whats_up(payload, say): - assert payload == message_payload +def whats_up(body, payload, message, say): + assert body == message_body + assert payload == message say("What's up?") -message_payload2 = { +message_body2 = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -177,8 +178,9 @@ def whats_up(payload, say): } -def verify_matches(context, say, body, message): +def verify_matches(context, say, body, payload, message): assert context["matches"] == ("103", "you") assert context.matches == ("103", "you") assert body["event"] == message + assert payload == message say("Thanks!") diff --git a/tests/scenario_tests/test_middleware.py b/tests/scenario_tests/test_middleware.py index f81b0e135..c533abe5b 100644 --- a/tests/scenario_tests/test_middleware.py +++ b/tests/scenario_tests/test_middleware.py @@ -30,7 +30,7 @@ def teardown_method(self): restore_os_env(self.old_os_env) def build_request(self) -> BoltRequest: - payload = { + body = { "type": "shortcut", "token": "verification_token", "action_ts": "111.111", @@ -44,7 +44,7 @@ def build_request(self) -> BoltRequest: "callback_id": "test-shortcut", "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(payload) + timestamp, body = str(int(time())), json.dumps(body) return BoltRequest( body=body, headers={ diff --git a/tests/scenario_tests/test_shortcut.py b/tests/scenario_tests/test_shortcut.py index 19e740785..32939eac3 100644 --- a/tests/scenario_tests/test_shortcut.py +++ b/tests/scenario_tests/test_shortcut.py @@ -160,7 +160,7 @@ def test_failure_2(self): assert self.mock_received_requests["/auth.test"] == 3 -global_shortcut_payload = { +global_shortcut_body = { "type": "shortcut", "token": "verification_token", "action_ts": "111.111", @@ -175,7 +175,7 @@ def test_failure_2(self): "trigger_id": "111.111.xxxxxx", } -message_shortcut_payload = { +message_shortcut_body = { "type": "message_action", "token": "verification_token", "action_ts": "1583637157.207593", @@ -216,10 +216,11 @@ def test_failure_2(self): "response_url": "https://hooks.slack.com/app/T111/111/xxx", } -global_shortcut_raw_body = f"payload={quote(json.dumps(global_shortcut_payload))}" -message_shortcut_raw_body = f"payload={quote(json.dumps(message_shortcut_payload))}" +global_shortcut_raw_body = f"payload={quote(json.dumps(global_shortcut_body))}" +message_shortcut_raw_body = f"payload={quote(json.dumps(message_shortcut_body))}" -def simple_listener(ack, body, shortcut): +def simple_listener(ack, body, payload, shortcut): assert body == shortcut + assert payload == shortcut ack() diff --git a/tests/scenario_tests/test_slash_command.py b/tests/scenario_tests/test_slash_command.py index 7b0fa737e..d19326772 100644 --- a/tests/scenario_tests/test_slash_command.py +++ b/tests/scenario_tests/test_slash_command.py @@ -41,7 +41,7 @@ def build_headers(self, timestamp: str, body: str): } def build_valid_request(self) -> BoltRequest: - timestamp, body = str(int(time())), json.dumps(slash_command_payload) + timestamp, body = str(int(time())), json.dumps(slash_command_body) return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) def test_mock_server_is_running(self): @@ -83,7 +83,7 @@ def test_failure(self): assert self.mock_received_requests["/auth.test"] == 2 -slash_command_payload = ( +slash_command_body = ( "token=verification_token" "&team_id=T111" "&team_domain=test-domain" @@ -100,6 +100,7 @@ def test_failure(self): ) -def commander(ack, body, command): +def commander(ack, body, payload, command): assert body == command + assert payload == command ack() diff --git a/tests/scenario_tests/test_view_closed.py b/tests/scenario_tests/test_view_closed.py index df1a57d94..6d67cdf57 100644 --- a/tests/scenario_tests/test_view_closed.py +++ b/tests/scenario_tests/test_view_closed.py @@ -119,7 +119,7 @@ def test_failure_2(self): assert self.mock_received_requests["/auth.test"] == 2 -payload = { +body = { "type": "view_closed", "team": { "id": "T111", @@ -167,10 +167,11 @@ def test_failure_2(self): "response_urls": [], } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -def simple_listener(ack, body, view): - assert body["view"] == view +def simple_listener(ack, body, payload, view): + assert body["view"] == payload + assert payload == view assert view["private_metadata"] == "This is for you!" ack() diff --git a/tests/scenario_tests/test_view_submission.py b/tests/scenario_tests/test_view_submission.py index 683866a62..9c39fe772 100644 --- a/tests/scenario_tests/test_view_submission.py +++ b/tests/scenario_tests/test_view_submission.py @@ -107,7 +107,7 @@ def test_failure_2(self): assert self.mock_received_requests["/auth.test"] == 2 -payload = { +body = { "type": "view_submission", "team": { "id": "T111", @@ -158,11 +158,12 @@ def test_failure_2(self): "response_urls": [], } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -def simple_listener(ack, body, view): +def simple_listener(ack, body, payload, view): assert body["trigger_id"] == "111.222.valid" - assert body["view"] == view + assert body["view"] == payload + assert payload == view assert view["private_metadata"] == "This is for you!" ack() From d2a6e36e407f12ec960e443056e4aab66bb066c0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2020 08:23:54 +0900 Subject: [PATCH 031/865] Fix samples --- samples/app.py | 16 ++-- samples/async_app.py | 12 +-- samples/async_steps_from_apps.py | 74 +++++++++---------- samples/aws_chalice/app.py | 4 +- samples/aws_chalice/oauth_app.py | 4 +- samples/aws_chalice/simple_app.py | 4 +- samples/aws_lambda/aws_lambda.py | 4 +- samples/aws_lambda/aws_lambda_oauth.py | 4 +- samples/aws_lambda/lazy_aws_lambda.py | 14 ++-- samples/bottle/app.py | 8 +- samples/bottle/oauth_app.py | 8 +- samples/cherrypy/app.py | 8 +- samples/cherrypy/oauth_app.py | 8 +- samples/dialogs_app.py | 10 +-- samples/django/slackapp/slackapp/views.py | 4 +- samples/docker/aiohttp/main.py | 4 +- samples/docker/fastapi-gunicorn/main.py | 4 +- samples/docker/flask-gunicorn/main.py | 4 +- samples/docker/flask-uwsgi/main.py | 4 +- samples/docker/sanic/main.py | 4 +- samples/events_app.py | 16 ++-- samples/falcon/app.py | 29 ++++---- samples/falcon/oauth_app.py | 28 +++---- samples/fastapi/app.py | 11 ++- samples/fastapi/async_app.py | 15 +++- samples/fastapi/async_oauth_app.py | 9 ++- samples/fastapi/oauth_app.py | 11 ++- samples/flask/app.py | 13 +++- samples/flask/oauth_app.py | 13 +++- samples/google_app_engine/flask/main.py | 6 +- samples/google_cloud_functions/main.py | 4 +- samples/google_cloud_run/aiohttp/main.py | 4 +- .../google_cloud_run/flask-gunicorn/main.py | 4 +- samples/google_cloud_run/sanic/main.py | 4 +- samples/lazy_async_modals_app.py | 16 ++-- samples/lazy_modals_app.py | 16 ++-- samples/message_events.py | 32 ++++---- samples/modals_app.py | 18 ++--- samples/modals_app_typed.py | 18 ++--- samples/oauth_app.py | 18 ++--- samples/oauth_sqlite3_app.py | 4 +- samples/pyramid/app.py | 4 +- samples/pyramid/oauth_app.py | 4 +- samples/readme_app.py | 12 +-- samples/readme_async_app.py | 12 +-- samples/sanic/async_app.py | 4 +- samples/sanic/async_oauth_app.py | 4 +- samples/starlette/app.py | 4 +- samples/starlette/async_app.py | 4 +- samples/starlette/async_oauth_app.py | 4 +- samples/starlette/oauth_app.py | 4 +- samples/steps_from_apps.py | 74 +++++++++---------- samples/tornado/app.py | 8 +- samples/tornado/oauth_app.py | 8 +- 54 files changed, 327 insertions(+), 310 deletions(-) diff --git a/samples/app.py b/samples/app.py index a7d051e6c..f8759c799 100644 --- a/samples/app.py +++ b/samples/app.py @@ -15,27 +15,27 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.command("/hello-bolt-python") -def hello_command(ack, payload): - user_id = payload["user_id"] +def hello_command(ack, body): + user_id = body["user_id"] ack(f"Hi <@{user_id}>!") @app.event("app_mention") -def event_test(payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") @app.error -def global_error_handler(error, payload, logger): +def global_error_handler(error, body, logger): logger.exception(error) - logger.info(payload) + logger.info(body) if __name__ == "__main__": diff --git a/samples/async_app.py b/samples/async_app.py index 07d25e802..c50416a2a 100644 --- a/samples/async_app.py +++ b/samples/async_app.py @@ -15,21 +15,21 @@ @app.middleware # or app.use(log_request) -async def log_request(logger, payload, next): - logger.debug(payload) +async def log_request(logger, body, next): + logger.debug(body) return await next() @app.event("app_mention") -async def event_test(payload, say, logger): - logger.info(payload) +async def event_test(body, say, logger): + logger.info(body) await say("What's up?") @app.command("/hello-bolt-python") # or app.command(re.compile(r"/hello-.+"))(test_command) -async def command(ack, payload): - user_id = payload["user_id"] +async def command(ack, body): + user_id = body["user_id"] await ack(f"Hi <@{user_id}>!") diff --git a/samples/async_steps_from_apps.py b/samples/async_steps_from_apps.py index d1c7b8dd8..3c65addf0 100644 --- a/samples/async_steps_from_apps.py +++ b/samples/async_steps_from_apps.py @@ -19,6 +19,7 @@ # https://api.slack.com/tutorials/workflow-builder-steps + @app.action({"type": "workflow_step_edit", "callback_id": "copy_review"}) async def edit(body: dict, ack: AsyncAck, client: AsyncWebClient): await ack() @@ -33,8 +34,8 @@ async def edit(body: dict, ack: AsyncAck, client: AsyncWebClient): "block_id": "intro-section", "text": { "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps." - } + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, }, { "type": "input", @@ -44,13 +45,10 @@ async def edit(body: dict, ack: AsyncAck, client: AsyncWebClient): "action_id": "task_name", "placeholder": { "type": "plain_text", - "text": "Write a task name" - } + "text": "Write a task name", + }, }, - "label": { - "type": "plain_text", - "text": "Task name" - } + "label": {"type": "plain_text", "text": "Task name"}, }, { "type": "input", @@ -60,13 +58,10 @@ async def edit(body: dict, ack: AsyncAck, client: AsyncWebClient): "action_id": "task_description", "placeholder": { "type": "plain_text", - "text": "Write a description for your task" - } + "text": "Write a description for your task", + }, }, - "label": { - "type": "plain_text", - "text": "Task description" - } + "label": {"type": "plain_text", "text": "Task description"}, }, { "type": "input", @@ -76,15 +71,12 @@ async def edit(body: dict, ack: AsyncAck, client: AsyncWebClient): "action_id": "task_author", "placeholder": { "type": "plain_text", - "text": "Write a task name" - } + "text": "Write a task name", + }, }, - "label": { - "type": "plain_text", - "text": "Task author" - } + "label": {"type": "plain_text", "text": "Task author"}, }, - ] + ], }, ) @@ -101,18 +93,16 @@ async def save(ack: AsyncAck, client: AsyncWebClient, body: dict): "value": state_values["task_name_input"]["task_name"]["value"], }, "taskDescription": { - "value": state_values["task_description_input"]["task_description"]["value"], + "value": state_values["task_description_input"]["task_description"][ + "value" + ], }, "taskAuthorEmail": { "value": state_values["task_author_input"]["task_author"]["value"], - } + }, }, "outputs": [ - { - "name": "taskName", - "type": "text", - "label": "Task Name", - }, + {"name": "taskName", "type": "text", "label": "Task Name",}, { "name": "taskDescription", "type": "text", @@ -123,8 +113,8 @@ async def save(ack: AsyncAck, client: AsyncWebClient, body: dict): "type": "text", "label": "Task Author Email", }, - ] - } + ], + }, ) await ack() @@ -143,10 +133,12 @@ async def execute(body: dict, client: AsyncWebClient): "taskName": step["inputs"]["taskName"]["value"], "taskDescription": step["inputs"]["taskDescription"]["value"], "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], - } - } + }, + }, + ) + user: AsyncSlackResponse = await client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] ) - user: AsyncSlackResponse = await client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) user_id = user["user"]["id"] new_task = { "task_name": step["inputs"]["taskName"]["value"], @@ -158,19 +150,21 @@ async def execute(body: dict, client: AsyncWebClient): blocks = [] for task in tasks: - blocks.append({"type": "section", "text": {"type": "plain_text", "text": task["task_name"]}}) + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) blocks.append({"type": "divider"}) home_tab_update: AsyncSlackResponse = await client.views_publish( user_id=user_id, view={ "type": "home", - "title": { - "type": 'plain_text', - "text": 'Your tasks!' - }, - "blocks": blocks - } + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, ) diff --git a/samples/aws_chalice/app.py b/samples/aws_chalice/app.py index 2f9fc378b..10b9be077 100644 --- a/samples/aws_chalice/app.py +++ b/samples/aws_chalice/app.py @@ -10,8 +10,8 @@ @bolt_app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up? I'm a Chalice app :wave:") diff --git a/samples/aws_chalice/oauth_app.py b/samples/aws_chalice/oauth_app.py index 56c943a13..075c7658d 100644 --- a/samples/aws_chalice/oauth_app.py +++ b/samples/aws_chalice/oauth_app.py @@ -18,8 +18,8 @@ @bolt_app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up? I'm a Chalice app :wave:") diff --git a/samples/aws_chalice/simple_app.py b/samples/aws_chalice/simple_app.py index 2f9fc378b..10b9be077 100644 --- a/samples/aws_chalice/simple_app.py +++ b/samples/aws_chalice/simple_app.py @@ -10,8 +10,8 @@ @bolt_app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up? I'm a Chalice app :wave:") diff --git a/samples/aws_lambda/aws_lambda.py b/samples/aws_lambda/aws_lambda.py index fdcc33ef4..e5ffc37f2 100644 --- a/samples/aws_lambda/aws_lambda.py +++ b/samples/aws_lambda/aws_lambda.py @@ -15,8 +15,8 @@ @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/aws_lambda/aws_lambda_oauth.py b/samples/aws_lambda/aws_lambda_oauth.py index 79414b40b..f7210dc85 100644 --- a/samples/aws_lambda/aws_lambda_oauth.py +++ b/samples/aws_lambda/aws_lambda_oauth.py @@ -16,8 +16,8 @@ @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/aws_lambda/lazy_aws_lambda.py b/samples/aws_lambda/lazy_aws_lambda.py index b72fa2943..c4bd25498 100644 --- a/samples/aws_lambda/lazy_aws_lambda.py +++ b/samples/aws_lambda/lazy_aws_lambda.py @@ -16,25 +16,25 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() command = "/hello-bolt-python-lambda" -def respond_to_slack_within_3_seconds(payload, ack): - if payload.get("text", None) is None: +def respond_to_slack_within_3_seconds(body, ack): + if body.get("text", None) is None: ack(f":x: Usage: {command} (description here)") else: - title = payload["text"] + title = body["text"] ack(f"Accepted! (task: {title})") -def process_request(respond, payload): +def process_request(respond, body): time.sleep(5) - title = payload["text"] + title = body["text"] respond(f"Completed! (task: {title})") diff --git a/samples/bottle/app.py b/samples/bottle/app.py index 182266fd8..20959217a 100644 --- a/samples/bottle/app.py +++ b/samples/bottle/app.py @@ -16,14 +16,14 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(ack, body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/bottle/oauth_app.py b/samples/bottle/oauth_app.py index ce5f7dbd9..63d2c5db4 100644 --- a/samples/bottle/oauth_app.py +++ b/samples/bottle/oauth_app.py @@ -14,14 +14,14 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/cherrypy/app.py b/samples/cherrypy/app.py index 863e54f49..42b335b1f 100644 --- a/samples/cherrypy/app.py +++ b/samples/cherrypy/app.py @@ -16,8 +16,8 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @@ -27,8 +27,8 @@ def hello_command(ack): @app.event("app_mention") -def event_test(payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/cherrypy/oauth_app.py b/samples/cherrypy/oauth_app.py index af293e683..180f9b43e 100644 --- a/samples/cherrypy/oauth_app.py +++ b/samples/cherrypy/oauth_app.py @@ -14,8 +14,8 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @@ -25,8 +25,8 @@ def hello_command(ack): @app.event("app_mention") -def event_test(payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/dialogs_app.py b/samples/dialogs_app.py index ce9462fec..902788edd 100644 --- a/samples/dialogs_app.py +++ b/samples/dialogs_app.py @@ -15,17 +15,17 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.command("/hello-bolt-python") -def test_command(payload, client, ack, logger): - logger.info(payload) +def test_command(body, client, ack, logger): + logger.info(body) ack("I got it!") res = client.dialog_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], dialog={ "callback_id": "dialog-callback-id", "title": "Request a Ride", diff --git a/samples/django/slackapp/slackapp/views.py b/samples/django/slackapp/slackapp/views.py index 1ffabcddb..4c0d74417 100644 --- a/samples/django/slackapp/slackapp/views.py +++ b/samples/django/slackapp/slackapp/views.py @@ -8,8 +8,8 @@ @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(ack, body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/docker/aiohttp/main.py b/samples/docker/aiohttp/main.py index ebd1d1f1d..f55a333b8 100644 --- a/samples/docker/aiohttp/main.py +++ b/samples/docker/aiohttp/main.py @@ -8,8 +8,8 @@ @app.command("/hello-bolt-python") -async def hello(payload, ack): - user_id = payload["user_id"] +async def hello(body, ack): + user_id = body["user_id"] await ack(f"Hi <@{user_id}>!") diff --git a/samples/docker/fastapi-gunicorn/main.py b/samples/docker/fastapi-gunicorn/main.py index dc3a1d28d..b118a6f1a 100644 --- a/samples/docker/fastapi-gunicorn/main.py +++ b/samples/docker/fastapi-gunicorn/main.py @@ -7,8 +7,8 @@ @app.command("/hello-bolt-python") -async def hello(payload, ack): - user_id = payload["user_id"] +async def hello(body, ack): + user_id = body["user_id"] await ack(f"Hi <@{user_id}>!") diff --git a/samples/docker/flask-gunicorn/main.py b/samples/docker/flask-gunicorn/main.py index 1fdad9545..6eecad39b 100644 --- a/samples/docker/flask-gunicorn/main.py +++ b/samples/docker/flask-gunicorn/main.py @@ -7,8 +7,8 @@ @app.command("/hello-bolt-python") -def hello(payload, ack): - user_id = payload["user_id"] +def hello(body, ack): + user_id = body["user_id"] ack(f"Hi <@{user_id}>!") diff --git a/samples/docker/flask-uwsgi/main.py b/samples/docker/flask-uwsgi/main.py index 1fdad9545..6eecad39b 100644 --- a/samples/docker/flask-uwsgi/main.py +++ b/samples/docker/flask-uwsgi/main.py @@ -7,8 +7,8 @@ @app.command("/hello-bolt-python") -def hello(payload, ack): - user_id = payload["user_id"] +def hello(body, ack): + user_id = body["user_id"] ack(f"Hi <@{user_id}>!") diff --git a/samples/docker/sanic/main.py b/samples/docker/sanic/main.py index 92da3239c..04203c8a5 100644 --- a/samples/docker/sanic/main.py +++ b/samples/docker/sanic/main.py @@ -10,8 +10,8 @@ @app.command("/hello-bolt-python") -async def hello(payload, ack): - user_id = payload["user_id"] +async def hello(body, ack): + user_id = body["user_id"] await ack(f"Hi <@{user_id}>!") diff --git a/samples/events_app.py b/samples/events_app.py index dc26e4fa2..9588bcea8 100644 --- a/samples/events_app.py +++ b/samples/events_app.py @@ -16,25 +16,25 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.event("app_mention") -def event_test(payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") @app.message("test") -def test_message(logger, payload): - logger.info(payload) +def test_message(logger, body): + logger.info(body) @app.message(re.compile("bug")) -def mention_bug(logger, payload): - logger.info(payload) +def mention_bug(logger, body): + logger.info(body) if __name__ == "__main__": diff --git a/samples/falcon/app.py b/samples/falcon/app.py index 59e2764ed..40e32e93d 100644 --- a/samples/falcon/app.py +++ b/samples/falcon/app.py @@ -16,9 +16,9 @@ app = App() -# @app.command("/bolt-py-proto", [lambda payload: payload["team_id"] == "T03E94MJU"]) -def test_command(logger: logging.Logger, payload: dict, ack: Ack, respond: Respond): - logger.info(payload) +# @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"]) +def test_command(logger: logging.Logger, body: dict, ack: Ack, respond: Respond): + logger.info(body) ack("thanks!") respond( blocks=[ @@ -40,15 +40,15 @@ def test_command(logger: logging.Logger, payload: dict, ack: Ack, respond: Respo ) -app.command(re.compile(r"/bolt-.+"))(test_command) +app.command(re.compile(r"/hello-bolt-.+"))(test_command) @app.shortcut("test-shortcut") -def test_shortcut(ack, client: WebClient, logger, payload): - logger.info(payload) +def test_shortcut(ack, client: WebClient, logger, body): + logger.info(body) ack() res = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", @@ -68,22 +68,21 @@ def test_shortcut(ack, client: WebClient, logger, payload): @app.view("view-id") -def view_submission(ack, payload, logger): - logger.info(payload) +def view_submission(ack, body, logger): + logger.info(body) ack() @app.action("a") -def button_click(logger, payload, say, ack, respond): - logger.info(payload) +def button_click(logger, action, ack, respond): + logger.info(action) ack() - respond("respond!") - # say(text="say!") + respond("Here is my response") @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/falcon/oauth_app.py b/samples/falcon/oauth_app.py index 675711eef..14208954d 100644 --- a/samples/falcon/oauth_app.py +++ b/samples/falcon/oauth_app.py @@ -16,9 +16,9 @@ app = App() -# @app.command("/bolt-py-proto", [lambda payload: payload["team_id"] == "T03E94MJU"]) -def test_command(logger: logging.Logger, payload: dict, ack: Ack, respond: Respond): - logger.info(payload) +# @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"]) +def test_command(logger: logging.Logger, body: dict, ack: Ack, respond: Respond): + logger.info(body) ack("thanks!") respond( blocks=[ @@ -40,15 +40,15 @@ def test_command(logger: logging.Logger, payload: dict, ack: Ack, respond: Respo ) -app.command(re.compile(r"/bolt-.+"))(test_command) +app.command(re.compile(r"/hello-bolt-.+"))(test_command) @app.shortcut("test-shortcut") -def test_shortcut(ack, client: WebClient, logger, payload): - logger.info(payload) +def test_shortcut(ack, client: WebClient, logger, body): + logger.info(body) ack() res = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", @@ -68,22 +68,22 @@ def test_shortcut(ack, client: WebClient, logger, payload): @app.view("view-id") -def view_submission(ack, payload, logger): - logger.info(payload) +def view_submission(ack, body, logger): + logger.info(body) ack() @app.action("a") -def button_click(logger, payload, say, ack, respond): - logger.info(payload) +def button_click(logger, action, ack, respond): + logger.info(action) ack() - respond("respond!") + respond("Here is my response.") # say(text="say!") @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/fastapi/app.py b/samples/fastapi/app.py index a196d9226..f1b4c7782 100644 --- a/samples/fastapi/app.py +++ b/samples/fastapi/app.py @@ -13,11 +13,16 @@ @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") +@app.event("message") +def handle_message(): + pass + + from fastapi import FastAPI, Request api = FastAPI() @@ -31,4 +36,4 @@ async def endpoint(req: Request): # pip install -r requirements.txt # export SLACK_SIGNING_SECRET=*** # export SLACK_BOT_TOKEN=xoxb-*** -# uvicorn app:api --reload --port 3000 --log-level debug +# uvicorn app:api --reload --port 3000 --log-level warning diff --git a/samples/fastapi/async_app.py b/samples/fastapi/async_app.py index 724c05e7f..5f2a77625 100644 --- a/samples/fastapi/async_app.py +++ b/samples/fastapi/async_app.py @@ -5,6 +5,10 @@ sys.path.insert(1, "../..") # ------------------------------------------------ +import logging + +logging.basicConfig(level=logging.DEBUG) + from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler @@ -13,11 +17,16 @@ @app.event("app_mention") -async def handle_app_mentions(payload, say, logger): - logger.info(payload) +async def handle_app_mentions(body, say, logger): + logger.info(body) await say("What's up?") +@app.event("message") +async def handle_message(): + pass + + from fastapi import FastAPI, Request api = FastAPI() @@ -31,4 +40,4 @@ async def endpoint(req: Request): # pip install -r requirements.txt # export SLACK_SIGNING_SECRET=*** # export SLACK_BOT_TOKEN=xoxb-*** -# uvicorn async_app:api --reload --port 3000 --log-level debug +# uvicorn async_app:api --reload --port 3000 --log-level warning diff --git a/samples/fastapi/async_oauth_app.py b/samples/fastapi/async_oauth_app.py index 59d28e3a4..c8463aadd 100644 --- a/samples/fastapi/async_oauth_app.py +++ b/samples/fastapi/async_oauth_app.py @@ -13,11 +13,16 @@ @app.event("app_mention") -async def handle_app_mentions(payload, say, logger): - logger.info(payload) +async def handle_app_mentions(body, say, logger): + logger.info(body) await say("What's up?") +@app.event("message") +async def handle_message(): + pass + + from fastapi import FastAPI, Request api = FastAPI() diff --git a/samples/fastapi/oauth_app.py b/samples/fastapi/oauth_app.py index 9075a2be5..1b58fc165 100644 --- a/samples/fastapi/oauth_app.py +++ b/samples/fastapi/oauth_app.py @@ -13,11 +13,16 @@ @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") +@app.event("message") +async def handle_message(): + pass + + from fastapi import FastAPI, Request api = FastAPI() @@ -46,4 +51,4 @@ async def oauth_redirect(req: Request): # export SLACK_CLIENT_SECRET=*** # export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write -# uvicorn oauth_app:api --reload --port 3000 --log-level debug +# uvicorn oauth_app:api --reload --port 3000 --log-level warning diff --git a/samples/flask/app.py b/samples/flask/app.py index fd8e62d9d..ce239280d 100644 --- a/samples/flask/app.py +++ b/samples/flask/app.py @@ -16,17 +16,22 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") +@app.event("message") +def handle_message(): + pass + + from flask import Flask, request flask_app = Flask(__name__) diff --git a/samples/flask/oauth_app.py b/samples/flask/oauth_app.py index 962f85009..c20ef5e67 100644 --- a/samples/flask/oauth_app.py +++ b/samples/flask/oauth_app.py @@ -14,17 +14,22 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") +@app.event("message") +def handle_message(): + pass + + from flask import Flask, request flask_app = Flask(__name__) diff --git a/samples/google_app_engine/flask/main.py b/samples/google_app_engine/flask/main.py index 2e52c0577..6ed295aac 100644 --- a/samples/google_app_engine/flask/main.py +++ b/samples/google_app_engine/flask/main.py @@ -8,8 +8,8 @@ @bolt_app.command("/hey-google-app-engine") -def hello(payload, ack): - user_id = payload["user_id"] +def hello(body, ack): + user_id = body["user_id"] ack(f"Hi <@{user_id}>!") @@ -20,7 +20,7 @@ def hello(payload, ack): handler = SlackRequestHandler(bolt_app) -@app.route('/_ah/warmup') +@app.route("/_ah/warmup") def warmup(): # Handle your warmup logic here, e.g. set up a database connection pool return "", 200, {} diff --git a/samples/google_cloud_functions/main.py b/samples/google_cloud_functions/main.py index edea87604..633efb67b 100644 --- a/samples/google_cloud_functions/main.py +++ b/samples/google_cloud_functions/main.py @@ -16,8 +16,8 @@ def hello_command(ack): @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("Hi from Google Cloud Functions!") diff --git a/samples/google_cloud_run/aiohttp/main.py b/samples/google_cloud_run/aiohttp/main.py index 5602d4754..41023c15e 100644 --- a/samples/google_cloud_run/aiohttp/main.py +++ b/samples/google_cloud_run/aiohttp/main.py @@ -8,8 +8,8 @@ @app.command("/hey-google") -async def hello(payload, ack): - user_id = payload["user_id"] +async def hello(body, ack): + user_id = body["user_id"] await ack(f"Hi <@{user_id}>!") diff --git a/samples/google_cloud_run/flask-gunicorn/main.py b/samples/google_cloud_run/flask-gunicorn/main.py index 552e4e877..70bf437cb 100644 --- a/samples/google_cloud_run/flask-gunicorn/main.py +++ b/samples/google_cloud_run/flask-gunicorn/main.py @@ -8,8 +8,8 @@ @app.command("/hey-google") -def hello(payload, ack): - user_id = payload["user_id"] +def hello(body, ack): + user_id = body["user_id"] ack(f"Hi <@{user_id}>!") diff --git a/samples/google_cloud_run/sanic/main.py b/samples/google_cloud_run/sanic/main.py index 47afb27eb..269cd8a04 100644 --- a/samples/google_cloud_run/sanic/main.py +++ b/samples/google_cloud_run/sanic/main.py @@ -10,8 +10,8 @@ @app.command("/hey-google") -async def hello(payload, ack): - user_id = payload["user_id"] +async def hello(body, ack): + user_id = body["user_id"] await ack(f"Hi <@{user_id}>!") diff --git a/samples/lazy_async_modals_app.py b/samples/lazy_async_modals_app.py index 45dcf2051..a566cdf56 100644 --- a/samples/lazy_async_modals_app.py +++ b/samples/lazy_async_modals_app.py @@ -15,13 +15,13 @@ @app.middleware # or app.use(log_request) -async def log_request(logger, payload, next): - logger.debug(payload) +async def log_request(logger, body, next): + logger.debug(body) return await next() -async def ack_command(payload, ack, logger): - logger.info(payload) +async def ack_command(body, ack, logger): + logger.info(body) await ack("Thanks!") @@ -46,9 +46,9 @@ async def post_button_message(respond): ) -async def open_modal(payload, client, logger): +async def open_modal(body, client, logger): res = await client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", @@ -134,9 +134,9 @@ async def show_multi_options(ack): @app.view("view-id") -async def handle_view_submission(ack, payload, logger): +async def handle_view_submission(ack, body, logger): await ack() - logger.info(payload["view"]["state"]["values"]) + logger.info(body["view"]["state"]["values"]) async def ack_button_click(ack, respond): diff --git a/samples/lazy_modals_app.py b/samples/lazy_modals_app.py index 7f279a073..4de8c7778 100644 --- a/samples/lazy_modals_app.py +++ b/samples/lazy_modals_app.py @@ -16,13 +16,13 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() -def ack_command(payload, ack, logger): - logger.info(payload) +def ack_command(body, ack, logger): + logger.info(body) ack("Thanks!") @@ -47,9 +47,9 @@ def post_button_message(respond): ) -def open_modal(payload, client, logger): +def open_modal(body, client, logger): res = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", @@ -135,9 +135,9 @@ def show_multi_options(ack): @app.view("view-id") -def handle_view_submission(ack, payload, logger): +def handle_view_submission(ack, body, logger): ack() - logger.info(payload["view"]["state"]["values"]) + logger.info(body["view"]["state"]["values"]) def ack_button_click(ack, respond): diff --git a/samples/message_events.py b/samples/message_events.py index faedf9bae..e732fd3f6 100644 --- a/samples/message_events.py +++ b/samples/message_events.py @@ -18,8 +18,8 @@ @app.middleware -def log_request(logger: logging.Logger, payload: dict, next: Callable): - logger.debug(payload) +def log_request(logger: logging.Logger, body: dict, next: Callable): + logger.debug(body) return next() @@ -34,8 +34,8 @@ def mention_bug(say): # middleware function -def extract_subtype(payload: dict, context: BoltContext, next: Callable): - context["subtype"] = payload.get("event", {}).get("subtype", None) +def extract_subtype(body: dict, context: BoltContext, next: Callable): + context["subtype"] = body.get("event", {}).get("subtype", None) next() @@ -43,8 +43,8 @@ def extract_subtype(payload: dict, context: BoltContext, next: Callable): # Newly posted messages only # or @app.event("message") @app.event({"type": "message", "subtype": None}) -def reply_in_thread(payload: dict, say: Say): - event = payload["event"] +def reply_in_thread(body: dict, say: Say): + event = body["event"] thread_ts = event.get("thread_ts", None) or event["ts"] say(text="Hey, what's up?", thread_ts=thread_ts) @@ -53,11 +53,12 @@ def reply_in_thread(payload: dict, say: Say): event={"type": "message", "subtype": "message_deleted"}, matchers=[ # Skip the deletion of messages by this listener - lambda payload: "You've deleted a message: " not in payload["event"]["previous_message"]["text"] - ] + lambda body: "You've deleted a message: " + not in body["event"]["previous_message"]["text"] + ], ) -def detect_deletion(say: Say, payload: dict): - text = payload["event"]["previous_message"]["text"] +def detect_deletion(say: Say, body: dict): + text = body["event"]["previous_message"]["text"] say(f"You've deleted a message: {text}") @@ -68,18 +69,13 @@ def detect_deletion(say: Say, payload: dict): middleware=[extract_subtype], ) def add_reaction( - payload: dict, - client: WebClient, - context: BoltContext, - logger: logging.Logger + body: dict, client: WebClient, context: BoltContext, logger: logging.Logger ): subtype = context["subtype"] # by extract_subtype logger.info(f"subtype: {subtype}") - message_ts = payload["event"]["ts"] + message_ts = body["event"]["ts"] api_response = client.reactions_add( - channel=context.channel_id, - timestamp=message_ts, - name="eyes", + channel=context.channel_id, timestamp=message_ts, name="eyes", ) logger.info(f"api_response: {api_response}") diff --git a/samples/modals_app.py b/samples/modals_app.py index 6a535273b..7859028ff 100644 --- a/samples/modals_app.py +++ b/samples/modals_app.py @@ -15,14 +15,14 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.command("/hello-bolt-python") -def handle_command(payload, ack, respond, client, logger): - logger.info(payload) +def handle_command(body, ack, respond, client, logger): + logger.info(body) ack( text="Accepted!", blocks=[ @@ -54,7 +54,7 @@ def handle_command(payload, ack, respond, client, logger): ) res = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", @@ -135,16 +135,16 @@ def show_multi_options(ack): @app.view("view-id") -def view_submission(ack, payload, logger): +def view_submission(ack, body, logger): ack() - logger.info(payload["view"]["state"]["values"]) + logger.info(body["view"]["state"]["values"]) @app.action("a") -def button_click(ack, payload, respond): +def button_click(ack, body, respond): ack() - user_id = payload["user"]["id"] + user_id = body["user"]["id"] # in_channel / dict respond( { diff --git a/samples/modals_app_typed.py b/samples/modals_app_typed.py index a0e2da54f..62821bb23 100644 --- a/samples/modals_app_typed.py +++ b/samples/modals_app_typed.py @@ -31,17 +31,17 @@ @app.middleware # or app.use(log_request) def log_request( - logger: Logger, payload: dict, next: Callable[[], BoltResponse] + logger: Logger, body: dict, next: Callable[[], BoltResponse] ) -> BoltResponse: - logger.debug(payload) + logger.debug(body) return next() @app.command("/hello-bolt-python") def handle_command( - payload: dict, ack: Ack, respond: Respond, client: WebClient, logger: Logger + body: dict, ack: Ack, respond: Respond, client: WebClient, logger: Logger ) -> None: - logger.info(payload) + logger.info(body) ack( text="Accepted!", blocks=[ @@ -69,7 +69,7 @@ def handle_command( ) res = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view=View( type="modal", callback_id="view-id", @@ -130,16 +130,16 @@ def show_multi_options(ack: Ack) -> None: @app.view("view-id") -def view_submission(ack: Ack, payload: dict, logger: Logger) -> None: +def view_submission(ack: Ack, body: dict, logger: Logger) -> None: ack() - logger.info(payload["view"]["state"]["values"]) + logger.info(body["view"]["state"]["values"]) @app.action("a") -def button_click(ack: Ack, payload: dict, respond: Respond) -> None: +def button_click(ack: Ack, body: dict, respond: Respond) -> None: ack() - user_id: str = payload["user"]["id"] + user_id: str = body["user"]["id"] # in_channel / dict respond( response_type="in_channel", diff --git a/samples/oauth_app.py b/samples/oauth_app.py index 120c689c3..d4c6b694a 100644 --- a/samples/oauth_app.py +++ b/samples/oauth_app.py @@ -13,15 +13,15 @@ @app.event("app_mention") -def event_test(payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") @app.command("/hello-bolt-python") # or app.command(re.compile(r"/hello-.+"))(test_command) -def test_command(payload, respond, client, ack, logger): - logger.info(payload) +def test_command(body, respond, client, ack, logger): + logger.info(body) ack("Thanks!") respond( @@ -44,7 +44,7 @@ def test_command(payload, respond, client, ack, logger): ) res = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", @@ -64,14 +64,14 @@ def test_command(payload, respond, client, ack, logger): @app.view("view-id") -def view_submission(ack, payload, logger): - logger.info(payload) +def view_submission(ack, body, logger): + logger.info(body) return ack() @app.action("a") -def button_click(logger, payload, ack, respond): - logger.info(payload) +def button_click(logger, body, ack, respond): + logger.info(body) respond("respond!") ack() diff --git a/samples/oauth_sqlite3_app.py b/samples/oauth_sqlite3_app.py index 1cda11d94..a62b4f595 100644 --- a/samples/oauth_sqlite3_app.py +++ b/samples/oauth_sqlite3_app.py @@ -16,8 +16,8 @@ @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/pyramid/app.py b/samples/pyramid/app.py index 4c37a3544..f3befa086 100644 --- a/samples/pyramid/app.py +++ b/samples/pyramid/app.py @@ -15,8 +15,8 @@ @app.event("app_mention") -def event_test(payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/pyramid/oauth_app.py b/samples/pyramid/oauth_app.py index 3306c4d61..e229c85b6 100644 --- a/samples/pyramid/oauth_app.py +++ b/samples/pyramid/oauth_app.py @@ -15,8 +15,8 @@ @app.event("app_mention") -def event_test(payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/readme_app.py b/samples/readme_app.py index b49587ecb..f6547583f 100644 --- a/samples/readme_app.py +++ b/samples/readme_app.py @@ -11,8 +11,8 @@ # Middleware @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.info(payload) +def log_request(logger, body, next): + logger.info(body) return next() @@ -25,12 +25,12 @@ def event_test(say): # Interactivity: https://api.slack.com/interactivity @app.shortcut("callback-id-here") # @app.command("/hello-bolt-python") -def open_modal(ack, client, logger, payload): +def open_modal(ack, client, logger, body): # acknowledge the incoming request from Slack immediately ack() # open a modal api_response = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", @@ -50,10 +50,10 @@ def open_modal(ack, client, logger, payload): @app.view("view-id") -def view_submission(ack, payload, logger): +def view_submission(ack, body, logger): ack() # Prints {'b': {'a': {'type': 'plain_text_input', 'value': 'Your Input'}}} - logger.info(payload["view"]["state"]["values"]) + logger.info(body["view"]["state"]["values"]) if __name__ == "__main__": diff --git a/samples/readme_async_app.py b/samples/readme_async_app.py index e1927d69c..a50b850ee 100644 --- a/samples/readme_async_app.py +++ b/samples/readme_async_app.py @@ -12,8 +12,8 @@ # Middleware @app.middleware # or app.use(log_request) -async def log_request(logger, payload, next): - logger.info(payload) +async def log_request(logger, body, next): + logger.info(body) return await next() @@ -26,12 +26,12 @@ async def event_test(say): # Interactivity: https://api.slack.com/interactivity @app.shortcut("callback-id-here") # @app.command("/hello-bolt-python") -async def open_modal(ack, client, logger, payload): +async def open_modal(ack, client, logger, body): # acknowledge the incoming request from Slack immediately await ack() # open a modal api_response = await client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", @@ -51,10 +51,10 @@ async def open_modal(ack, client, logger, payload): @app.view("view-id") -async def view_submission(ack, payload, logger): +async def view_submission(ack, body, logger): await ack() # Prints {'b': {'a': {'type': 'plain_text_input', 'value': 'Your Input'}}} - logger.info(payload["view"]["state"]["values"]) + logger.info(body["view"]["state"]["values"]) if __name__ == "__main__": diff --git a/samples/sanic/async_app.py b/samples/sanic/async_app.py index 8797e4358..6a4e8c19c 100644 --- a/samples/sanic/async_app.py +++ b/samples/sanic/async_app.py @@ -14,8 +14,8 @@ @app.event("app_mention") -async def handle_app_mentions(payload, say, logger): - logger.info(payload) +async def handle_app_mentions(body, say, logger): + logger.info(body) await say("What's up?") diff --git a/samples/sanic/async_oauth_app.py b/samples/sanic/async_oauth_app.py index b332fe438..cbadb8cab 100644 --- a/samples/sanic/async_oauth_app.py +++ b/samples/sanic/async_oauth_app.py @@ -14,8 +14,8 @@ @app.event("app_mention") -async def handle_app_mentions(payload, say, logger): - logger.info(payload) +async def handle_app_mentions(body, say, logger): + logger.info(body) await say("What's up?") diff --git a/samples/starlette/app.py b/samples/starlette/app.py index 8b70c38dd..14842c691 100644 --- a/samples/starlette/app.py +++ b/samples/starlette/app.py @@ -12,8 +12,8 @@ @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/starlette/async_app.py b/samples/starlette/async_app.py index e6df9cc45..61fc2ee39 100644 --- a/samples/starlette/async_app.py +++ b/samples/starlette/async_app.py @@ -12,8 +12,8 @@ @app.event("app_mention") -async def handle_app_mentions(payload, say, logger): - logger.info(payload) +async def handle_app_mentions(body, say, logger): + logger.info(body) await say("What's up?") diff --git a/samples/starlette/async_oauth_app.py b/samples/starlette/async_oauth_app.py index 90d37a1e4..433b2c3e5 100644 --- a/samples/starlette/async_oauth_app.py +++ b/samples/starlette/async_oauth_app.py @@ -13,8 +13,8 @@ @app.event("app_mention") -async def handle_app_mentions(payload, say, logger): - logger.info(payload) +async def handle_app_mentions(body, say, logger): + logger.info(body) await say("What's up?") diff --git a/samples/starlette/oauth_app.py b/samples/starlette/oauth_app.py index b024c69a3..64791adb3 100644 --- a/samples/starlette/oauth_app.py +++ b/samples/starlette/oauth_app.py @@ -13,8 +13,8 @@ @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/steps_from_apps.py b/samples/steps_from_apps.py index 15cc3e2a9..9f8889cbc 100644 --- a/samples/steps_from_apps.py +++ b/samples/steps_from_apps.py @@ -21,6 +21,7 @@ # https://api.slack.com/tutorials/workflow-builder-steps + @app.action({"type": "workflow_step_edit", "callback_id": "copy_review"}) def edit(body: dict, ack: Ack, client: WebClient): ack() @@ -35,8 +36,8 @@ def edit(body: dict, ack: Ack, client: WebClient): "block_id": "intro-section", "text": { "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps." - } + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, }, { "type": "input", @@ -46,13 +47,10 @@ def edit(body: dict, ack: Ack, client: WebClient): "action_id": "task_name", "placeholder": { "type": "plain_text", - "text": "Write a task name" - } + "text": "Write a task name", + }, }, - "label": { - "type": "plain_text", - "text": "Task name" - } + "label": {"type": "plain_text", "text": "Task name"}, }, { "type": "input", @@ -62,13 +60,10 @@ def edit(body: dict, ack: Ack, client: WebClient): "action_id": "task_description", "placeholder": { "type": "plain_text", - "text": "Write a description for your task" - } + "text": "Write a description for your task", + }, }, - "label": { - "type": "plain_text", - "text": "Task description" - } + "label": {"type": "plain_text", "text": "Task description"}, }, { "type": "input", @@ -78,15 +73,12 @@ def edit(body: dict, ack: Ack, client: WebClient): "action_id": "task_author", "placeholder": { "type": "plain_text", - "text": "Write a task name" - } + "text": "Write a task name", + }, }, - "label": { - "type": "plain_text", - "text": "Task author" - } + "label": {"type": "plain_text", "text": "Task author"}, }, - ] + ], }, ) @@ -103,18 +95,16 @@ def save(ack: Ack, client: WebClient, body: dict): "value": state_values["task_name_input"]["task_name"]["value"], }, "taskDescription": { - "value": state_values["task_description_input"]["task_description"]["value"], + "value": state_values["task_description_input"]["task_description"][ + "value" + ], }, "taskAuthorEmail": { "value": state_values["task_author_input"]["task_author"]["value"], - } + }, }, "outputs": [ - { - "name": "taskName", - "type": "text", - "label": "Task Name", - }, + {"name": "taskName", "type": "text", "label": "Task Name",}, { "name": "taskDescription", "type": "text", @@ -125,8 +115,8 @@ def save(ack: Ack, client: WebClient, body: dict): "type": "text", "label": "Task Author Email", }, - ] - } + ], + }, ) ack() @@ -145,10 +135,12 @@ def execute(body: dict, client: WebClient): "taskName": step["inputs"]["taskName"]["value"], "taskDescription": step["inputs"]["taskDescription"]["value"], "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], - } - } + }, + }, + ) + user: SlackResponse = client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] ) - user: SlackResponse = client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) user_id = user["user"]["id"] new_task = { "task_name": step["inputs"]["taskName"]["value"], @@ -160,19 +152,21 @@ def execute(body: dict, client: WebClient): blocks = [] for task in tasks: - blocks.append({"type": "section", "text": {"type": "plain_text", "text": task["task_name"]}}) + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) blocks.append({"type": "divider"}) home_tab_update: SlackResponse = client.views_publish( user_id=user_id, view={ "type": "home", - "title": { - "type": 'plain_text', - "text": 'Your tasks!' - }, - "blocks": blocks - } + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, ) diff --git a/samples/tornado/app.py b/samples/tornado/app.py index 4dcaf4d1d..a73eeb7ff 100644 --- a/samples/tornado/app.py +++ b/samples/tornado/app.py @@ -16,14 +16,14 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/tornado/oauth_app.py b/samples/tornado/oauth_app.py index 997146258..2ae762eb1 100644 --- a/samples/tornado/oauth_app.py +++ b/samples/tornado/oauth_app.py @@ -14,14 +14,14 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(ack, body, say, logger): + logger.info(body) say("What's up?") From 9509d08d7d0d9780607d007edbda5c43ab4660ca Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 4 Sep 2020 20:37:04 +0900 Subject: [PATCH 032/865] Add Flask app sample that runs on Heroku --- samples/heroku/Procfile | 1 + samples/heroku/main.py | 34 +++++++++++++++++++++++++++++++++ samples/heroku/requirements.txt | 3 +++ 3 files changed, 38 insertions(+) create mode 100644 samples/heroku/Procfile create mode 100644 samples/heroku/main.py create mode 100644 samples/heroku/requirements.txt diff --git a/samples/heroku/Procfile b/samples/heroku/Procfile new file mode 100644 index 000000000..66be3ed8b --- /dev/null +++ b/samples/heroku/Procfile @@ -0,0 +1 @@ +web: gunicorn --bind :$PORT --workers 1 --threads 2 --timeout 0 main:flask_app \ No newline at end of file diff --git a/samples/heroku/main.py b/samples/heroku/main.py new file mode 100644 index 000000000..6756684da --- /dev/null +++ b/samples/heroku/main.py @@ -0,0 +1,34 @@ +import logging + +from slack_bolt import App + +logging.basicConfig(level=logging.DEBUG) +app = App() + + +@app.command("/hello-bolt-python-heroku") +def hello(payload, ack): + user_id = payload["user_id"] + ack(f"Hi <@{user_id}>!") + + +from flask import Flask, request +from slack_bolt.adapter.flask import SlackRequestHandler + +flask_app = Flask(__name__) +handler = SlackRequestHandler(app) + + +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + return handler.handle(request) + +# heroku login +# heroku create +# git remote add heroku https://git.heroku.com/xxx.git + +# export $SLACK_BOT_TOKEN=xxx +# export SLACK_SIGNING_SECRET=xxx +# heroku config:set SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN +# heroku config:set SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET +# git push heroku main diff --git a/samples/heroku/requirements.txt b/samples/heroku/requirements.txt new file mode 100644 index 000000000..24cc3e223 --- /dev/null +++ b/samples/heroku/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +Flask>=1.1 +gunicorn>=20 \ No newline at end of file From 2bb1bd2e4e64aa05b74e6711870675ad971a38b8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2020 07:13:31 +0900 Subject: [PATCH 033/865] Update samples/heroku/main.py Co-authored-by: Michael Brooks --- samples/heroku/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/heroku/main.py b/samples/heroku/main.py index 6756684da..5a1ec053a 100644 --- a/samples/heroku/main.py +++ b/samples/heroku/main.py @@ -27,7 +27,7 @@ def slack_events(): # heroku create # git remote add heroku https://git.heroku.com/xxx.git -# export $SLACK_BOT_TOKEN=xxx +# export SLACK_BOT_TOKEN=xxx # export SLACK_SIGNING_SECRET=xxx # heroku config:set SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN # heroku config:set SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET From 2ebb9a6bb584c60e27f6cf9f241ae747b1f4ebae Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2020 07:16:33 +0900 Subject: [PATCH 034/865] Update samples/heroku/main.py --- samples/heroku/main.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/samples/heroku/main.py b/samples/heroku/main.py index 5a1ec053a..91a123ab5 100644 --- a/samples/heroku/main.py +++ b/samples/heroku/main.py @@ -7,8 +7,8 @@ @app.command("/hello-bolt-python-heroku") -def hello(payload, ack): - user_id = payload["user_id"] +def hello(body, ack): + user_id = body["user_id"] ack(f"Hi <@{user_id}>!") @@ -23,6 +23,7 @@ def hello(payload, ack): def slack_events(): return handler.handle(request) + # heroku login # heroku create # git remote add heroku https://git.heroku.com/xxx.git @@ -31,4 +32,7 @@ def slack_events(): # export SLACK_SIGNING_SECRET=xxx # heroku config:set SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN # heroku config:set SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET +# git checkout -b main +# git add . +# git commit -m'Initial commit for my awesome Slack app' # git push heroku main From 3534cc013af67b4bca9a359b84c21ca9252d3712 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2020 10:58:32 +0900 Subject: [PATCH 035/865] Update readme --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2894610f6..e3271f66d 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ app = App() # Middleware @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.info(payload) +def log_request(logger, body, next): + logger.info(body) return next() # Events API: https://api.slack.com/events-api @@ -48,12 +48,12 @@ def event_test(say): # Interactivity: https://api.slack.com/interactivity @app.shortcut("callback-id-here") # @app.command("/hello-bolt-python") -def open_modal(ack, client, logger, payload): +def open_modal(ack, client, logger, body): # acknowledge the incoming request from Slack immediately ack() # open a modal api_response = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", @@ -83,10 +83,10 @@ def open_modal(ack, client, logger, payload): logger.debug(api_response) @app.view("view-id") -def view_submission(ack, payload, logger): +def view_submission(ack, body, logger): ack() # Prints {'b': {'a': {'type': 'plain_text_input', 'value': 'Your Input'}}} - logger.info(payload["view"]["state"]["values"]) + logger.info(body["view"]["state"]["values"]) if __name__ == "__main__": app.start(3000) # POST http://localhost:3000/slack/events From f1d70bdb1e5eec05fd7de668f8e499cc5e906e73 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Sep 2020 11:00:28 +0900 Subject: [PATCH 036/865] version 0.4.0a0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 09e7285bc..6d9370c41 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.3.2a0" +__version__ = "0.4.0a0" From 059111b9157f17dfada20a0880f9a4a43111a960 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 9 Sep 2020 15:12:59 +0900 Subject: [PATCH 037/865] Add test coverage report in CI builds --- .travis.yml | 3 ++- setup.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 564e64c18..1e854ed0c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ install: - pip install -U pip # https://discuss.python.org/t/announcement-pip-20-2-release/4863 - pip config set global.use-feature 2020-resolver - - pip install "pytest>=5,<6" + - pip install "pytest>=5,<6" "pytest-cov>=2,<3" script: # testing without aiohttp - travis_retry pytest tests/scenario_tests/ @@ -24,3 +24,4 @@ script: - travis_retry python setup.py test # Run pytype only for Python 3.8 - if [ ${TRAVIS_PYTHON_VERSION:0:3} == "3.8" ]; then pip install "pytype" && pytype slack_bolt/; fi + - if [ ${TRAVIS_PYTHON_VERSION:0:3} == "3.8" ]; then pytest --cov=slack_bolt/ && bash <(curl -s https://codecov.io/bash); fi diff --git a/setup.py b/setup.py index c2674aa3f..0ea7c873a 100755 --- a/setup.py +++ b/setup.py @@ -13,6 +13,7 @@ test_dependencies = [ "pytest>=5,<6", + "pytest-cov>=2,<3", "pytest-asyncio<1", # for async "aiohttp>=3,<4", # for async "black==19.10b0", From dcea08e916fb60292077c6c1dc1cc4d171369678 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 9 Sep 2020 15:30:13 +0900 Subject: [PATCH 038/865] Add Codecov to README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index e3271f66d..16e388689 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This project is **still in alpha**, and may have bugs in it. Also, the public AP [![Python Version][python-version]][pypi-url] [![pypi package][pypi-image]][pypi-url] [![Build Status][travis-image]][travis-url] +[![Codecov][codecov-image]][codecov-url] A Python framework to build Slack apps in a flash with the latest platform features. Check the [samples](https://github.com/slackapi/bolt-python/tree/main/samples) to know how to use this framework. @@ -115,4 +116,6 @@ The MIT License [pypi-url]: https://pypi.org/project/slack-bolt/ [travis-image]: https://travis-ci.org/slackapi/bolt-python.svg?branch=main [travis-url]: https://travis-ci.org/slackapi/bolt-python +[codecov-image]: https://codecov.io/gh/slackapi/bolt-python/branch/main/graph/badge.svg +[codecov-url]: https://codecov.io/gh/slackapi/bolt-python [python-version]: https://img.shields.io/pypi/pyversions/slack-bolt.svg From 8bf4aade714bf1451aca57a28ffbb37ba789a384 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 9 Sep 2020 16:06:03 +0900 Subject: [PATCH 039/865] Add more tests --- .travis.yml | 2 + slack_bolt/context/async_context.py | 4 +- slack_bolt/context/context.py | 4 +- tests/slack_bolt/__init__.py | 0 tests/slack_bolt/context/__init__.py | 0 tests/slack_bolt/context/test_respond.py | 25 +++++++++++ .../context/test_respond_internals.py | 29 ++++++++++++ tests/slack_bolt/context/test_say.py | 26 +++++++++++ tests/slack_bolt/kwargs_injection/__init__.py | 0 .../slack_bolt/kwargs_injection/test_args.py | 42 ++++++++++++++++++ tests/slack_bolt_async/__init__.py | 0 tests/slack_bolt_async/context/__init__.py | 0 .../context/test_async_respond.py | 33 ++++++++++++++ .../context/test_async_say.py | 33 ++++++++++++++ .../kwargs_injection/__init__.py | 0 .../kwargs_injection/test_async_args.py | 44 +++++++++++++++++++ 16 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 tests/slack_bolt/__init__.py create mode 100644 tests/slack_bolt/context/__init__.py create mode 100644 tests/slack_bolt/context/test_respond.py create mode 100644 tests/slack_bolt/context/test_respond_internals.py create mode 100644 tests/slack_bolt/context/test_say.py create mode 100644 tests/slack_bolt/kwargs_injection/__init__.py create mode 100644 tests/slack_bolt/kwargs_injection/test_args.py create mode 100644 tests/slack_bolt_async/__init__.py create mode 100644 tests/slack_bolt_async/context/__init__.py create mode 100644 tests/slack_bolt_async/context/test_async_respond.py create mode 100644 tests/slack_bolt_async/context/test_async_say.py create mode 100644 tests/slack_bolt_async/kwargs_injection/__init__.py create mode 100644 tests/slack_bolt_async/kwargs_injection/test_async_args.py diff --git a/.travis.yml b/.travis.yml index 1e854ed0c..f3545ae7b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,10 +12,12 @@ install: - pip install "pytest>=5,<6" "pytest-cov>=2,<3" script: # testing without aiohttp + - travis_retry pytest tests/slack_bolt/ - travis_retry pytest tests/scenario_tests/ # testing with aiohttp - pip install -e ".[async]" - pip install "pytest-asyncio<1" + - travis_retry pytest tests/slack_bolt_async/ - travis_retry pytest tests/async_scenario_tests/ # testing for adapters - pip install -e ".[adapter]" diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index b9a5b7873..8d646bf51 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -11,7 +11,9 @@ class AsyncBoltContext(BaseContext): @property def client(self) -> Optional[AsyncWebClient]: - return self.get("client", None) + if "client" not in self: + self["client"] = AsyncWebClient(token=None) + return self["client"] @property def ack(self) -> AsyncAck: diff --git a/slack_bolt/context/context.py b/slack_bolt/context/context.py index b428b5c9a..964f476de 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -12,7 +12,9 @@ class BoltContext(BaseContext): @property def client(self) -> Optional[WebClient]: - return self.get("client", None) + if "client" not in self: + self["client"] = WebClient(token=None) + return self["client"] @property def ack(self) -> Ack: diff --git a/tests/slack_bolt/__init__.py b/tests/slack_bolt/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/context/__init__.py b/tests/slack_bolt/context/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/context/test_respond.py b/tests/slack_bolt/context/test_respond.py new file mode 100644 index 000000000..1a76e507d --- /dev/null +++ b/tests/slack_bolt/context/test_respond.py @@ -0,0 +1,25 @@ +from slack_bolt.context.respond import Respond +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +class TestRespond: + def setup_method(self): + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_respond(self): + response_url = "http://localhost:8888" + respond = Respond(response_url=response_url) + response = respond(text="Hi there!") + assert response.status_code == 200 + + def test_respond2(self): + response_url = "http://localhost:8888" + respond = Respond(response_url=response_url) + response = respond({"text": "Hi there!"}) + assert response.status_code == 200 diff --git a/tests/slack_bolt/context/test_respond_internals.py b/tests/slack_bolt/context/test_respond_internals.py new file mode 100644 index 000000000..35d8edc5f --- /dev/null +++ b/tests/slack_bolt/context/test_respond_internals.py @@ -0,0 +1,29 @@ +from slack_sdk.models.blocks import DividerBlock + +from slack_bolt.context.respond.internals import _build_message + + +class TestRespondInternals: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_build_message_empty(self): + message = _build_message() + assert message is not None + + def test_build_message_text(self): + message = _build_message(text="Hello!") + assert message is not None + + def test_build_message_blocks(self): + message = _build_message(blocks=[{"type": "divider"}]) + assert message is not None + + def test_build_message_blocks2(self): + message = _build_message(blocks=[DividerBlock(block_id="foo")]) + assert message is not None + assert isinstance(message["blocks"][0], dict) + assert message["blocks"][0]["block_id"] == "foo" diff --git a/tests/slack_bolt/context/test_say.py b/tests/slack_bolt/context/test_say.py new file mode 100644 index 000000000..8b696168a --- /dev/null +++ b/tests/slack_bolt/context/test_say.py @@ -0,0 +1,26 @@ +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt import Say +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +class TestSay: + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient( + token=valid_token, base_url=mock_api_server_base_url + ) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_say(self): + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say(text="Hi there!") + assert response.status_code == 200 diff --git a/tests/slack_bolt/kwargs_injection/__init__.py b/tests/slack_bolt/kwargs_injection/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/kwargs_injection/test_args.py b/tests/slack_bolt/kwargs_injection/test_args.py new file mode 100644 index 000000000..93f74071d --- /dev/null +++ b/tests/slack_bolt/kwargs_injection/test_args.py @@ -0,0 +1,42 @@ +import logging + +from slack_bolt import Args, BoltRequest, BoltResponse +from slack_bolt.kwargs_injection import build_required_kwargs + + +class TestArgs: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def next(self): + pass + + def test_build(self): + required_args = [ + "logger", + "client", + "req", + "resp", + "context", + "body", + "payload", + "ack", + "say", + "respond", + "next", + ] + arg_params: dict = build_required_kwargs( + logger=logging.getLogger(__name__), + required_arg_names=required_args, + request=BoltRequest(body="", headers={}), + response=BoltResponse(status=200), + next_func=next, + ) + args = Args(**arg_params) + assert args.logger is not None + assert args.request is not None + assert args.response is not None + assert args.client is not None diff --git a/tests/slack_bolt_async/__init__.py b/tests/slack_bolt_async/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/context/__init__.py b/tests/slack_bolt_async/context/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/context/test_async_respond.py b/tests/slack_bolt_async/context/test_async_respond.py new file mode 100644 index 000000000..fb3083132 --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_respond.py @@ -0,0 +1,33 @@ +import asyncio + +import pytest + +from slack_bolt.context.respond.async_respond import AsyncRespond +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +class TestAsyncRespond: + @pytest.fixture + def event_loop(self): + setup_mock_web_api_server(self) + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + + @pytest.mark.asyncio + async def test_respond(self): + response_url = "http://localhost:8888" + respond = AsyncRespond(response_url=response_url) + response = await respond(text="Hi there!") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_respond2(self): + response_url = "http://localhost:8888" + respond = AsyncRespond(response_url=response_url) + response = await respond({"text": "Hi there!"}) + assert response.status_code == 200 diff --git a/tests/slack_bolt_async/context/test_async_say.py b/tests/slack_bolt_async/context/test_async_say.py new file mode 100644 index 000000000..75350628b --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_say.py @@ -0,0 +1,33 @@ +import asyncio + +import pytest +from slack_sdk.web.async_slack_response import AsyncSlackResponse +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.context.say.async_say import AsyncSay +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +class TestAsyncSay: + @pytest.fixture + def event_loop(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = AsyncWebClient( + token=valid_token, base_url=mock_api_server_base_url + ) + + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + + @pytest.mark.asyncio + async def test_say(self): + say = AsyncSay(client=self.web_client, channel="C111") + response: AsyncSlackResponse = await say(text="Hi there!") + assert response.status_code == 200 diff --git a/tests/slack_bolt_async/kwargs_injection/__init__.py b/tests/slack_bolt_async/kwargs_injection/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/kwargs_injection/test_async_args.py b/tests/slack_bolt_async/kwargs_injection/test_async_args.py new file mode 100644 index 000000000..45422a5e8 --- /dev/null +++ b/tests/slack_bolt_async/kwargs_injection/test_async_args.py @@ -0,0 +1,44 @@ +import logging + +from slack_bolt import BoltResponse +from slack_bolt.kwargs_injection.async_args import AsyncArgs +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs +from slack_bolt.request.async_request import AsyncBoltRequest + + +class TestAsyncArgs: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def next(self): + pass + + def test_build(self): + required_args = [ + "logger", + "client", + "req", + "resp", + "context", + "body", + "payload", + "ack", + "say", + "respond", + "next", + ] + arg_params: dict = build_async_required_kwargs( + logger=logging.getLogger(__name__), + required_arg_names=required_args, + request=AsyncBoltRequest(body="", headers={}), + response=BoltResponse(status=200), + next_func=next, + ) + args = AsyncArgs(**arg_params) + assert args.logger is not None + assert args.request is not None + assert args.response is not None + assert args.client is not None From 78bd61e8bfbaa2bdccf05a18683736e74f8d70bf Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 9 Sep 2020 16:54:45 +0900 Subject: [PATCH 040/865] Add unit tests for OAuthFlow --- tests/mock_web_api_server.py | 31 ++++++ tests/slack_bolt/oauth/__init__.py | 0 tests/slack_bolt/oauth/test_oauth_flow.py | 95 ++++++++++++++++ tests/slack_bolt_async/oauth/__init__.py | 0 .../oauth/test_async_oauth_flow.py | 103 ++++++++++++++++++ 5 files changed, 229 insertions(+) create mode 100644 tests/slack_bolt/oauth/__init__.py create mode 100644 tests/slack_bolt/oauth/test_oauth_flow.py create mode 100644 tests/slack_bolt_async/oauth/__init__.py create mode 100644 tests/slack_bolt_async/oauth/test_async_oauth_flow.py diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index 601de9156..a1cf43383 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -29,10 +29,41 @@ def set_common_headers(self): "error": "invalid_auth", } + oauth_v2_access_response = """ +{ + "ok": true, + "access_token": "xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy", + "token_type": "bot", + "scope": "chat:write,commands", + "bot_user_id": "U0KRQLJ9H", + "app_id": "A0KRD7HC3", + "team": { + "name": "Slack Softball Team", + "id": "T9TK3CUKW" + }, + "enterprise": { + "name": "slack-sports", + "id": "E12345678" + }, + "authed_user": { + "id": "U1234", + "scope": "chat:write", + "access_token": "xoxp-1234", + "token_type": "user" + } +} + """ + def _handle(self): self.received_requests[self.path] = self.received_requests.get(self.path, 0) + 1 try: body = {"ok": True} + if self.path == "/oauth.v2.access": + self.send_response(200) + self.set_common_headers() + self.wfile.write(self.oauth_v2_access_response.encode("utf-8")) + return + if self.is_valid_token(): parsed_path = urlparse(self.path) diff --git a/tests/slack_bolt/oauth/__init__.py b/tests/slack_bolt/oauth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/oauth/test_oauth_flow.py b/tests/slack_bolt/oauth/test_oauth_flow.py new file mode 100644 index 000000000..05b8c47f8 --- /dev/null +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -0,0 +1,95 @@ +import re + +from slack_sdk import WebClient +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +from slack_bolt import BoltRequest +from slack_bolt.oauth import OAuthFlow +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) + + +class TestOAuthFlow: + mock_api_server_base_url = "http://localhost:8888" + + def setup_method(self): + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def next(self): + pass + + def test_instantiation(self): + oauth_flow = OAuthFlow( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + ) + assert oauth_flow is not None + + def test_handle_installation(self): + oauth_flow = OAuthFlow( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + ) + req = BoltRequest(body="") + resp = oauth_flow.handle_installation(req) + assert resp.status == 302 + url = resp.headers["location"][0] + assert ( + re.compile( + "https://slack.com/oauth/v2/authorize\\?state=[-0-9a-z]+." + "&client_id=111\\.222" + "&scope=chat:write,commands" + "&user_scope=" + ).match(url) + is not None + ) + + def test_handle_callback(self): + oauth_flow = OAuthFlow( + client=WebClient(base_url=self.mock_api_server_base_url), + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + success_url="https://www.example.com/completion", + failure_url="https://www.example.com/failure", + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.oauth_state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 200 + assert "https://www.example.com/completion" in resp.body + + def test_handle_callback_invalid_state(self): + oauth_flow = OAuthFlow( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.oauth_state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 400 diff --git a/tests/slack_bolt_async/oauth/__init__.py b/tests/slack_bolt_async/oauth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py new file mode 100644 index 000000000..bc3a9537d --- /dev/null +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -0,0 +1,103 @@ +import asyncio +import re + +import pytest +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) + + +class TestAsyncOAuthFlow: + mock_api_server_base_url = "http://localhost:8888" + + @pytest.fixture + def event_loop(self): + setup_mock_web_api_server(self) + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + + def next(self): + pass + + @pytest.mark.asyncio + async def test_instantiation(self): + oauth_flow = AsyncOAuthFlow( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + ) + assert oauth_flow is not None + + @pytest.mark.asyncio + async def test_handle_installation(self): + oauth_flow = AsyncOAuthFlow( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + ) + req = AsyncBoltRequest(body="") + resp = await oauth_flow.handle_installation(req) + assert resp.status == 302 + url = resp.headers["location"][0] + assert ( + re.compile( + "https://slack.com/oauth/v2/authorize\\?state=[-0-9a-z]+." + "&client_id=111\\.222" + "&scope=chat:write,commands" + "&user_scope=" + ).match(url) + is not None + ) + + @pytest.mark.asyncio + async def test_handle_callback(self): + oauth_flow = AsyncOAuthFlow( + client=AsyncWebClient(base_url=self.mock_api_server_base_url), + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + success_url="https://www.example.com/completion", + failure_url="https://www.example.com/failure", + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.oauth_state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 200 + assert "https://www.example.com/completion" in resp.body + + @pytest.mark.asyncio + async def test_handle_callback_invalid_state(self): + oauth_flow = AsyncOAuthFlow( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.oauth_state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 400 From 4a4dee934c84356c4cbbe9971b2199be8f758bfb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 9 Sep 2020 17:02:52 +0900 Subject: [PATCH 041/865] Add tests for standalone servers --- tests/slack_bolt/app/__init__.py | 0 tests/slack_bolt/app/test_dev_server.py | 17 +++++++++++++++++ tests/slack_bolt_async/app/__init__.py | 0 tests/slack_bolt_async/app/test_server.py | 18 ++++++++++++++++++ 4 files changed, 35 insertions(+) create mode 100644 tests/slack_bolt/app/__init__.py create mode 100644 tests/slack_bolt/app/test_dev_server.py create mode 100644 tests/slack_bolt_async/app/__init__.py create mode 100644 tests/slack_bolt_async/app/test_server.py diff --git a/tests/slack_bolt/app/__init__.py b/tests/slack_bolt/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/app/test_dev_server.py b/tests/slack_bolt/app/test_dev_server.py new file mode 100644 index 000000000..4553999e0 --- /dev/null +++ b/tests/slack_bolt/app/test_dev_server.py @@ -0,0 +1,17 @@ +from slack_bolt.app.app import SlackAppDevelopmentServer, App + + +class TestDevServer: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_instance(self): + server = SlackAppDevelopmentServer( + port=3001, + path="/slack/events", + app=App(signing_secret="valid", token="xoxb-valid",), + ) + assert server is not None diff --git a/tests/slack_bolt_async/app/__init__.py b/tests/slack_bolt_async/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/app/test_server.py b/tests/slack_bolt_async/app/test_server.py new file mode 100644 index 000000000..1c8bbfc29 --- /dev/null +++ b/tests/slack_bolt_async/app/test_server.py @@ -0,0 +1,18 @@ +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.app.async_server import AsyncSlackAppServer + + +class TestServer: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_instance(self): + server = AsyncSlackAppServer( + port=3001, + path="/slack/events", + app=AsyncApp(signing_secret="valid", token="xoxb-valid",), + ) + assert server is not None From 5d8c7a84d986f228b638c15584f130c29299b3f2 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 9 Sep 2020 11:31:35 +0900 Subject: [PATCH 042/865] Fix #64 by correcting say() implementation --- samples/events_app.py | 5 ++ slack_bolt/request/internals.py | 3 + tests/async_scenario_tests/test_events.py | 36 +++++++++++- tests/scenario_tests/test_events.py | 39 +++++++++++++ tests/slack_bolt/request/__init__.py | 0 tests/slack_bolt/request/test_internals.py | 67 ++++++++++++++++++++++ 6 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 tests/slack_bolt/request/__init__.py create mode 100644 tests/slack_bolt/request/test_internals.py diff --git a/samples/events_app.py b/samples/events_app.py index 9588bcea8..f15fab9d0 100644 --- a/samples/events_app.py +++ b/samples/events_app.py @@ -27,6 +27,11 @@ def event_test(body, say, logger): say("What's up?") +@app.event("reaction_added") +def say_something_to_reaction(say): + say("OK!") + + @app.message("test") def test_message(logger, body): logger.info(body) diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index e157cb7a3..493e4f219 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -104,6 +104,9 @@ def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: return payload.get("channel_id") if "event" in payload: return extract_channel_id(payload["event"]) + if "item" in payload: + # reaction_added: body["event"]["item"] + return extract_channel_id(payload["item"]) return None diff --git a/tests/async_scenario_tests/test_events.py b/tests/async_scenario_tests/test_events.py index 1a390ad0d..33b72d43b 100644 --- a/tests/async_scenario_tests/test_events.py +++ b/tests/async_scenario_tests/test_events.py @@ -113,6 +113,22 @@ async def test_simultaneous_requests(self): assert self.mock_received_requests["/auth.test"] == times assert self.mock_received_requests["/chat.postMessage"] == times + def build_valid_reaction_added_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(reaction_added_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_reaction_added(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.event("reaction_added")(whats_up) + + request = self.build_valid_reaction_added_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + app_mention_body = { "token": "verification_token", @@ -135,6 +151,25 @@ async def test_simultaneous_requests(self): "authed_users": ["W111"], } +reaction_added_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": {"type": "message", "channel": "C111", "ts": "1599529504.000400"}, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} + async def random_sleeper(body, say, payload, event): assert body == app_mention_body @@ -146,7 +181,6 @@ async def random_sleeper(body, say, payload, event): async def whats_up(body, say, payload, event): - assert body == app_mention_body assert body["event"] == payload assert payload == event await say("What's up?") diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index 36b66d1c0..83984d15e 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -104,3 +104,42 @@ def handle_app_mention(body, logger, payload, event): response = app.dispatch(request) assert response.status == 404 assert self.mock_received_requests["/auth.test"] == 1 + + valid_reaction_added_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": {"type": "message", "channel": "C111", "ts": "1599529504.000400"}, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + def test_reaction_added(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + @app.event("reaction_added") + def handle_app_mention(body, say, payload, event): + assert body == self.valid_reaction_added_body + assert body["event"] == payload + assert payload == event + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(self.valid_reaction_added_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 diff --git a/tests/slack_bolt/request/__init__.py b/tests/slack_bolt/request/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/request/test_internals.py b/tests/slack_bolt/request/test_internals.py new file mode 100644 index 000000000..33e8db85b --- /dev/null +++ b/tests/slack_bolt/request/test_internals.py @@ -0,0 +1,67 @@ +from slack_bolt.request.internals import ( + extract_channel_id, + extract_user_id, + extract_team_id, + extract_enterprise_id, +) + + +class TestRequestInternals: + def setup_method(self): + pass + + def teardown_method(self): + pass + + # based on https://github.com/slackapi/bolt-js/blob/f8c25ffb5cd91827510bbc689e97556d2d5ad017/src/App.spec.ts#L1123 + requests = [ + { + "event": {"channel": "C111", "user": "U111"}, + "team_id": "T111", + "enterprise_id": "E111", + }, + { + "event": {"item": {"channel": "C111"}, "user": "U111", "item_user": "U222"}, + "team_id": "T111", + "enterprise_id": "E111", + }, + { + "command": "/hello", + "channel_id": "C111", + "team_id": "T111", + "enterprise_id": "E111", + "user_id": "U111", + }, + { + "actions": [{}], + "channel": {"id": "C111"}, + "user": {"id": "U111"}, + "team": {"id": "T111", "enterprise_id": "E111"}, + }, + { + "type": "dialog_submission", + "channel": {"id": "C111"}, + "user": {"id": "U111"}, + "team": {"id": "T111", "enterprise_id": "E111"}, + }, + ] + + def test_channel_id_extraction(self): + for req in self.requests: + channel_id = extract_channel_id(req) + assert channel_id == "C111" + + def test_user_id_extraction(self): + for req in self.requests: + user_id = extract_user_id(req) + assert user_id == "U111" + + def test_team_id_extraction(self): + for req in self.requests: + team_id = extract_team_id(req) + assert team_id == "T111" + + def test_enterprise_id_extraction(self): + for req in self.requests: + team_id = extract_enterprise_id(req) + assert team_id == "E111" From d6c66cea5bda51a85f94bddbdc726c2cbafd2438 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 9 Sep 2020 14:52:33 +0900 Subject: [PATCH 043/865] Fix #65 by having block_actions as the default when type is missing in constraints --- slack_bolt/app/app.py | 4 +- slack_bolt/app/async_app.py | 4 +- slack_bolt/listener_matcher/builtins.py | 28 ++++-- .../test_block_actions.py | 30 ++++++ tests/scenario_tests/test_block_actions.py | 27 ++++++ tests/slack_bolt/listener_matcher/__init__.py | 0 .../listener_matcher/test_builtins.py | 97 +++++++++++++++++++ 7 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 tests/slack_bolt/listener_matcher/__init__.py create mode 100644 tests/slack_bolt/listener_matcher/test_builtins.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 574a30d96..4ebe0367f 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -590,13 +590,13 @@ def __call__(*args, **kwargs): def block_action( self, - action_id: Union[str, Pattern], + constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, ): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_action(action_id) + primary_matcher = builtin_matchers.block_action(constraints) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index c8a1662a4..ca01b637a 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -622,13 +622,13 @@ def __call__(*args, **kwargs): def block_action( self, - action_id: Union[str, Pattern], + constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, ): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_action(action_id, True) + primary_matcher = builtin_matchers.block_action(constraints, True) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 297155163..fe07535db 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -183,7 +183,7 @@ def action( elif "type" in constraints: action_type = constraints["type"] if action_type == "block_actions": - return block_action(constraints["action_id"], asyncio) + return block_action(constraints, asyncio) if action_type == "interactive_message": return attachment_action(constraints["callback_id"], asyncio) if action_type == "dialog_submission": @@ -197,6 +197,9 @@ def action( return workflow_step_edit(constraints["callback_id"], asyncio) raise BoltError(f"type: {action_type} is unsupported") + elif "action_id" in constraints: + # The default value is "block_actions" + return block_action(constraints, asyncio) raise BoltError( f"action ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict" @@ -204,12 +207,25 @@ def action( def block_action( - action_id: Union[str, Pattern], asyncio: bool = False, + constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: - return is_block_actions(body) and _matches( - action_id, to_action(body)["action_id"] - ) + if is_block_actions(body) is False: + return False + + action = to_action(body) + if isinstance(constraints, (str, Pattern)): + action_id = constraints + return _matches(action_id, action["action_id"]) + elif isinstance(constraints, dict): + # block_id matching is optional + block_id: Optional[Union[str, Pattern]] = constraints.get("block_id") + block_id_matched = block_id is None or _matches( + block_id, action.get("block_id") + ) + action_id_matched = _matches(constraints["action_id"], action["action_id"]) + return block_id_matched and action_id_matched return build_listener_matcher(func, asyncio) @@ -347,7 +363,7 @@ def _matches(str_or_pattern: Union[str, Pattern], input: Optional[str]) -> bool: return input == exact_match_str elif isinstance(str_or_pattern, Pattern): pattern: Pattern = str_or_pattern - return pattern.search(input) + return pattern.search(input) is not None else: raise BoltError( f"{str_or_pattern} ({type(str_or_pattern)}) must be either str or Pattern" diff --git a/tests/async_scenario_tests/test_block_actions.py b/tests/async_scenario_tests/test_block_actions.py index 4d753ba6f..c6568c8ec 100644 --- a/tests/async_scenario_tests/test_block_actions.py +++ b/tests/async_scenario_tests/test_block_actions.py @@ -78,6 +78,36 @@ async def test_success_2(self): assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 1 + @pytest.mark.asyncio + async def test_default_type(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.action({"action_id": "a", "block_id": "b"})(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + @pytest.mark.asyncio + async def test_default_type_no_block_id(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.action({"action_id": "a"})(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + @pytest.mark.asyncio + async def test_default_type_unmatched_block_id(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.action({"action_id": "a", "block_id": "bbb"})(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + @pytest.mark.asyncio async def test_process_before_response(self): app = AsyncApp( diff --git a/tests/scenario_tests/test_block_actions.py b/tests/scenario_tests/test_block_actions.py index e1a6e2891..f6bd93ee0 100644 --- a/tests/scenario_tests/test_block_actions.py +++ b/tests/scenario_tests/test_block_actions.py @@ -82,6 +82,33 @@ def test_process_before_response(self): assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 1 + def test_default_type(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.action({"action_id": "a", "block_id": "b"})(simple_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_default_type_no_block_id(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.action({"action_id": "a"})(simple_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_default_type_and_unmatched_block_id(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.action({"action_id": "a", "block_id": "bbb"})(simple_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + def test_failure(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) request = self.build_valid_request() diff --git a/tests/slack_bolt/listener_matcher/__init__.py b/tests/slack_bolt/listener_matcher/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/listener_matcher/test_builtins.py b/tests/slack_bolt/listener_matcher/test_builtins.py new file mode 100644 index 000000000..c15361722 --- /dev/null +++ b/tests/slack_bolt/listener_matcher/test_builtins.py @@ -0,0 +1,97 @@ +import json +import re +from urllib.parse import quote + +from slack_bolt import BoltRequest, BoltResponse +from slack_bolt.listener_matcher.builtins import block_action, action + + +class TestBuiltins: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_block_action(self): + body = { + "type": "block_actions", + "actions": [ + { + "type": "button", + "action_id": "valid_action_id", + "block_id": "b", + "action_ts": "111.222", + "value": "v", + } + ], + } + raw_body = f"payload={quote(json.dumps(body))}" + headers = {"Content-Type": "application/x-www-form-urlencoded"} + req = BoltRequest(body=raw_body, headers=headers) + resp = BoltResponse(status=404) + + assert block_action("valid_action_id").matches(req, resp) is True + assert block_action("invalid_action_id").matches(req, resp) is False + assert block_action(re.compile("valid_.+")).matches(req, resp) is True + assert block_action(re.compile("invalid_.+")).matches(req, resp) is False + + assert action("valid_action_id").matches(req, resp) is True + assert action("invalid_action_id").matches(req, resp) is False + assert action(re.compile("valid_.+")).matches(req, resp) is True + assert action(re.compile("invalid_.+")).matches(req, resp) is False + + assert action({"action_id": "valid_action_id"}).matches(req, resp) is True + assert action({"action_id": "invalid_action_id"}).matches(req, resp) is False + assert action({"action_id": re.compile("valid_.+")}).matches(req, resp) is True + assert ( + action({"action_id": re.compile("invalid_.+")}).matches(req, resp) is False + ) + + assert ( + action({"action_id": "valid_action_id", "block_id": "b"}).matches(req, resp) + is True + ) + assert ( + action({"action_id": "invalid_action_id", "block_id": "b"}).matches( + req, resp + ) + is False + ) + assert ( + action({"action_id": re.compile("valid_.+"), "block_id": "b"}).matches( + req, resp + ) + is True + ) + assert ( + action({"action_id": re.compile("invalid_.+"), "block_id": "b"}).matches( + req, resp + ) + is False + ) + + assert ( + action({"action_id": "valid_action_id", "block_id": "bbb"}).matches( + req, resp + ) + is False + ) + assert ( + action({"action_id": "invalid_action_id", "block_id": "bbb"}).matches( + req, resp + ) + is False + ) + assert ( + action({"action_id": re.compile("valid_.+"), "block_id": "bbb"}).matches( + req, resp + ) + is False + ) + assert ( + action({"action_id": re.compile("invalid_.+"), "block_id": "bbb"}).matches( + req, resp + ) + is False + ) From efc28ac31294bb12302adcc246f5ec25cd3ce8fd Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 9 Sep 2020 20:49:46 +0900 Subject: [PATCH 044/865] Add full support for Ack args --- slack_bolt/context/ack/ack.py | 13 +++- slack_bolt/context/ack/async_ack.py | 13 +++- slack_bolt/context/ack/internals.py | 68 +++++++++++++---- tests/slack_bolt/context/test_ack.py | 74 ++++++++++++++++++ .../context/test_async_ack.py | 76 +++++++++++++++++++ 5 files changed, 227 insertions(+), 17 deletions(-) create mode 100644 tests/slack_bolt/context/test_ack.py create mode 100644 tests/slack_bolt_async/context/test_async_ack.py diff --git a/slack_bolt/context/ack/ack.py b/slack_bolt/context/ack/ack.py index 5db225a47..bc9b7c82d 100644 --- a/slack_bolt/context/ack/ack.py +++ b/slack_bolt/context/ack/ack.py @@ -1,7 +1,8 @@ -from typing import List, Optional, Union +from typing import List, Optional, Union, Dict from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, Option, OptionGroup +from slack_sdk.models.views import View from slack_bolt.context.ack.internals import _set_response from slack_bolt.response.response import BoltResponse @@ -18,14 +19,24 @@ def __call__( text: Union[str, dict] = "", # text: str or whole_response: dict blocks: Optional[List[Union[dict, Block]]] = None, attachments: Optional[List[Union[dict, Attachment]]] = None, + response_type: Optional[str] = None, # in_channel / ephemeral + # block_suggestion / dialog_suggestion options: Optional[List[Union[dict, Option]]] = None, option_groups: Optional[List[Union[dict, OptionGroup]]] = None, + # view_submission + response_action: Optional[str] = None, # errors / update / push / clear + errors: Optional[Dict[str, str]] = None, + view: Optional[Union[dict, View]] = None, ) -> BoltResponse: return _set_response( self, text_or_whole_response=text, blocks=blocks, attachments=attachments, + response_type=response_type, options=options, option_groups=option_groups, + response_action=response_action, + errors=errors, + view=view, ) diff --git a/slack_bolt/context/ack/async_ack.py b/slack_bolt/context/ack/async_ack.py index ea36e898a..380131e4e 100644 --- a/slack_bolt/context/ack/async_ack.py +++ b/slack_bolt/context/ack/async_ack.py @@ -1,7 +1,8 @@ -from typing import List, Optional, Union +from typing import List, Optional, Union, Dict from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, Option, OptionGroup +from slack_sdk.models.views import View from slack_bolt.context.ack.internals import _set_response from slack_bolt.response.response import BoltResponse @@ -18,14 +19,24 @@ async def __call__( text: Union[str, dict] = "", # text: str or whole_response: dict blocks: Optional[List[Union[dict, Block]]] = None, attachments: Optional[List[Union[dict, Attachment]]] = None, + response_type: Optional[str] = None, # in_channel / ephemeral + # block_suggestion / dialog_suggestion options: Optional[List[Union[dict, Option]]] = None, option_groups: Optional[List[Union[dict, OptionGroup]]] = None, + # view_submission + response_action: Optional[str] = None, # errors / update / push / clear + errors: Optional[Dict[str, str]] = None, + view: Optional[Union[dict, View]] = None, ) -> BoltResponse: return _set_response( self, text_or_whole_response=text, blocks=blocks, attachments=attachments, + response_type=response_type, options=options, option_groups=option_groups, + response_action=response_action, + errors=errors, + view=view, ) diff --git a/slack_bolt/context/ack/internals.py b/slack_bolt/context/ack/internals.py index c9fdde899..73ebd40fc 100644 --- a/slack_bolt/context/ack/internals.py +++ b/slack_bolt/context/ack/internals.py @@ -1,11 +1,12 @@ -from typing import Optional, List, Union, Any +from typing import Optional, List, Union, Any, Dict from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, Option, OptionGroup +from slack_sdk.models.views import View from slack_bolt.error import BoltError from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import convert_to_dict_list +from slack_bolt.util.utils import convert_to_dict_list, _to_dict def _set_response( @@ -13,30 +14,59 @@ def _set_response( text_or_whole_response: Union[str, dict] = "", blocks: Optional[List[Union[dict, Block]]] = None, attachments: Optional[List[Union[dict, Attachment]]] = None, + response_type: Optional[str] = None, # in_channel / ephemeral + # block_suggestion / dialog_suggestion options: Optional[List[Union[dict, Option]]] = None, option_groups: Optional[List[Union[dict, OptionGroup]]] = None, + # view_submission + response_action: Optional[str] = None, + errors: Optional[Dict[str, str]] = None, + view: Optional[Union[dict, View]] = None, ) -> BoltResponse: if isinstance(text_or_whole_response, str): text: str = text_or_whole_response + body = {"text": text} + if response_type: + body["response_type"] = response_type if attachments and len(attachments) > 0: - self.response = BoltResponse( - status=200, - body={"text": text, "attachments": convert_to_dict_list(attachments),}, + body.update( + {"text": text, "attachments": convert_to_dict_list(attachments)} ) + self.response = BoltResponse(status=200, body=body) elif blocks and len(blocks) > 0: - self.response = BoltResponse( - status=200, body={"text": text, "blocks": convert_to_dict_list(blocks),} - ) + body.update({"text": text, "blocks": convert_to_dict_list(blocks)}) + self.response = BoltResponse(status=200, body=body) elif options and len(options) > 0: - self.response = BoltResponse( - status=200, body={"options": convert_to_dict_list(options),} - ) + body = {"options": convert_to_dict_list(options)} + self.response = BoltResponse(status=200, body=body) elif option_groups and len(option_groups) > 0: - self.response = BoltResponse( - status=200, body={"option_groups": convert_to_dict_list(option_groups),} - ) + body = {"option_groups": convert_to_dict_list(option_groups)} + self.response = BoltResponse(status=200, body=body) + elif response_action: + # These patterns are in response to view_submission requests + if response_action == "errors": + if errors: + self.response = BoltResponse( + status=200, + body={ + "response_action": response_action, + "errors": _to_dict(errors), + }, + ) + else: + raise ValueError( + f"errors field is required for response_action: errors" + ) + else: + body = {"response_action": response_action} + if view: + body["view"] = _to_dict(view) + self.response = BoltResponse(status=200, body=body) else: - self.response = BoltResponse(status=200, body=text) + if len(body) == 1 and "text" in body: + self.response = BoltResponse(status=200, body=body["text"]) + else: + self.response = BoltResponse(status=200, body=body) return self.response elif isinstance(text_or_whole_response, dict): body = text_or_whole_response @@ -48,6 +78,14 @@ def _set_response( body["options"] = convert_to_dict_list(body["options"]) if "option_groups" in body: body["option_groups"] = convert_to_dict_list(body["option_groups"]) + if "response_type" in body: + body["response_type"] = body["response_type"] + if "response_action" in body: + body["response_action"] = body["response_action"] + if "errors" in body: + body["errors"] = _to_dict(body["errors"]) + if "view" in body: + body["view"] = _to_dict(body["view"]) self.response = BoltResponse(status=200, body=body) return self.response diff --git a/tests/slack_bolt/context/test_ack.py b/tests/slack_bolt/context/test_ack.py new file mode 100644 index 000000000..af30e349c --- /dev/null +++ b/tests/slack_bolt/context/test_ack.py @@ -0,0 +1,74 @@ +from slack_bolt import Ack, BoltResponse + + +class TestAck: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_text(self): + ack = Ack() + response: BoltResponse = ack(text="foo") + assert (response.status, response.body) == (200, "foo") + + def test_blocks(self): + ack = Ack() + response: BoltResponse = ack(text="foo", blocks=[{"type": "divider"}]) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "blocks": [{"type": "divider"}]}', + ) + + def test_response_type(self): + ack = Ack() + response: BoltResponse = ack(text="foo", response_type="in_channel") + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "response_type": "in_channel"}', + ) + + def test_view_errors(self): + ack = Ack() + response: BoltResponse = ack( + response_action="errors", + errors={ + "block_title": "Title is required", + "block_description": "Description must be longer than 10 characters", + }, + ) + assert (response.status, response.body) == ( + 200, + '{"response_action": "errors", ' + '"errors": {' + '"block_title": "Title is required", ' + '"block_description": "Description must be longer than 10 characters"' + "}" + "}", + ) + + def test_view_update(self): + ack = Ack() + response: BoltResponse = ack( + response_action="update", + view={ + "type": "modal", + "callback_id": "view-id", + "title": {"type": "plain_text", "text": "My App",}, + "close": {"type": "plain_text", "text": "Cancel",}, + "blocks": [{"type": "divider", "block_id": "b"}], + }, + ) + assert (response.status, response.body) == ( + 200, + '{"response_action": "update", ' + '"view": {' + '"type": "modal", ' + '"callback_id": "view-id", ' + '"title": {"type": "plain_text", "text": "My App"}, ' + '"close": {"type": "plain_text", "text": "Cancel"}, ' + '"blocks": [{"type": "divider", "block_id": "b"}]' + "}" + "}", + ) diff --git a/tests/slack_bolt_async/context/test_async_ack.py b/tests/slack_bolt_async/context/test_async_ack.py new file mode 100644 index 000000000..a3d250a9e --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_ack.py @@ -0,0 +1,76 @@ +import pytest + +from slack_bolt import BoltResponse +from slack_bolt.context.ack.async_ack import AsyncAck + + +class TestAsyncAsyncAck: + @pytest.mark.asyncio + async def test_text(self): + ack = AsyncAck() + response: BoltResponse = await ack(text="foo") + assert (response.status, response.body) == (200, "foo") + + @pytest.mark.asyncio + async def test_blocks(self): + ack = AsyncAck() + response: BoltResponse = await ack(text="foo", blocks=[{"type": "divider"}]) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "blocks": [{"type": "divider"}]}', + ) + + @pytest.mark.asyncio + async def test_response_type(self): + ack = AsyncAck() + response: BoltResponse = await ack(text="foo", response_type="in_channel") + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "response_type": "in_channel"}', + ) + + @pytest.mark.asyncio + async def test_view_errors(self): + ack = AsyncAck() + response: BoltResponse = await ack( + response_action="errors", + errors={ + "block_title": "Title is required", + "block_description": "Description must be longer than 10 characters", + }, + ) + assert (response.status, response.body) == ( + 200, + '{"response_action": "errors", ' + '"errors": {' + '"block_title": "Title is required", ' + '"block_description": "Description must be longer than 10 characters"' + "}" + "}", + ) + + @pytest.mark.asyncio + async def test_view_update(self): + ack = AsyncAck() + response: BoltResponse = await ack( + response_action="update", + view={ + "type": "modal", + "callbAsyncAck_id": "view-id", + "title": {"type": "plain_text", "text": "My App",}, + "close": {"type": "plain_text", "text": "Cancel",}, + "blocks": [{"type": "divider", "block_id": "b"}], + }, + ) + assert (response.status, response.body) == ( + 200, + '{"response_action": "update", ' + '"view": {' + '"type": "modal", ' + '"callbAsyncAck_id": "view-id", ' + '"title": {"type": "plain_text", "text": "My App"}, ' + '"close": {"type": "plain_text", "text": "Cancel"}, ' + '"blocks": [{"type": "divider", "block_id": "b"}]' + "}" + "}", + ) From 14ccbfa7dfa3dd01a77fc84879b1d6d75bf2f2e7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 9 Sep 2020 20:58:23 +0900 Subject: [PATCH 045/865] Add type-safe builder tests --- tests/slack_bolt/context/test_ack.py | 29 +++++++++++++++++++ .../context/test_async_ack.py | 29 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/tests/slack_bolt/context/test_ack.py b/tests/slack_bolt/context/test_ack.py index af30e349c..73d7c1369 100644 --- a/tests/slack_bolt/context/test_ack.py +++ b/tests/slack_bolt/context/test_ack.py @@ -1,3 +1,6 @@ +from slack_sdk.models.blocks import PlainTextObject, DividerBlock +from slack_sdk.models.views import View + from slack_bolt import Ack, BoltResponse @@ -72,3 +75,29 @@ def test_view_update(self): "}" "}", ) + + def test_view_update_2(self): + ack = Ack() + response: BoltResponse = ack( + response_action="update", + view=View( + type="modal", + callback_id="view-id", + title=PlainTextObject(text="My App"), + close=PlainTextObject(text="Cancel"), + blocks=[DividerBlock(block_id="b")], + ), + ) + assert (response.status, response.body) == ( + 200, + "" + '{"response_action": "update", ' + '"view": {' + '"blocks": [{"block_id": "b", "type": "divider"}], ' + '"callback_id": "view-id", ' + '"close": {"text": "Cancel", "type": "plain_text"}, ' + '"title": {"text": "My App", "type": "plain_text"}, ' + '"type": "modal"' + "}" + "}", + ) diff --git a/tests/slack_bolt_async/context/test_async_ack.py b/tests/slack_bolt_async/context/test_async_ack.py index a3d250a9e..56daa06ea 100644 --- a/tests/slack_bolt_async/context/test_async_ack.py +++ b/tests/slack_bolt_async/context/test_async_ack.py @@ -1,4 +1,6 @@ import pytest +from slack_sdk.models.blocks import PlainTextObject, DividerBlock +from slack_sdk.models.views import View from slack_bolt import BoltResponse from slack_bolt.context.ack.async_ack import AsyncAck @@ -74,3 +76,30 @@ async def test_view_update(self): "}" "}", ) + + @pytest.mark.asyncio + async def test_view_update_2(self): + ack = AsyncAck() + response: BoltResponse = await ack( + response_action="update", + view=View( + type="modal", + callback_id="view-id", + title=PlainTextObject(text="My App"), + close=PlainTextObject(text="Cancel"), + blocks=[DividerBlock(block_id="b")], + ), + ) + assert (response.status, response.body) == ( + 200, + "" + '{"response_action": "update", ' + '"view": {' + '"blocks": [{"block_id": "b", "type": "divider"}], ' + '"callback_id": "view-id", ' + '"close": {"text": "Cancel", "type": "plain_text"}, ' + '"title": {"text": "My App", "type": "plain_text"}, ' + '"type": "modal"' + "}" + "}", + ) From 5f70dddea734bb9071eb3546b4db51917e461123 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 10 Sep 2020 08:52:10 +0900 Subject: [PATCH 046/865] Rename utilities --- slack_bolt/app/app.py | 4 ++-- slack_bolt/app/async_app.py | 4 ++-- slack_bolt/context/ack/internals.py | 10 +++++----- slack_bolt/util/utils.py | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 4ebe0367f..5b904f769 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -40,7 +40,7 @@ from slack_bolt.oauth import OAuthFlow from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import create_web_client, _copy_object +from slack_bolt.util.utils import create_web_client, create_copy class App: @@ -432,7 +432,7 @@ def _start_lazy_function( @staticmethod def _build_lazy_request(request: BoltRequest, lazy_func_name: str) -> BoltRequest: - copied_request = _copy_object(request) + copied_request = create_copy(request) copied_request.method = "NONE" copied_request.lazy_only = True copied_request.lazy_function_name = lazy_func_name diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index ca01b637a..8eb2c3d0b 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -52,7 +52,7 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from slack_bolt.util.async_utils import create_async_web_client -from slack_bolt.util.utils import _copy_object +from slack_bolt.util.utils import create_copy class AsyncApp: @@ -464,7 +464,7 @@ def _start_lazy_function( def _build_lazy_request( request: AsyncBoltRequest, lazy_func_name: str ) -> AsyncBoltRequest: - copied_request = _copy_object(request) + copied_request = create_copy(request) copied_request.method = "NONE" copied_request.lazy_only = True copied_request.lazy_function_name = lazy_func_name diff --git a/slack_bolt/context/ack/internals.py b/slack_bolt/context/ack/internals.py index 73ebd40fc..6fdd0ca41 100644 --- a/slack_bolt/context/ack/internals.py +++ b/slack_bolt/context/ack/internals.py @@ -6,7 +6,7 @@ from slack_bolt.error import BoltError from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import convert_to_dict_list, _to_dict +from slack_bolt.util.utils import convert_to_dict_list, convert_to_dict def _set_response( @@ -50,7 +50,7 @@ def _set_response( status=200, body={ "response_action": response_action, - "errors": _to_dict(errors), + "errors": convert_to_dict(errors), }, ) else: @@ -60,7 +60,7 @@ def _set_response( else: body = {"response_action": response_action} if view: - body["view"] = _to_dict(view) + body["view"] = convert_to_dict(view) self.response = BoltResponse(status=200, body=body) else: if len(body) == 1 and "text" in body: @@ -83,9 +83,9 @@ def _set_response( if "response_action" in body: body["response_action"] = body["response_action"] if "errors" in body: - body["errors"] = _to_dict(body["errors"]) + body["errors"] = convert_to_dict(body["errors"]) if "view" in body: - body["view"] = _to_dict(body["view"]) + body["view"] = convert_to_dict(body["view"]) self.response = BoltResponse(status=200, body=body) return self.response diff --git a/slack_bolt/util/utils.py b/slack_bolt/util/utils.py index 55e971c7c..2289f85d4 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -14,10 +14,10 @@ def create_web_client(token: Optional[str] = None) -> WebClient: def convert_to_dict_list(objects: List[Union[Dict, JsonObject]]) -> List[Dict]: - return [_to_dict(elm) for elm in objects] + return [convert_to_dict(elm) for elm in objects] -def _to_dict(obj: Union[Dict, JsonObject]) -> Dict: +def convert_to_dict(obj: Union[Dict, JsonObject]) -> Dict: if isinstance(obj, dict): return obj if isinstance(obj, JsonObject) or hasattr(obj, "to_dict"): @@ -25,7 +25,7 @@ def _to_dict(obj: Union[Dict, JsonObject]) -> Dict: raise BoltError(f"{obj} (type: {type(obj)}) is unsupported") -def _copy_object(original: Any) -> Any: +def create_copy(original: Any) -> Any: if sys.version_info.major == 3 and sys.version_info.minor <= 6: # NOTE: Unfortunately, copy.deepcopy doesn't work in Python 3.6.5. # -------------------- From 5d02699c62647b225781457caa8f4c0d5f5b1003 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 10 Sep 2020 08:53:33 +0900 Subject: [PATCH 047/865] Remove unncessary code in ack.internals --- slack_bolt/context/ack/internals.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/slack_bolt/context/ack/internals.py b/slack_bolt/context/ack/internals.py index 6fdd0ca41..3b98fc5e7 100644 --- a/slack_bolt/context/ack/internals.py +++ b/slack_bolt/context/ack/internals.py @@ -78,14 +78,11 @@ def _set_response( body["options"] = convert_to_dict_list(body["options"]) if "option_groups" in body: body["option_groups"] = convert_to_dict_list(body["option_groups"]) - if "response_type" in body: - body["response_type"] = body["response_type"] - if "response_action" in body: - body["response_action"] = body["response_action"] if "errors" in body: body["errors"] = convert_to_dict(body["errors"]) if "view" in body: body["view"] = convert_to_dict(body["view"]) + # no modification for response_type, response_action here self.response = BoltResponse(status=200, body=body) return self.response From e78578a325efec5478cdfb976a26b42ccb0d1447 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Sep 2020 14:03:07 +0900 Subject: [PATCH 048/865] Fix #73 by adding oauth_settings to App and its underlying (#74) * Fix #73 by adding oauth_settings to App and its underlying * Remove oauth_state_store from App/AsyncApp args * Fix LambdaS3OAuthFlow --- samples/oauth_app.py | 1 - samples/oauth_app_settings.py | 108 +++++++ slack_bolt/adapter/aws_lambda/handler.py | 2 +- .../aws_lambda/lambda_s3_oauth_flow.py | 88 ++---- slack_bolt/app/app.py | 92 +----- slack_bolt/app/async_app.py | 106 ++----- slack_bolt/oauth/async_callback_options.py | 87 ++++++ slack_bolt/oauth/async_oauth_flow.py | 295 ++++++++---------- slack_bolt/oauth/async_oauth_settings.py | 122 ++++++++ slack_bolt/oauth/callback_options.py | 87 ++++++ slack_bolt/oauth/internals.py | 66 ++++ slack_bolt/oauth/oauth_flow.py | 293 ++++++++--------- slack_bolt/oauth/oauth_settings.py | 119 +++++++ tests/adapter_tests/test_async_fastapi.py | 9 +- tests/adapter_tests/test_async_starlette.py | 9 +- tests/adapter_tests/test_aws_lambda.py | 9 +- tests/adapter_tests/test_cherrypy_oauth.py | 13 +- tests/adapter_tests/test_fastapi.py | 9 +- tests/adapter_tests/test_flask.py | 9 +- tests/adapter_tests/test_starlette.py | 9 +- tests/async_scenario_tests/test_app.py | 27 +- tests/scenario_tests/test_app.py | 26 +- tests/slack_bolt/oauth/test_oauth_flow.py | 101 ++++-- .../oauth/test_async_oauth_flow.py | 106 +++++-- 24 files changed, 1155 insertions(+), 638 deletions(-) create mode 100644 samples/oauth_app_settings.py create mode 100644 slack_bolt/oauth/async_callback_options.py create mode 100644 slack_bolt/oauth/async_oauth_settings.py create mode 100644 slack_bolt/oauth/callback_options.py create mode 100644 slack_bolt/oauth/internals.py create mode 100644 slack_bolt/oauth/oauth_settings.py diff --git a/samples/oauth_app.py b/samples/oauth_app.py index d4c6b694a..596b52fad 100644 --- a/samples/oauth_app.py +++ b/samples/oauth_app.py @@ -81,7 +81,6 @@ def button_click(logger, body, ack, respond): # pip install slack_bolt # export SLACK_SIGNING_SECRET=*** -# export SLACK_BOT_TOKEN=xoxb-*** # export SLACK_CLIENT_ID=111.111 # export SLACK_CLIENT_SECRET=*** # export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write diff --git a/samples/oauth_app_settings.py b/samples/oauth_app_settings.py new file mode 100644 index 000000000..103cbe8ea --- /dev/null +++ b/samples/oauth_app_settings.py @@ -0,0 +1,108 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging +import os +from slack_bolt import App, BoltResponse +from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs +from slack_bolt.oauth.oauth_settings import OAuthSettings + +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +logging.basicConfig(level=logging.DEBUG) + +def success(args: SuccessArgs) -> BoltResponse: + return BoltResponse(status=200, body="Thanks!") + +def failure(args: FailureArgs) -> BoltResponse: + return BoltResponse(status=args.suggested_status_code, body=args.reason) + +app = App( + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), + installation_store=FileInstallationStore(), + oauth_settings=OAuthSettings( + client_id=os.environ.get("SLACK_CLIENT_ID"), + client_secret=os.environ.get("SLACK_CLIENT_SECRET"), + scopes=["app_mentions:read","channels:history","im:history","chat:write"], + user_scopes=[], + redirect_uri=None, + install_path="/slack/install", + redirect_uri_path="/slack/oauth_redirect", + state_store=FileOAuthStateStore(expiration_seconds=600), + callback_options=CallbackOptions( + success=success, + failure=failure + ) + ) +) + +@app.command("/hello-bolt-python") +def test_command(body, respond, client, ack, logger): + logger.info(body) + ack("Thanks!") + + respond( + blocks=[ + { + "type": "section", + "block_id": "b", + "text": { + "type": "mrkdwn", + "text": "You can add a button alongside text in your message. ", + }, + "accessory": { + "type": "button", + "action_id": "a", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + }, + } + ] + ) + + res = client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "view-id", + "title": {"type": "plain_text", "text": "My App",}, + "submit": {"type": "plain_text", "text": "Submit",}, + "close": {"type": "plain_text", "text": "Cancel",}, + "blocks": [ + { + "type": "input", + "element": {"type": "plain_text_input"}, + "label": {"type": "plain_text", "text": "Label",}, + } + ], + }, + ) + logger.info(res) + + +@app.view("view-id") +def view_submission(ack, body, logger): + logger.info(body) + return ack() + + +@app.action("a") +def button_click(logger, body, ack, respond): + logger.info(body) + respond("respond!") + ack() + + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# python oauth_app.py diff --git a/slack_bolt/adapter/aws_lambda/handler.py b/slack_bolt/adapter/aws_lambda/handler.py index 7b7adbecb..93f8c79ae 100644 --- a/slack_bolt/adapter/aws_lambda/handler.py +++ b/slack_bolt/adapter/aws_lambda/handler.py @@ -17,7 +17,7 @@ def __init__(self, app: App): # type: ignore self.logger = get_bolt_app_logger(app.name, SlackRequestHandler) self.app.lazy_listener_runner = LambdaLazyListenerRunner(self.logger) if self.app.oauth_flow is not None: - self.app.oauth_flow.redirect_uri_page_renderer.install_path = "?" + self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?" @classmethod def clear_all_log_handlers(cls): diff --git a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py index 19b62d7fd..66ac7e155 100644 --- a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py +++ b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py @@ -10,6 +10,7 @@ from slack_sdk.oauth.installation_store.amazon_s3 import AmazonS3InstallationStore from slack_sdk.oauth.state_store.amazon_s3 import AmazonS3OAuthStateStore +from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.util.utils import create_web_client @@ -19,73 +20,42 @@ def __init__( *, client: Optional[WebClient] = None, logger: Optional[Logger] = None, + settings: Optional[OAuthSettings] = None, oauth_state_bucket_name: Optional[str] = None, # required installation_bucket_name: Optional[str] = None, # required - oauth_state_cookie_name: str = "slack-app-oauth-state", - oauth_state_expiration_seconds: int = 60 * 10, # 10 minutes - client_id: Optional[str] = None, # required - client_secret: Optional[str] = None, # required - scopes: Optional[str] = None, # required - user_scopes: Optional[str] = None, - redirect_uri: Optional[str] = None, - install_path: Optional[str] = None, # required - redirect_uri_path: Optional[str] = None, # required - success_url: Optional[str] = None, - failure_url: Optional[str] = None, ): - self._client = client - self._logger = logger - - self.s3_client = boto3.client("s3") - + logger = logger or logging.getLogger(__name__) + settings = settings or OAuthSettings( + client_id=os.environ["SLACK_CLIENT_ID"], + client_secret=os.environ["SLACK_CLIENT_SECRET"], + ) oauth_state_bucket_name = ( - oauth_state_bucket_name - or os.environ["SLACK_STATE_S3_BUCKET_NAME"] # required + oauth_state_bucket_name or os.environ["SLACK_STATE_S3_BUCKET_NAME"] ) installation_bucket_name = ( - installation_bucket_name - or os.environ["SLACK_INSTALLATION_S3_BUCKET_NAME"] # required - ) - - client_id = client_id or os.environ["SLACK_CLIENT_ID"] # required - client_secret = client_secret or os.environ["SLACK_CLIENT_SECRET"] # required - scopes = scopes or os.environ.get("SLACK_SCOPES", None) - user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", None) - redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) - install_path = install_path or os.environ.get( - "SLACK_LAMBDA_PATH", "/slack/install" - ) - redirect_uri_path = redirect_uri_path or os.environ.get( - "SLACK_LAMBDA_PATH", "/slack/oauth_redirect" + installation_bucket_name or os.environ["SLACK_INSTALLATION_S3_BUCKET_NAME"] ) + self.s3_client = boto3.client("s3") + if settings.state_store is None or not isinstance( + settings.state_store, AmazonS3OAuthStateStore + ): + settings.state_store = AmazonS3OAuthStateStore( + logger=logger, + s3_client=self.s3_client, + bucket_name=oauth_state_bucket_name, + expiration_seconds=settings.state_expiration_seconds, + ) - self.oauth_state_store = AmazonS3OAuthStateStore( - logger=self.logger, - s3_client=self.s3_client, - bucket_name=oauth_state_bucket_name, - expiration_seconds=oauth_state_expiration_seconds, - ) - self.installation_store = AmazonS3InstallationStore( - logger=self.logger, - s3_client=self.s3_client, - bucket_name=installation_bucket_name, - client_id=client_id, - ) - self.oauth_state_cookie_name = oauth_state_cookie_name - self.oauth_state_expiration_seconds = oauth_state_expiration_seconds - - self.client_id = client_id - self.client_secret = client_secret - self.scopes = scopes.split(",") if scopes else None - self.user_scopes = user_scopes.split(",") if user_scopes else None - self.redirect_uri = redirect_uri - - self.install_path = install_path - self.redirect_uri_path = redirect_uri_path - self.success_url = success_url - self.failure_url = failure_url - - self._init_internal_utils() + if settings.installation_store is None or not isinstance( + settings.installation_store, AmazonS3InstallationStore + ): + settings.installation_store = AmazonS3InstallationStore( + logger=logger, + s3_client=self.s3_client, + bucket_name=installation_bucket_name, + client_id=settings.client_id, + ) + OAuthFlow.__init__(self, client=client, logger=logger, settings=settings) @property def client(self) -> WebClient: diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 5b904f769..7918a3826 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -7,9 +7,8 @@ from http.server import SimpleHTTPRequestHandler, HTTPServer from typing import List, Union, Pattern, Callable, Dict, Optional -from slack_sdk.oauth import OAuthStateUtils -from slack_sdk.oauth.installation_store import InstallationStore, FileInstallationStore -from slack_sdk.oauth.state_store import OAuthStateStore, FileOAuthStateStore +from slack_sdk.oauth.installation_store import InstallationStore +from slack_sdk.oauth.state_store import OAuthStateStore from slack_sdk.web import WebClient from slack_bolt.error import BoltError @@ -38,6 +37,7 @@ from slack_bolt.middleware.message_listener_matches import MessageListenerMatches from slack_bolt.middleware.url_verification import UrlVerification from slack_bolt.oauth import OAuthFlow +from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from slack_bolt.util.utils import create_web_client, create_copy @@ -58,21 +58,10 @@ def __init__( client: Optional[WebClient] = None, # for multi-workspace apps installation_store: Optional[InstallationStore] = None, - oauth_state_store: Optional[OAuthStateStore] = None, - oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, - oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, # for the OAuth flow + oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, authorization_test_enabled: bool = True, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, - redirect_uri: Optional[str] = None, - oauth_install_path: Optional[str] = None, - oauth_redirect_uri_path: Optional[str] = None, - oauth_success_url: Optional[str] = None, - oauth_failure_url: Optional[str] = None, # No need to set (the value is used only in response to ssl_check requests) verification_token: Optional[str] = None, ): @@ -87,18 +76,6 @@ def __init__( self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] self._signing_secret: str = signing_secret - client_id = client_id or os.environ.get("SLACK_CLIENT_ID", None) - client_secret = client_secret or os.environ.get("SLACK_CLIENT_SECRET", None) - scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") - user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",") - redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) - oauth_install_path = oauth_install_path or os.environ.get( - "SLACK_INSTALL_PATH", "/slack/install" - ) - oauth_redirect_uri_path = oauth_redirect_uri_path or os.environ.get( - "SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect" - ) - self._verification_token: Optional[str] = verification_token or os.environ.get( "SLACK_VERIFICATION_TOKEN", None ) @@ -119,64 +96,23 @@ def __init__( self._client = create_web_client(token) # NOTE: the token here can be None self._installation_store: Optional[InstallationStore] = installation_store - self._oauth_state_store: Optional[OAuthStateStore] = oauth_state_store - - self._oauth_state_cookie_name = oauth_state_cookie_name - self._oauth_state_expiration_seconds = oauth_state_expiration_seconds self._oauth_flow: Optional[OAuthFlow] = None self._authorization_test_enabled = authorization_test_enabled if oauth_flow: self._oauth_flow = oauth_flow if self._installation_store is None: - self._installation_store = self._oauth_flow.installation_store - if self._oauth_state_store is None: - self._oauth_state_store = self._oauth_flow.oauth_state_store + self._installation_store = self._oauth_flow.settings.installation_store if self._oauth_flow._client is None: self._oauth_flow._client = self._client - else: - if client_id is not None and client_secret is not None: - # The OAuth flow support is enabled - if self._installation_store is None and self._oauth_state_store is None: - # use the default ones - self._installation_store = FileInstallationStore( - client_id=client_id, - ) - self._oauth_state_store = FileOAuthStateStore( - expiration_seconds=self._oauth_state_expiration_seconds, - client_id=client_id, - ) + elif oauth_settings is not None: + if self._installation_store: + # Consistently use a single installation_store + oauth_settings.installation_store = self._installation_store - if ( - self._installation_store is not None - and self._oauth_state_store is None - ): - raise ValueError( - f"Configure an appropriate OAuthStateStore for {self._installation_store}" - ) - - self._oauth_flow = OAuthFlow( - client=create_web_client(), - logger=self._framework_logger, - # required storage implementations - installation_store=self._installation_store, - oauth_state_store=self._oauth_state_store, - oauth_state_cookie_name=self._oauth_state_cookie_name, - oauth_state_expiration_seconds=self._oauth_state_expiration_seconds, - # used for oauth.v2.access calls - client_id=client_id, - client_secret=client_secret, - # installation url parameters - scopes=scopes, - user_scopes=user_scopes, - redirect_uri=redirect_uri, - # path in this app - install_path=oauth_install_path, - redirect_uri_path=oauth_redirect_uri_path, - # urls after callback - success_url=oauth_success_url, - failure_url=oauth_failure_url, - ) + self._oauth_flow = OAuthFlow( + client=self.client, logger=self.logger, settings=oauth_settings + ) if self._installation_store is not None and self._token is not None: self._token = None @@ -248,10 +184,6 @@ def client(self) -> WebClient: def installation_store(self) -> Optional[InstallationStore]: return self._installation_store - @property - def oauth_state_store(self) -> Optional[OAuthStateStore]: - return self._oauth_state_store - @property def listener_error_handler(self) -> ListenerErrorHandler: return self._listener_error_handler diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 8eb2c3d0b..b11de7e44 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -6,13 +6,10 @@ from asyncio import Future from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable -from slack_sdk.oauth import OAuthStateUtils -from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth import OAuthStateStore from slack_sdk.oauth.installation_store.async_installation_store import ( AsyncInstallationStore, ) -from slack_sdk.oauth.state_store import FileOAuthStateStore -from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.context.ack.async_ack import AsyncAck @@ -49,6 +46,7 @@ AsyncSingleTeamAuthorization, ) from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from slack_bolt.util.async_utils import create_async_web_client @@ -70,20 +68,10 @@ def __init__( client: Optional[AsyncWebClient] = None, # for multi-workspace apps installation_store: Optional[AsyncInstallationStore] = None, - oauth_state_store: Optional[AsyncOAuthStateStore] = None, - oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, - oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, # for the OAuth flow + oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, - redirect_uri: Optional[str] = None, - oauth_install_path: Optional[str] = None, - oauth_redirect_uri_path: Optional[str] = None, - oauth_success_url: Optional[str] = None, - oauth_failure_url: Optional[str] = None, + authorization_test_enabled: bool = True, # No need to set (the value is used only in response to ssl_check requests) verification_token: Optional[str] = None, ): @@ -97,19 +85,6 @@ def __init__( self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] self._signing_secret: str = signing_secret - - client_id = client_id or os.environ.get("SLACK_CLIENT_ID", None) - client_secret = client_secret or os.environ.get("SLACK_CLIENT_SECRET", None) - scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") - user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",") - redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) - oauth_install_path = oauth_install_path or os.environ.get( - "SLACK_INSTALL_PATH", "/slack/install" - ) - oauth_redirect_uri_path = oauth_redirect_uri_path or os.environ.get( - "SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect" - ) - self._verification_token: Optional[str] = verification_token or os.environ.get( "SLACK_VERIFICATION_TOKEN", None ) @@ -130,73 +105,29 @@ def __init__( # NOTE: the token here can be None self._async_client = create_async_web_client(token) + self._authorization_test_enabled = authorization_test_enabled + self._async_installation_store: Optional[ AsyncInstallationStore ] = installation_store - self._async_oauth_state_store: Optional[ - AsyncOAuthStateStore - ] = oauth_state_store - - self._oauth_state_cookie_name = oauth_state_cookie_name - self._oauth_state_expiration_seconds = oauth_state_expiration_seconds self._async_oauth_flow: Optional[AsyncOAuthFlow] = None if oauth_flow: self._async_oauth_flow = oauth_flow if self._async_installation_store is None: self._async_installation_store = ( - self._async_oauth_flow.installation_store + self._async_oauth_flow.settings.installation_store ) - if self._async_oauth_state_store is None: - self._async_oauth_state_store = self._async_oauth_flow.oauth_state_store if self._async_oauth_flow._async_client is None: self._async_oauth_flow._async_client = self._async_client - else: - if client_id is not None and client_secret is not None: - # The OAuth flow support is enabled - if ( - self._async_installation_store is None - and self._async_oauth_state_store is None - ): - # use the default ones - self._async_installation_store = FileInstallationStore( - client_id=client_id, - ) - self._async_oauth_state_store = FileOAuthStateStore( - expiration_seconds=self._oauth_state_expiration_seconds, - client_id=client_id, - ) + elif oauth_settings is not None: + if self._async_installation_store: + # Consistently use a single installation_store + oauth_settings.installation_store = self._async_installation_store - if ( - self._async_installation_store is not None - and self._async_oauth_state_store is None - ): - raise ValueError( - f"Configure an appropriate OAuthStateStore for {self._async_installation_store}" - ) - - self._async_oauth_flow = AsyncOAuthFlow( - client=create_async_web_client(), - logger=self._framework_logger, - # required storage implementations - installation_store=self._async_installation_store, - oauth_state_store=self._async_oauth_state_store, - oauth_state_cookie_name=self._oauth_state_cookie_name, - oauth_state_expiration_seconds=self._oauth_state_expiration_seconds, - # used for oauth.v2.access calls - client_id=client_id, - client_secret=client_secret, - # installation url parameters - scopes=scopes, - user_scopes=user_scopes, - redirect_uri=redirect_uri, - # path in this app - install_path=oauth_install_path, - redirect_uri_path=oauth_redirect_uri_path, - # urls after callback - success_url=oauth_success_url, - failure_url=oauth_failure_url, - ) + self._async_oauth_flow = AsyncOAuthFlow( + client=self._async_client, logger=self.logger, settings=oauth_settings + ) if self._async_installation_store is not None and self._token is not None: self._token = None @@ -235,7 +166,10 @@ def _init_async_middleware_list(self): ) else: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(self._async_installation_store) + AsyncMultiTeamsAuthorization( + installation_store=self._async_installation_store, + verification_enabled=self._authorization_test_enabled, + ) ) self._async_middleware_list.append(AsyncIgnoringSelfEvents()) @@ -265,10 +199,6 @@ def logger(self) -> logging.Logger: def installation_store(self) -> Optional[AsyncInstallationStore]: return self._async_installation_store - @property - def oauth_state_store(self) -> Optional[AsyncOAuthStateStore]: - return self._async_oauth_state_store - @property def listener_error_handler(self) -> AsyncListenerErrorHandler: return self._async_listener_error_handler diff --git a/slack_bolt/oauth/async_callback_options.py b/slack_bolt/oauth/async_callback_options.py new file mode 100644 index 000000000..17e21d16d --- /dev/null +++ b/slack_bolt/oauth/async_callback_options.py @@ -0,0 +1,87 @@ +import logging +from logging import Logger +from typing import Optional, Callable, Awaitable + +from slack_sdk.oauth import RedirectUriPageRenderer, OAuthStateUtils +from slack_sdk.oauth.installation_store import Installation +from slack_bolt.oauth.internals import CallbackResponseBuilder + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse + + +class AsyncSuccessArgs: + def __init__( # type: ignore + self, + *, + request: AsyncBoltRequest, + installation: Installation, + settings: "AsyncOAuthSettings", + ): + self.request = request + self.installation = installation + self.settings = settings + + +class AsyncFailureArgs: + def __init__( # type: ignore + self, + *, + request: AsyncBoltRequest, + reason: str, + error: Optional[Exception] = None, + suggested_status_code: int, + settings: "AsyncOAuthSettings", + ): + self.request = request + self.reason = reason + self.error = error + self.suggested_status_code = suggested_status_code + self.settings = settings + + +class AsyncCallbackOptions: + success: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]] + failure: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]] + + def __init__( + self, + success: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]], + failure: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]], + ): + self.success = success + self.failure = failure + + +class DefaultAsyncCallbackOptions(AsyncCallbackOptions): + success: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]] + failure: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]] + + def __init__( + self, + *, + logger: Logger, + state_utils: OAuthStateUtils, + redirect_uri_page_renderer: RedirectUriPageRenderer, + ): + self._response_builder = CallbackResponseBuilder( + logger=logger or logging.getLogger(__name__), + state_utils=state_utils, + redirect_uri_page_renderer=redirect_uri_page_renderer, + ) + self.success = self._success_handler + self.failure = self._failure_handler + + # -------------------------- + # Internal methods + # -------------------------- + + async def _success_handler(self, args: AsyncSuccessArgs) -> BoltResponse: + return self._response_builder._build_callback_success_response( + request=args.request, installation=args.installation, + ) + + async def _failure_handler(self, args: AsyncFailureArgs) -> BoltResponse: + return self._response_builder._build_callback_failure_response( + request=args.request, reason=args.reason, status=args.suggested_status_code, + ) diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 1b26128c0..4f9dfb509 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -1,23 +1,22 @@ import logging import os from logging import Logger -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Callable, Awaitable from slack_bolt.error import BoltError +from slack_bolt.oauth.async_callback_options import ( + AsyncCallbackOptions, + DefaultAsyncCallbackOptions, + AsyncSuccessArgs, + AsyncFailureArgs, +) +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from slack_sdk.errors import SlackApiError -from slack_sdk.oauth import ( - AuthorizeUrlGenerator, - OAuthStateUtils, - RedirectUriPageRenderer, -) +from slack_sdk.oauth import OAuthStateUtils from slack_sdk.oauth.installation_store import Installation -from slack_sdk.oauth.installation_store.async_installation_store import ( - AsyncInstallationStore, -) from slack_sdk.oauth.installation_store.sqlite3 import SQLite3InstallationStore -from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore from slack_sdk.oauth.state_store.sqlite3 import SQLite3OAuthStateStore from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse @@ -26,24 +25,14 @@ class AsyncOAuthFlow: - installation_store: AsyncInstallationStore - oauth_state_store: AsyncOAuthStateStore - oauth_state_cookie_name: str - oauth_state_expiration_seconds: int - + settings: AsyncOAuthSettings client_id: str - client_secret: str redirect_uri: Optional[str] - scopes: Optional[List[str]] - user_scopes: Optional[List[str]] - install_path: str redirect_uri_path: str - success_url: Optional[str] - failure_url: Optional[str] - oauth_state_utils: OAuthStateUtils - authorize_url_generator: AuthorizeUrlGenerator - redirect_uri_page_renderer: RedirectUriPageRenderer + + success_handler: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]] + failure_handler: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]] @property def client(self) -> AsyncWebClient: @@ -62,59 +51,24 @@ def __init__( *, client: Optional[AsyncWebClient] = None, logger: Optional[Logger] = None, - installation_store: AsyncInstallationStore, - oauth_state_store: AsyncOAuthStateStore, - oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, - oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, - client_id: str, - client_secret: str, - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, - redirect_uri: Optional[str] = None, - install_path: str = "/slack/install", - redirect_uri_path: str = "/slack/oauth_redirect", - success_url: Optional[str] = None, - failure_url: Optional[str] = None, + settings: AsyncOAuthSettings, ): self._async_client = client self._logger = logger - - self.installation_store = installation_store - self.oauth_state_store = oauth_state_store - self.oauth_state_cookie_name = oauth_state_cookie_name - self.oauth_state_expiration_seconds = oauth_state_expiration_seconds - - self.client_id = client_id - self.client_secret = client_secret - self.redirect_uri = redirect_uri - self.scopes = scopes - self.user_scopes = user_scopes - - self.install_path = install_path - self.redirect_uri_path = redirect_uri_path - self.success_url = success_url - self.failure_url = failure_url - - self._init_internal_utils() - - def _init_internal_utils(self): - self.oauth_state_utils = OAuthStateUtils( - cookie_name=self.oauth_state_cookie_name, - expiration_seconds=self.oauth_state_expiration_seconds, - ) - self.authorize_url_generator = AuthorizeUrlGenerator( - client_id=self.client_id, - client_secret=self.client_secret, - redirect_uri=self.redirect_uri, - scopes=self.scopes, - user_scopes=self.user_scopes, - ) - self.redirect_uri_page_renderer = RedirectUriPageRenderer( - install_path=self.install_path, - redirect_uri_path=self.redirect_uri_path, - success_url=self.success_url, - failure_url=self.failure_url, - ) + self.settings = settings + self.client_id = self.settings.client_id + self.redirect_uri = self.settings.redirect_uri + self.install_path = self.settings.install_path + self.redirect_uri_path = self.settings.redirect_uri_path + + if settings.callback_options is None: + settings.callback_options = DefaultAsyncCallbackOptions( + logger=logger, + state_utils=self.settings.state_utils, + redirect_uri_page_renderer=self.settings.redirect_uri_page_renderer, + ) + self.success_handler = settings.callback_options.success + self.failure_handler = settings.callback_options.failure # ----------------------------- # Factory Methods @@ -124,13 +78,23 @@ def _init_internal_utils(self): def sqlite3( cls, database: str, + # OAuth flow parameters/credentials + authorization_url: Optional[str] = None, client_id: Optional[str] = None, # required client_secret: Optional[str] = None, # required scopes: Optional[List[str]] = None, user_scopes: Optional[List[str]] = None, redirect_uri: Optional[str] = None, - oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, - oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + # Handler configuration + install_path: Optional[str] = None, + redirect_uri_path: Optional[str] = None, + callback_options: Optional[AsyncCallbackOptions] = None, + success_url: Optional[str] = None, + failure_url: Optional[str] = None, + # Installation Management + # state parameter related configurations + state_cookie_name: str = OAuthStateUtils.default_cookie_name, + state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, logger: Optional[Logger] = None, ) -> "AsyncOAuthFlow": @@ -140,23 +104,35 @@ def sqlite3( user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",") redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) return AsyncOAuthFlow( - client=create_async_web_client(), + client=AsyncWebClient(), logger=logger, - installation_store=SQLite3InstallationStore( - database=database, client_id=client_id, logger=logger, - ), - oauth_state_store=SQLite3OAuthStateStore( - database=database, - expiration_seconds=oauth_state_expiration_seconds, - logger=logger, + settings=AsyncOAuthSettings( + # OAuth flow parameters/credentials + authorization_url=authorization_url, + client_id=client_id, + client_secret=client_secret, + scopes=scopes, + user_scopes=user_scopes, + redirect_uri=redirect_uri, + # Handler configuration + install_path=install_path, + redirect_uri_path=redirect_uri_path, + callback_options=callback_options, + success_url=success_url, + failure_url=failure_url, + # Installation Management + installation_store=SQLite3InstallationStore( + database=database, client_id=client_id, logger=logger, + ), + # state parameter related configurations + state_store=SQLite3OAuthStateStore( + database=database, + expiration_seconds=state_expiration_seconds, + logger=logger, + ), + state_cookie_name=state_cookie_name, + state_expiration_seconds=state_expiration_seconds, ), - oauth_state_cookie_name=oauth_state_cookie_name, - oauth_state_expiration_seconds=oauth_state_expiration_seconds, - client_id=client_id, - client_secret=client_secret, - scopes=scopes, - user_scopes=user_scopes, - redirect_uri=redirect_uri, ) # ----------------------------- @@ -171,7 +147,7 @@ async def handle_installation(self, request: BoltRequest) -> BoltResponse: # Internal methods for Installation async def issue_new_state(self, request: BoltRequest) -> str: - return await self.oauth_state_store.async_issue() + return await self.settings.state_store.async_issue() async def build_authorize_url_redirection( self, request: BoltRequest, state: str @@ -179,9 +155,9 @@ async def build_authorize_url_redirection( return BoltResponse( status=302, headers={ - "Location": [self.authorize_url_generator.generate(state)], + "Location": [self.settings.authorize_url_generator.generate(state)], "Set-Cookie": [ - self.oauth_state_utils.build_set_cookie_for_new_state(state) + self.settings.state_utils.build_set_cookie_for_new_state(state) ], }, ) @@ -195,46 +171,82 @@ async def handle_callback(self, request: BoltRequest) -> BoltResponse: # failure due to end-user's cancellation or invalid redirection to slack.com error = request.query.get("error", [None])[0] if error is not None: - return await self.build_callback_failure_response( - request, reason=error, status=200 + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason=error, + suggested_status_code=200, + settings=self.settings, + ) ) # state parameter verification state = request.query.get("state", [None])[0] - if not self.oauth_state_utils.is_valid_browser(state, request.headers): - return await self.build_callback_failure_response( - request, reason="invalid_browser", status=400 + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + ) ) - valid_state_consumed = await self.oauth_state_store.async_consume(state) + valid_state_consumed = await self.settings.state_store.async_consume(state) if not valid_state_consumed: - return await self.build_callback_failure_response( - request, reason="invalid_state", status=401 + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_state", + suggested_status_code=401, + settings=self.settings, + ) ) # run installation code = request.query.get("code", [None])[0] if code is None: - return await self.build_callback_failure_response( - request, reason="missing_code", status=401 + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="missing_code", + suggested_status_code=401, + settings=self.settings, + ) ) + installation = await self.run_installation(code) if installation is None: # failed to run installation with the code - return await self.build_callback_failure_response( - request, reason="invalid_code", status=401 + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_code", + suggested_status_code=401, + settings=self.settings, + ) ) # persist the installation try: await self.store_installation(request, installation) - except BoltError as e: - return await self.build_callback_failure_response( - request, reason="storage_error", error=e + except BoltError as err: + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="storage_error", + error=err, + suggested_status_code=500, + settings=self.settings, + ) ) # display a successful completion page to the end-user - return await self.build_callback_success_response(request, installation) + return await self.success_handler( + AsyncSuccessArgs( + request=request, installation=installation, settings=self.settings, + ) + ) # ---------------------- # Internal methods for Callback @@ -243,16 +255,18 @@ async def run_installation(self, code: str) -> Optional[Installation]: try: oauth_response: AsyncSlackResponse = await self.client.oauth_v2_access( code=code, - client_id=self.client_id, - client_secret=self.client_secret, - redirect_uri=self.redirect_uri, # can be None + client_id=self.settings.client_id, + client_secret=self.settings.client_secret, + redirect_uri=self.settings.redirect_uri, # can be None ) - installed_enterprise: Dict[str, str] = oauth_response.get("enterprise", {}) - installed_team: Dict[str, str] = oauth_response.get("team", {}) - installer: Dict[str, str] = oauth_response.get("authed_user", {}) + installed_enterprise: Dict[str, str] = oauth_response.get( + "enterprise" + ) or {} + installed_team: Dict[str, str] = oauth_response.get("team") or {} + installer: Dict[str, str] = oauth_response.get("authed_user") or {} incoming_webhook: Dict[str, str] = oauth_response.get( - "incoming_webhook", {} - ) + "incoming_webhook" + ) or {} bot_token: Optional[str] = oauth_response.get("access_token", None) # NOTE: oauth.v2.access doesn't include bot_id in response @@ -290,47 +304,4 @@ async def store_installation( self, request: BoltRequest, installation: Installation ): # may raise BoltError - await self.installation_store.async_save(installation) - - async def build_callback_failure_response( - self, - request: BoltRequest, - reason: str, - status: int = 500, - error: Optional[Exception] = None, - ) -> BoltResponse: - debug_message = ( - "Handling an OAuth callback failure " - f"(reason: {reason}, error: {error}, request: {request.query})" - ) - self.logger.debug(debug_message) - - html = self.redirect_uri_page_renderer.render_failure_page(reason) - return BoltResponse( - status=status, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(html), - "Set-Cookie": self.oauth_state_utils.build_set_cookie_for_deletion(), - }, - body=html, - ) - - async def build_callback_success_response( - self, request: BoltRequest, installation: Installation, - ) -> BoltResponse: - debug_message = f"Handling an OAuth callback success (request: {request.query})" - self.logger.debug(debug_message) - - html = self.redirect_uri_page_renderer.render_success_page( - app_id=installation.app_id, team_id=installation.team_id, - ) - return BoltResponse( - status=200, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(html), - "Set-Cookie": self.oauth_state_utils.build_set_cookie_for_deletion(), - }, - body=html, - ) + await self.settings.installation_store.async_save(installation) diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py new file mode 100644 index 000000000..fefdaf76c --- /dev/null +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -0,0 +1,122 @@ +import os +from typing import List, Optional + +from slack_sdk.oauth import ( + OAuthStateUtils, + AuthorizeUrlGenerator, + RedirectUriPageRenderer, +) +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore + +from slack_bolt.error import BoltError +from slack_bolt.oauth.callback_options import CallbackOptions + + +class AsyncOAuthSettings: + # OAuth flow parameters/credentials + client_id: str + client_secret: str + scopes: Optional[List[str]] + user_scopes: Optional[List[str]] + redirect_uri: Optional[str] + # Handler configuration + install_path: str + redirect_uri_path: str + callback_options: Optional[CallbackOptions] = None + success_url: Optional[str] + failure_url: Optional[str] + authorization_url: str # default: https://slack.com/oauth/v2/authorize + # Installation Management + installation_store: AsyncInstallationStore + # state parameter related configurations + state_store: AsyncOAuthStateStore + state_cookie_name: str + state_expiration_seconds: int + # Customizable utilities + state_utils: OAuthStateUtils + authorize_url_generator: AuthorizeUrlGenerator + redirect_uri_page_renderer: RedirectUriPageRenderer + + def __init__( + self, + *, + # OAuth flow parameters/credentials + client_id: str, + client_secret: str, + scopes: Optional[List[str]] = None, + user_scopes: Optional[List[str]] = None, + redirect_uri: Optional[str] = None, + # Handler configuration + install_path: str = "/slack/install", + redirect_uri_path: str = "/slack/oauth_redirect", + callback_options: Optional[CallbackOptions] = None, + success_url: Optional[str] = None, + failure_url: Optional[str] = None, + authorization_url: Optional[str] = None, + # Installation Management + installation_store: Optional[AsyncInstallationStore] = None, + # state parameter related configurations + state_store: Optional[AsyncOAuthStateStore] = None, + state_cookie_name: str = OAuthStateUtils.default_cookie_name, + state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + ): + # OAuth flow parameters/credentials + self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID", None) + self.client_secret = client_secret or os.environ.get( + "SLACK_CLIENT_SECRET", None + ) + if self.client_id is None or self.client_secret is None: + raise BoltError("Both client_id and client_secret are required") + + self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( + "," + ) + self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) + # Handler configuration + self.install_path = install_path or os.environ.get( + "SLACK_INSTALL_PATH", "/slack/install" + ) + self.redirect_uri_path = redirect_uri_path or os.environ.get( + "SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect" + ) + self.callback_options = callback_options + self.success_url = success_url + self.failure_url = failure_url + self.authorization_url = ( + authorization_url or "https://slack.com/oauth/v2/authorize" + ) + # Installation Management + self.installation_store = installation_store or FileInstallationStore( + client_id=client_id + ) + # state parameter related configurations + self.state_store = state_store or FileOAuthStateStore( + expiration_seconds=state_expiration_seconds, client_id=client_id, + ) + self.state_cookie_name = state_cookie_name + self.state_expiration_seconds = state_expiration_seconds + + self.state_utils = OAuthStateUtils( + cookie_name=self.state_cookie_name, + expiration_seconds=self.state_expiration_seconds, + ) + # TODO: add authorization_url support on the slack-sdk side + self.authorize_url_generator = AuthorizeUrlGenerator( + client_id=self.client_id, + client_secret=self.client_secret, + redirect_uri=self.redirect_uri, + scopes=self.scopes, + user_scopes=self.user_scopes, + ) + self.redirect_uri_page_renderer = RedirectUriPageRenderer( + install_path=self.install_path, + redirect_uri_path=self.redirect_uri_path, + success_url=self.success_url, + failure_url=self.failure_url, + ) diff --git a/slack_bolt/oauth/callback_options.py b/slack_bolt/oauth/callback_options.py new file mode 100644 index 000000000..5056d5ad8 --- /dev/null +++ b/slack_bolt/oauth/callback_options.py @@ -0,0 +1,87 @@ +import logging +from logging import Logger +from typing import Optional, Callable + +from slack_sdk.oauth import RedirectUriPageRenderer, OAuthStateUtils +from slack_sdk.oauth.installation_store import Installation + +from slack_bolt.oauth.internals import CallbackResponseBuilder +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +class SuccessArgs: + def __init__( # type: ignore + self, + *, + request: BoltRequest, + installation: Installation, + settings: "OAuthSettings", + ): + self.request = request + self.installation = installation + self.settings = settings + + +class FailureArgs: + def __init__( # type: ignore + self, + *, + request: BoltRequest, + reason: str, + error: Optional[Exception] = None, + suggested_status_code: int, + settings: "OAuthSettings", + ): + self.request = request + self.reason = reason + self.error = error + self.suggested_status_code = suggested_status_code + self.settings = settings + + +class CallbackOptions: + success: Callable[[SuccessArgs], BoltResponse] + failure: Callable[[FailureArgs], BoltResponse] + + def __init__( + self, + success: Callable[[SuccessArgs], BoltResponse], + failure: Callable[[FailureArgs], BoltResponse], + ): + self.success = success + self.failure = failure + + +class DefaultCallbackOptions(CallbackOptions): + success: Callable[[SuccessArgs], BoltResponse] + failure: Callable[[FailureArgs], BoltResponse] + + def __init__( + self, + *, + logger: Logger, + state_utils: OAuthStateUtils, + redirect_uri_page_renderer: RedirectUriPageRenderer, + ): + self._response_builder = CallbackResponseBuilder( + logger=logger or logging.getLogger(__name__), + state_utils=state_utils, + redirect_uri_page_renderer=redirect_uri_page_renderer, + ) + self.success = self._success_handler + self.failure = self._failure_handler + + # -------------------------- + # Internal methods + # -------------------------- + + def _success_handler(self, args: SuccessArgs) -> BoltResponse: + return self._response_builder._build_callback_success_response( + request=args.request, installation=args.installation, + ) + + def _failure_handler(self, args: FailureArgs) -> BoltResponse: + return self._response_builder._build_callback_failure_response( + request=args.request, reason=args.reason, status=args.suggested_status_code, + ) diff --git a/slack_bolt/oauth/internals.py b/slack_bolt/oauth/internals.py new file mode 100644 index 000000000..645f350a3 --- /dev/null +++ b/slack_bolt/oauth/internals.py @@ -0,0 +1,66 @@ +from logging import Logger +from typing import Optional, Union + +from slack_sdk.oauth import OAuthStateUtils, RedirectUriPageRenderer +from slack_sdk.oauth.installation_store import Installation + +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +class CallbackResponseBuilder: + def __init__( + self, + *, + logger: Logger, + state_utils: OAuthStateUtils, + redirect_uri_page_renderer: RedirectUriPageRenderer, + ): + self._logger = logger + self._state_utils = state_utils + self._redirect_uri_page_renderer = redirect_uri_page_renderer + + def _build_callback_success_response( # type: ignore + self, + request: Union[BoltRequest, "AsyncBoltRequest"], + installation: Installation, + ) -> BoltResponse: + debug_message = f"Handling an OAuth callback success (request: {request.query})" + self._logger.debug(debug_message) + + html = self._redirect_uri_page_renderer.render_success_page( + app_id=installation.app_id, team_id=installation.team_id, + ) + return BoltResponse( + status=200, + headers={ + "Content-Type": "text/html; charset=utf-8", + "Content-Length": len(html), + "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), + }, + body=html, + ) + + def _build_callback_failure_response( # type: ignore + self, + request: Union[BoltRequest, "AsyncBoltRequest"], + reason: str, + status: int = 500, + error: Optional[Exception] = None, + ) -> BoltResponse: + debug_message = ( + "Handling an OAuth callback failure " + f"(reason: {reason}, error: {error}, request: {request.query})" + ) + self._logger.debug(debug_message) + + html = self._redirect_uri_page_renderer.render_failure_page(reason) + return BoltResponse( + status=status, + headers={ + "Content-Type": "text/html; charset=utf-8", + "Content-Length": len(html), + "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), + }, + body=html, + ) diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index d94ccb6ed..8968363f3 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -1,20 +1,23 @@ import logging import os from logging import Logger -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Callable from slack_bolt.error import BoltError +from slack_bolt.oauth.callback_options import ( + FailureArgs, + SuccessArgs, + DefaultCallbackOptions, + CallbackOptions, +) + +from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from slack_sdk.errors import SlackApiError -from slack_sdk.oauth import ( - AuthorizeUrlGenerator, - OAuthStateUtils, - RedirectUriPageRenderer, -) -from slack_sdk.oauth.installation_store import InstallationStore, Installation +from slack_sdk.oauth import OAuthStateUtils +from slack_sdk.oauth.installation_store import Installation from slack_sdk.oauth.installation_store.sqlite3 import SQLite3InstallationStore -from slack_sdk.oauth.state_store import OAuthStateStore from slack_sdk.oauth.state_store.sqlite3 import SQLite3OAuthStateStore from slack_sdk.web import WebClient, SlackResponse @@ -22,24 +25,14 @@ class OAuthFlow: - installation_store: InstallationStore - oauth_state_store: OAuthStateStore - oauth_state_cookie_name: str - oauth_state_expiration_seconds: int - + settings: OAuthSettings client_id: str - client_secret: str redirect_uri: Optional[str] - scopes: Optional[List[str]] - user_scopes: Optional[List[str]] - install_path: str redirect_uri_path: str - success_url: Optional[str] - failure_url: Optional[str] - oauth_state_utils: OAuthStateUtils - authorize_url_generator: AuthorizeUrlGenerator - redirect_uri_page_renderer: RedirectUriPageRenderer + + success_handler: Callable[[SuccessArgs], BoltResponse] + failure_handler: Callable[[FailureArgs], BoltResponse] @property def client(self) -> WebClient: @@ -58,59 +51,24 @@ def __init__( *, client: Optional[WebClient] = None, logger: Optional[Logger] = None, - installation_store: InstallationStore, - oauth_state_store: OAuthStateStore, - oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, - oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, - client_id: str, - client_secret: str, - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, - redirect_uri: Optional[str] = None, - install_path: str = "/slack/install", - redirect_uri_path: str = "/slack/oauth_redirect", - success_url: Optional[str] = None, - failure_url: Optional[str] = None, + settings: OAuthSettings, ): self._client = client self._logger = logger - - self.installation_store = installation_store - self.oauth_state_store = oauth_state_store - self.oauth_state_cookie_name = oauth_state_cookie_name - self.oauth_state_expiration_seconds = oauth_state_expiration_seconds - - self.client_id = client_id - self.client_secret = client_secret - self.redirect_uri = redirect_uri - self.scopes = scopes - self.user_scopes = user_scopes - - self.install_path = install_path - self.redirect_uri_path = redirect_uri_path - self.success_url = success_url - self.failure_url = failure_url - - self._init_internal_utils() - - def _init_internal_utils(self): - self.oauth_state_utils = OAuthStateUtils( - cookie_name=self.oauth_state_cookie_name, - expiration_seconds=self.oauth_state_expiration_seconds, - ) - self.authorize_url_generator = AuthorizeUrlGenerator( - client_id=self.client_id, - client_secret=self.client_secret, - redirect_uri=self.redirect_uri, - scopes=self.scopes, - user_scopes=self.user_scopes, - ) - self.redirect_uri_page_renderer = RedirectUriPageRenderer( - install_path=self.install_path, - redirect_uri_path=self.redirect_uri_path, - success_url=self.success_url, - failure_url=self.failure_url, - ) + self.settings = settings + self.client_id = self.settings.client_id + self.redirect_uri = self.settings.redirect_uri + self.install_path = self.settings.install_path + self.redirect_uri_path = self.settings.redirect_uri_path + + if settings.callback_options is None: + settings.callback_options = DefaultCallbackOptions( + logger=logger, + state_utils=self.settings.state_utils, + redirect_uri_page_renderer=self.settings.redirect_uri_page_renderer, + ) + self.success_handler = settings.callback_options.success + self.failure_handler = settings.callback_options.failure # ----------------------------- # Factory Methods @@ -120,13 +78,23 @@ def _init_internal_utils(self): def sqlite3( cls, database: str, + # OAuth flow parameters/credentials client_id: Optional[str] = None, # required client_secret: Optional[str] = None, # required scopes: Optional[List[str]] = None, user_scopes: Optional[List[str]] = None, redirect_uri: Optional[str] = None, - oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, - oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + # Handler configuration + install_path: Optional[str] = None, + redirect_uri_path: Optional[str] = None, + callback_options: Optional[CallbackOptions] = None, + success_url: Optional[str] = None, + failure_url: Optional[str] = None, + authorization_url: Optional[str] = None, + # Installation Management + # state parameter related configurations + state_cookie_name: str = OAuthStateUtils.default_cookie_name, + state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, logger: Optional[Logger] = None, ) -> "OAuthFlow": @@ -138,21 +106,33 @@ def sqlite3( return OAuthFlow( client=WebClient(), logger=logger, - installation_store=SQLite3InstallationStore( - database=database, client_id=client_id, logger=logger, - ), - oauth_state_store=SQLite3OAuthStateStore( - database=database, - expiration_seconds=oauth_state_expiration_seconds, - logger=logger, + settings=OAuthSettings( + # OAuth flow parameters/credentials + client_id=client_id, + client_secret=client_secret, + scopes=scopes, + user_scopes=user_scopes, + redirect_uri=redirect_uri, + # Handler configuration + install_path=install_path, + redirect_uri_path=redirect_uri_path, + callback_options=callback_options, + success_url=success_url, + failure_url=failure_url, + authorization_url=authorization_url, + # Installation Management + installation_store=SQLite3InstallationStore( + database=database, client_id=client_id, logger=logger, + ), + # state parameter related configurations + state_store=SQLite3OAuthStateStore( + database=database, + expiration_seconds=state_expiration_seconds, + logger=logger, + ), + state_cookie_name=state_cookie_name, + state_expiration_seconds=state_expiration_seconds, ), - oauth_state_cookie_name=oauth_state_cookie_name, - oauth_state_expiration_seconds=oauth_state_expiration_seconds, - client_id=client_id, - client_secret=client_secret, - scopes=scopes, - user_scopes=user_scopes, - redirect_uri=redirect_uri, ) # ----------------------------- @@ -167,7 +147,7 @@ def handle_installation(self, request: BoltRequest) -> BoltResponse: # Internal methods for Installation def issue_new_state(self, request: BoltRequest) -> str: - return self.oauth_state_store.issue() + return self.settings.state_store.issue() def build_authorize_url_redirection( self, request: BoltRequest, state: str @@ -175,9 +155,9 @@ def build_authorize_url_redirection( return BoltResponse( status=302, headers={ - "Location": [self.authorize_url_generator.generate(state)], + "Location": [self.settings.authorize_url_generator.generate(state)], "Set-Cookie": [ - self.oauth_state_utils.build_set_cookie_for_new_state(state) + self.settings.state_utils.build_set_cookie_for_new_state(state) ], }, ) @@ -191,46 +171,82 @@ def handle_callback(self, request: BoltRequest) -> BoltResponse: # failure due to end-user's cancellation or invalid redirection to slack.com error = request.query.get("error", [None])[0] if error is not None: - return self.build_callback_failure_response( - request, reason=error, status=200 + return self.failure_handler( + FailureArgs( + request=request, + reason=error, + suggested_status_code=200, + settings=self.settings, + ) ) # state parameter verification state = request.query.get("state", [None])[0] - if not self.oauth_state_utils.is_valid_browser(state, request.headers): - return self.build_callback_failure_response( - request, reason="invalid_browser", status=400 + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + ) ) - valid_state_consumed = self.oauth_state_store.consume(state) + valid_state_consumed = self.settings.state_store.consume(state) if not valid_state_consumed: - return self.build_callback_failure_response( - request, reason="invalid_state", status=401 + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_state", + suggested_status_code=401, + settings=self.settings, + ) ) # run installation code = request.query.get("code", [None])[0] if code is None: - return self.build_callback_failure_response( - request, reason="missing_code", status=401 + return self.failure_handler( + FailureArgs( + request=request, + reason="missing_code", + suggested_status_code=401, + settings=self.settings, + ) ) + installation = self.run_installation(code) if installation is None: # failed to run installation with the code - return self.build_callback_failure_response( - request, reason="invalid_code", status=401 + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_code", + suggested_status_code=401, + settings=self.settings, + ) ) # persist the installation try: self.store_installation(request, installation) - except BoltError as e: - return self.build_callback_failure_response( - request, reason="storage_error", error=e + except BoltError as err: + return self.failure_handler( + FailureArgs( + request=request, + reason="storage_error", + error=err, + suggested_status_code=500, + settings=self.settings, + ) ) # display a successful completion page to the end-user - return self.build_callback_success_response(request, installation) + return self.success_handler( + SuccessArgs( + request=request, installation=installation, settings=self.settings, + ) + ) # ---------------------- # Internal methods for Callback @@ -239,16 +255,18 @@ def run_installation(self, code: str) -> Optional[Installation]: try: oauth_response: SlackResponse = self.client.oauth_v2_access( code=code, - client_id=self.client_id, - client_secret=self.client_secret, - redirect_uri=self.redirect_uri, # can be None + client_id=self.settings.client_id, + client_secret=self.settings.client_secret, + redirect_uri=self.settings.redirect_uri, # can be None ) - installed_enterprise: Dict[str, str] = oauth_response.get("enterprise", {}) - installed_team: Dict[str, str] = oauth_response.get("team", {}) - installer: Dict[str, str] = oauth_response.get("authed_user", {}) + installed_enterprise: Dict[str, str] = oauth_response.get( + "enterprise" + ) or {} + installed_team: Dict[str, str] = oauth_response.get("team") or {} + installer: Dict[str, str] = oauth_response.get("authed_user") or {} incoming_webhook: Dict[str, str] = oauth_response.get( - "incoming_webhook", {} - ) + "incoming_webhook" + ) or {} bot_token: Optional[str] = oauth_response.get("access_token", None) # NOTE: oauth.v2.access doesn't include bot_id in response @@ -284,47 +302,4 @@ def run_installation(self, code: str) -> Optional[Installation]: def store_installation(self, request: BoltRequest, installation: Installation): # may raise BoltError - self.installation_store.save(installation) - - def build_callback_failure_response( - self, - request: BoltRequest, - reason: str, - status: int = 500, - error: Optional[Exception] = None, - ) -> BoltResponse: - debug_message = ( - "Handling an OAuth callback failure " - f"(reason: {reason}, error: {error}, request: {request.query})" - ) - self.logger.debug(debug_message) - - html = self.redirect_uri_page_renderer.render_failure_page(reason) - return BoltResponse( - status=status, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(html), - "Set-Cookie": self.oauth_state_utils.build_set_cookie_for_deletion(), - }, - body=html, - ) - - def build_callback_success_response( - self, request: BoltRequest, installation: Installation, - ) -> BoltResponse: - debug_message = f"Handling an OAuth callback success (request: {request.query})" - self.logger.debug(debug_message) - - html = self.redirect_uri_page_renderer.render_success_page( - app_id=installation.app_id, team_id=installation.team_id, - ) - return BoltResponse( - status=200, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(html), - "Set-Cookie": self.oauth_state_utils.build_set_cookie_for_deletion(), - }, - body=html, - ) + self.settings.installation_store.save(installation) diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py new file mode 100644 index 000000000..a99e0fc18 --- /dev/null +++ b/slack_bolt/oauth/oauth_settings.py @@ -0,0 +1,119 @@ +import os +from typing import List, Optional + +from slack_sdk.oauth import ( + OAuthStateStore, + InstallationStore, + OAuthStateUtils, + AuthorizeUrlGenerator, + RedirectUriPageRenderer, +) +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +from slack_bolt.error import BoltError +from slack_bolt.oauth.callback_options import CallbackOptions + + +class OAuthSettings: + # OAuth flow parameters/credentials + client_id: str + client_secret: str + scopes: Optional[List[str]] + user_scopes: Optional[List[str]] + redirect_uri: Optional[str] + # Handler configuration + install_path: str + redirect_uri_path: str + callback_options: Optional[CallbackOptions] = None + success_url: Optional[str] + failure_url: Optional[str] + authorization_url: str # default: https://slack.com/oauth/v2/authorize + # Installation Management + installation_store: InstallationStore + # state parameter related configurations + state_store: OAuthStateStore + state_cookie_name: str + state_expiration_seconds: int + # Customizable utilities + state_utils: OAuthStateUtils + authorize_url_generator: AuthorizeUrlGenerator + redirect_uri_page_renderer: RedirectUriPageRenderer + + def __init__( + self, + *, + # OAuth flow parameters/credentials + client_id: str, + client_secret: str, + scopes: Optional[List[str]] = None, + user_scopes: Optional[List[str]] = None, + redirect_uri: Optional[str] = None, + # Handler configuration + install_path: str = "/slack/install", + redirect_uri_path: str = "/slack/oauth_redirect", + callback_options: Optional[CallbackOptions] = None, + success_url: Optional[str] = None, + failure_url: Optional[str] = None, + authorization_url: Optional[str] = None, + # Installation Management + installation_store: Optional[InstallationStore] = None, + # state parameter related configurations + state_store: Optional[OAuthStateStore] = None, + state_cookie_name: str = OAuthStateUtils.default_cookie_name, + state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + ): + self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID", None) + self.client_secret = client_secret or os.environ.get( + "SLACK_CLIENT_SECRET", None + ) + if self.client_id is None or self.client_secret is None: + raise BoltError("Both client_id and client_secret are required") + + self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( + "," + ) + self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) + # Handler configuration + self.install_path = install_path or os.environ.get( + "SLACK_INSTALL_PATH", "/slack/install" + ) + self.redirect_uri_path = redirect_uri_path or os.environ.get( + "SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect" + ) + self.callback_options = callback_options + self.success_url = success_url + self.failure_url = failure_url + self.authorization_url = ( + authorization_url or "https://slack.com/oauth/v2/authorize" + ) + # Installation Management + self.installation_store = installation_store or FileInstallationStore( + client_id=client_id + ) + # state parameter related configurations + self.state_store = state_store or FileOAuthStateStore( + expiration_seconds=state_expiration_seconds, client_id=client_id, + ) + self.state_cookie_name = state_cookie_name + self.state_expiration_seconds = state_expiration_seconds + + self.state_utils = OAuthStateUtils( + cookie_name=self.state_cookie_name, + expiration_seconds=self.state_expiration_seconds, + ) + # TODO: add authorization_url support on the slack-sdk side + self.authorize_url_generator = AuthorizeUrlGenerator( + client_id=self.client_id, + client_secret=self.client_secret, + redirect_uri=self.redirect_uri, + scopes=self.scopes, + user_scopes=self.user_scopes, + ) + self.redirect_uri_page_renderer = RedirectUriPageRenderer( + install_path=self.install_path, + redirect_uri_path=self.redirect_uri_path, + success_url=self.success_url, + failure_url=self.failure_url, + ) diff --git a/tests/adapter_tests/test_async_fastapi.py b/tests/adapter_tests/test_async_fastapi.py index 5a7f8c7f3..a4ff5985f 100644 --- a/tests/adapter_tests/test_async_fastapi.py +++ b/tests/adapter_tests/test_async_fastapi.py @@ -10,6 +10,7 @@ from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler from slack_bolt.app.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -170,9 +171,11 @@ def test_oauth(self): app = AsyncApp( client=self.web_client, signing_secret=self.signing_secret, - client_id="111.111", - client_secret="xxx", - scopes=["chat:write", "commands"], + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), ) api = FastAPI() app_handler = AsyncSlackRequestHandler(app) diff --git a/tests/adapter_tests/test_async_starlette.py b/tests/adapter_tests/test_async_starlette.py index f31fccfbb..c549cb886 100644 --- a/tests/adapter_tests/test_async_starlette.py +++ b/tests/adapter_tests/test_async_starlette.py @@ -11,6 +11,7 @@ from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler from slack_bolt.app.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -177,9 +178,11 @@ def test_oauth(self): app = AsyncApp( client=self.web_client, signing_secret=self.signing_secret, - client_id="111.111", - client_secret="xxx", - scopes=["chat:write", "commands"], + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), ) app_handler = AsyncSlackRequestHandler(app) diff --git a/tests/adapter_tests/test_aws_lambda.py b/tests/adapter_tests/test_aws_lambda.py index e2fb22957..0838fdb1b 100644 --- a/tests/adapter_tests/test_aws_lambda.py +++ b/tests/adapter_tests/test_aws_lambda.py @@ -6,6 +6,7 @@ from slack_bolt.adapter.aws_lambda import SlackRequestHandler from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -168,9 +169,11 @@ def test_oauth(self): app = App( client=self.web_client, signing_secret=self.signing_secret, - client_id="111.111", - client_secret="xxx", - scopes=["chat:write", "commands"], + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), ) event = { diff --git a/tests/adapter_tests/test_cherrypy_oauth.py b/tests/adapter_tests/test_cherrypy_oauth.py index c56e24f98..cbda0b06c 100644 --- a/tests/adapter_tests/test_cherrypy_oauth.py +++ b/tests/adapter_tests/test_cherrypy_oauth.py @@ -5,10 +5,7 @@ from slack_bolt.adapter.cherrypy import SlackRequestHandler from slack_bolt.app import App -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) +from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.utils import remove_os_env_temporarily, restore_os_env @@ -28,9 +25,11 @@ def setup_server(cls): app = App( client=web_client, signing_secret=signing_secret, - client_id="111.111", - client_secret="xxx", - scopes=["chat:write", "commands"], + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), ) handler = SlackRequestHandler(app) diff --git a/tests/adapter_tests/test_fastapi.py b/tests/adapter_tests/test_fastapi.py index a809704af..699c471c1 100644 --- a/tests/adapter_tests/test_fastapi.py +++ b/tests/adapter_tests/test_fastapi.py @@ -10,6 +10,7 @@ from slack_bolt.adapter.fastapi import SlackRequestHandler from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -170,9 +171,11 @@ def test_oauth(self): app = App( client=self.web_client, signing_secret=self.signing_secret, - client_id="111.111", - client_secret="xxx", - scopes=["chat:write", "commands"], + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), ) api = FastAPI() app_handler = SlackRequestHandler(app) diff --git a/tests/adapter_tests/test_flask.py b/tests/adapter_tests/test_flask.py index 9884cd2c8..4196521b7 100644 --- a/tests/adapter_tests/test_flask.py +++ b/tests/adapter_tests/test_flask.py @@ -7,6 +7,7 @@ from slack_bolt.adapter.flask import SlackRequestHandler from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -164,9 +165,11 @@ def test_oauth(self): app = App( client=self.web_client, signing_secret=self.signing_secret, - client_id="111.111", - client_secret="xxx", - scopes=["chat:write", "commands"], + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), ) flask_app = Flask(__name__) diff --git a/tests/adapter_tests/test_starlette.py b/tests/adapter_tests/test_starlette.py index 1c9090b79..2f07180c9 100644 --- a/tests/adapter_tests/test_starlette.py +++ b/tests/adapter_tests/test_starlette.py @@ -11,6 +11,7 @@ from slack_bolt.adapter.starlette import SlackRequestHandler from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -177,9 +178,11 @@ def test_oauth(self): app = App( client=self.web_client, signing_secret=self.signing_secret, - client_id="111.111", - client_secret="xxx", - scopes=["chat:write", "commands"], + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), ) app_handler = SlackRequestHandler(app) diff --git a/tests/async_scenario_tests/test_app.py b/tests/async_scenario_tests/test_app.py index df9a68f54..a22c95a3e 100644 --- a/tests/async_scenario_tests/test_app.py +++ b/tests/async_scenario_tests/test_app.py @@ -6,6 +6,8 @@ from slack_bolt.async_app import AsyncApp from slack_bolt.error import BoltError from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.utils import remove_os_env_temporarily, restore_os_env @@ -64,24 +66,35 @@ def test_token_absence(self): def test_valid_multi_auth(self): app = AsyncApp( - signing_secret="valid", client_id="111.222", client_secret="valid" + signing_secret="valid", + oauth_settings=AsyncOAuthSettings( + client_id="111.222", client_secret="valid" + ), ) assert app != None def test_valid_multi_auth_oauth_flow(self): oauth_flow = AsyncOAuthFlow( - client_id="111.222", - client_secret="valid", - installation_store=FileInstallationStore(), - oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) ) app = AsyncApp(signing_secret="valid", oauth_flow=oauth_flow) assert app != None def test_valid_multi_auth_client_id_absence(self): with pytest.raises(BoltError): - AsyncApp(signing_secret="valid", client_id=None, client_secret="valid") + AsyncApp( + signing_secret="valid", + oauth_settings=OAuthSettings(client_id=None, client_secret="valid"), + ) def test_valid_multi_auth_secret_absence(self): with pytest.raises(BoltError): - AsyncApp(signing_secret="valid", client_id="111.222", client_secret=None) + AsyncApp( + signing_secret="valid", + oauth_settings=OAuthSettings(client_id="111.222", client_secret=None), + ) diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index f075b696e..bf08dcb0e 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -5,6 +5,7 @@ from slack_bolt import App from slack_bolt.error import BoltError from slack_bolt.oauth import OAuthFlow +from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.utils import remove_os_env_temporarily, restore_os_env @@ -48,23 +49,34 @@ def test_token_absence(self): # -------------------------- def test_valid_multi_auth(self): - app = App(signing_secret="valid", client_id="111.222", client_secret="valid") + app = App( + signing_secret="valid", + oauth_settings=OAuthSettings(client_id="111.222", client_secret="valid"), + ) assert app != None def test_valid_multi_auth_oauth_flow(self): oauth_flow = OAuthFlow( - client_id="111.222", - client_secret="valid", - installation_store=FileInstallationStore(), - oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + settings=OAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) ) app = App(signing_secret="valid", oauth_flow=oauth_flow) assert app != None def test_valid_multi_auth_client_id_absence(self): with pytest.raises(BoltError): - App(signing_secret="valid", client_id=None, client_secret="valid") + App( + signing_secret="valid", + oauth_settings=OAuthSettings(client_id=None, client_secret="valid"), + ) def test_valid_multi_auth_secret_absence(self): with pytest.raises(BoltError): - App(signing_secret="valid", client_id="111.222", client_secret=None) + App( + signing_secret="valid", + oauth_settings=OAuthSettings(client_id="111.222", client_secret=None), + ) diff --git a/tests/slack_bolt/oauth/test_oauth_flow.py b/tests/slack_bolt/oauth/test_oauth_flow.py index 05b8c47f8..603926911 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow.py +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -4,8 +4,10 @@ from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore -from slack_bolt import BoltRequest +from slack_bolt import BoltRequest, BoltResponse from slack_bolt.oauth import OAuthFlow +from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs +from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( cleanup_mock_web_api_server, setup_mock_web_api_server, @@ -26,21 +28,25 @@ def next(self): def test_instantiation(self): oauth_flow = OAuthFlow( - client_id="111.222", - client_secret="xxx", - scopes=["chat:write", "commands"], - installation_store=FileInstallationStore(), - oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) ) assert oauth_flow is not None def test_handle_installation(self): oauth_flow = OAuthFlow( - client_id="111.222", - client_secret="xxx", - scopes=["chat:write", "commands"], - installation_store=FileInstallationStore(), - oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) ) req = BoltRequest(body="") resp = oauth_flow.handle_installation(req) @@ -59,19 +65,21 @@ def test_handle_installation(self): def test_handle_callback(self): oauth_flow = OAuthFlow( client=WebClient(base_url=self.mock_api_server_base_url), - client_id="111.222", - client_secret="xxx", - scopes=["chat:write", "commands"], - installation_store=FileInstallationStore(), - oauth_state_store=FileOAuthStateStore(expiration_seconds=120), - success_url="https://www.example.com/completion", - failure_url="https://www.example.com/failure", + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + success_url="https://www.example.com/completion", + failure_url="https://www.example.com/failure", + ), ) state = oauth_flow.issue_new_state(None) req = BoltRequest( body="", query=f"code=foo&state={state}", - headers={"cookie": [f"{oauth_flow.oauth_state_cookie_name}={state}"]}, + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, ) resp = oauth_flow.handle_callback(req) assert resp.status == 200 @@ -79,17 +87,60 @@ def test_handle_callback(self): def test_handle_callback_invalid_state(self): oauth_flow = OAuthFlow( - client_id="111.222", - client_secret="xxx", - scopes=["chat:write", "commands"], - installation_store=FileInstallationStore(), - oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) ) state = oauth_flow.issue_new_state(None) req = BoltRequest( body="", query=f"code=foo&state=invalid", - headers={"cookie": [f"{oauth_flow.oauth_state_cookie_name}={state}"]}, + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, ) resp = oauth_flow.handle_callback(req) assert resp.status == 400 + + def test_handle_callback_using_options(self): + def success(args: SuccessArgs) -> BoltResponse: + assert args.request is not None + return BoltResponse(status=200, body="customized") + + def failure(args: FailureArgs) -> BoltResponse: + assert args.request is not None + assert args.reason is not None + return BoltResponse(status=502, body="customized") + + oauth_flow = OAuthFlow( + client=WebClient(base_url=self.mock_api_server_base_url), + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + callback_options=CallbackOptions(success=success, failure=failure), + ), + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 200 + assert resp.body == "customized" + + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 502 + assert resp.body == "customized" diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index bc3a9537d..fbf2f4b0a 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -6,7 +6,14 @@ from slack_sdk.oauth.state_store import FileOAuthStateStore from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt import BoltResponse +from slack_bolt.oauth.async_callback_options import ( + AsyncFailureArgs, + AsyncSuccessArgs, + AsyncCallbackOptions, +) from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( cleanup_mock_web_api_server, @@ -31,22 +38,26 @@ def next(self): @pytest.mark.asyncio async def test_instantiation(self): oauth_flow = AsyncOAuthFlow( - client_id="111.222", - client_secret="xxx", - scopes=["chat:write", "commands"], - installation_store=FileInstallationStore(), - oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) ) assert oauth_flow is not None @pytest.mark.asyncio async def test_handle_installation(self): oauth_flow = AsyncOAuthFlow( - client_id="111.222", - client_secret="xxx", - scopes=["chat:write", "commands"], - installation_store=FileInstallationStore(), - oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) ) req = AsyncBoltRequest(body="") resp = await oauth_flow.handle_installation(req) @@ -66,19 +77,21 @@ async def test_handle_installation(self): async def test_handle_callback(self): oauth_flow = AsyncOAuthFlow( client=AsyncWebClient(base_url=self.mock_api_server_base_url), - client_id="111.222", - client_secret="xxx", - scopes=["chat:write", "commands"], - installation_store=FileInstallationStore(), - oauth_state_store=FileOAuthStateStore(expiration_seconds=120), - success_url="https://www.example.com/completion", - failure_url="https://www.example.com/failure", + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + success_url="https://www.example.com/completion", + failure_url="https://www.example.com/failure", + ), ) state = await oauth_flow.issue_new_state(None) req = AsyncBoltRequest( body="", query=f"code=foo&state={state}", - headers={"cookie": [f"{oauth_flow.oauth_state_cookie_name}={state}"]}, + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, ) resp = await oauth_flow.handle_callback(req) assert resp.status == 200 @@ -87,17 +100,62 @@ async def test_handle_callback(self): @pytest.mark.asyncio async def test_handle_callback_invalid_state(self): oauth_flow = AsyncOAuthFlow( - client_id="111.222", - client_secret="xxx", - scopes=["chat:write", "commands"], - installation_store=FileInstallationStore(), - oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) ) state = await oauth_flow.issue_new_state(None) req = AsyncBoltRequest( body="", query=f"code=foo&state=invalid", - headers={"cookie": [f"{oauth_flow.oauth_state_cookie_name}={state}"]}, + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, ) resp = await oauth_flow.handle_callback(req) assert resp.status == 400 + + @pytest.mark.asyncio + async def test_handle_callback_using_options(self): + async def success(args: AsyncSuccessArgs) -> BoltResponse: + assert args.request is not None + return BoltResponse(status=200, body="customized") + + async def failure(args: AsyncFailureArgs) -> BoltResponse: + assert args.request is not None + assert args.reason is not None + return BoltResponse(status=502, body="customized") + + oauth_flow = AsyncOAuthFlow( + client=AsyncWebClient(base_url=self.mock_api_server_base_url), + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + callback_options=AsyncCallbackOptions( + success=success, failure=failure, + ), + ), + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 200 + assert resp.body == "customized" + + req = AsyncBoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 502 + assert resp.body == "customized" From 8df4bf1b04c917c97d5b1b68a1c46a2b27b8ee22 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Sep 2020 14:42:11 +0900 Subject: [PATCH 049/865] Add more tests for OAuth modules --- slack_bolt/oauth/async_oauth_flow.py | 3 +- slack_bolt/oauth/oauth_flow.py | 3 +- tests/mock_web_api_server.py | 17 ++ tests/slack_bolt/oauth/test_oauth_flow.py | 5 +- .../oauth/test_oauth_flow_sqlite3.py | 132 ++++++++++++++++ .../oauth/test_async_oauth_flow.py | 2 + .../oauth/test_async_oauth_flow_sqlite3.py | 146 ++++++++++++++++++ 7 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py create mode 100644 tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 4f9dfb509..8c508adec 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -95,6 +95,7 @@ def sqlite3( # state parameter related configurations state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + client: Optional[AsyncWebClient] = None, logger: Optional[Logger] = None, ) -> "AsyncOAuthFlow": @@ -104,7 +105,7 @@ def sqlite3( user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",") redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) return AsyncOAuthFlow( - client=AsyncWebClient(), + client=client or AsyncWebClient(), logger=logger, settings=AsyncOAuthSettings( # OAuth flow parameters/credentials diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index 8968363f3..f269c36a0 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -95,6 +95,7 @@ def sqlite3( # state parameter related configurations state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + client: Optional[WebClient] = None, logger: Optional[Logger] = None, ) -> "OAuthFlow": @@ -104,7 +105,7 @@ def sqlite3( user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",") redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) return OAuthFlow( - client=WebClient(), + client=client or WebClient(), logger=logger, settings=OAuthSettings( # OAuth flow parameters/credentials diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index a1cf43383..31fcf2ffc 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -53,6 +53,17 @@ def set_common_headers(self): } } """ + auth_test_response = """ +{ + "ok": true, + "url": "https://subarachnoid.slack.com/", + "team": "Subarachnoid Workspace", + "user": "bot", + "team_id": "T0G9PQBBK", + "user_id": "W23456789", + "bot_id": "BZYBOTHED" +} +""" def _handle(self): self.received_requests[self.path] = self.received_requests.get(self.path, 0) + 1 @@ -67,6 +78,12 @@ def _handle(self): if self.is_valid_token(): parsed_path = urlparse(self.path) + if self.path == "/auth.test": + self.send_response(200) + self.set_common_headers() + self.wfile.write(self.auth_test_response.encode("utf-8")) + return + len_header = self.headers.get("Content-Length") or 0 content_len = int(len_header) post_body = self.rfile.read(content_len) diff --git a/tests/slack_bolt/oauth/test_oauth_flow.py b/tests/slack_bolt/oauth/test_oauth_flow.py index 603926911..4b1f974d8 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow.py +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -23,9 +23,6 @@ def setup_method(self): def teardown_method(self): cleanup_mock_web_api_server(self) - def next(self): - pass - def test_instantiation(self): oauth_flow = OAuthFlow( settings=OAuthSettings( @@ -37,6 +34,8 @@ def test_instantiation(self): ) ) assert oauth_flow is not None + assert oauth_flow.logger is not None + assert oauth_flow.client is not None def test_handle_installation(self): oauth_flow = OAuthFlow( diff --git a/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py new file mode 100644 index 000000000..1e0da3b32 --- /dev/null +++ b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py @@ -0,0 +1,132 @@ +import re + +from slack_sdk import WebClient +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +from slack_bolt import BoltRequest, BoltResponse +from slack_bolt.oauth import OAuthFlow +from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) + + +class TestOAuthFlowSQLite3: + mock_api_server_base_url = "http://localhost:8888" + + def setup_method(self): + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_instantiation(self): + oauth_flow = OAuthFlow.sqlite3( + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + assert oauth_flow is not None + assert oauth_flow.logger is not None + assert oauth_flow.client is not None + + def test_handle_installation(self): + oauth_flow = OAuthFlow.sqlite3( + client=WebClient(base_url=self.mock_api_server_base_url), + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + req = BoltRequest(body="") + resp = oauth_flow.handle_installation(req) + assert resp.status == 302 + url = resp.headers["location"][0] + assert ( + re.compile( + "https://slack.com/oauth/v2/authorize\\?state=[-0-9a-z]+." + "&client_id=111\\.222" + "&scope=chat:write,commands" + "&user_scope=" + ).match(url) + is not None + ) + + def test_handle_callback(self): + oauth_flow = OAuthFlow.sqlite3( + client=WebClient(base_url=self.mock_api_server_base_url), + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + success_url="https://www.example.com/completion", + failure_url="https://www.example.com/failure", + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 200 + assert "https://www.example.com/completion" in resp.body + + def test_handle_callback_invalid_state(self): + oauth_flow = OAuthFlow.sqlite3( + client=WebClient(base_url=self.mock_api_server_base_url), + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 400 + + def test_handle_callback_using_options(self): + def success(args: SuccessArgs) -> BoltResponse: + assert args.request is not None + return BoltResponse(status=200, body="customized") + + def failure(args: FailureArgs) -> BoltResponse: + assert args.request is not None + assert args.reason is not None + return BoltResponse(status=502, body="customized") + + oauth_flow = OAuthFlow.sqlite3( + client=WebClient(base_url=self.mock_api_server_base_url), + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + callback_options=CallbackOptions(success=success, failure=failure), + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 200 + assert resp.body == "customized" + + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 502 + assert resp.body == "customized" diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index fbf2f4b0a..80798fddd 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -47,6 +47,8 @@ async def test_instantiation(self): ) ) assert oauth_flow is not None + assert oauth_flow.logger is not None + assert oauth_flow.client is not None @pytest.mark.asyncio async def test_handle_installation(self): diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py new file mode 100644 index 000000000..dd3f9aa42 --- /dev/null +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py @@ -0,0 +1,146 @@ +import asyncio +import re + +import pytest +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import BoltResponse +from slack_bolt.oauth.async_callback_options import ( + AsyncFailureArgs, + AsyncSuccessArgs, + AsyncCallbackOptions, +) +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) + + +class TestAsyncOAuthFlowSQLite3: + mock_api_server_base_url = "http://localhost:8888" + + @pytest.fixture + def event_loop(self): + setup_mock_web_api_server(self) + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + + def next(self): + pass + + @pytest.mark.asyncio + async def test_instantiation(self): + oauth_flow = AsyncOAuthFlow.sqlite3( + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + assert oauth_flow is not None + assert oauth_flow.logger is not None + assert oauth_flow.client is not None + + @pytest.mark.asyncio + async def test_handle_installation(self): + oauth_flow = AsyncOAuthFlow.sqlite3( + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + req = AsyncBoltRequest(body="") + resp = await oauth_flow.handle_installation(req) + assert resp.status == 302 + url = resp.headers["location"][0] + assert ( + re.compile( + "https://slack.com/oauth/v2/authorize\\?state=[-0-9a-z]+." + "&client_id=111\\.222" + "&scope=chat:write,commands" + "&user_scope=" + ).match(url) + is not None + ) + + @pytest.mark.asyncio + async def test_handle_callback(self): + oauth_flow = AsyncOAuthFlow.sqlite3( + database="./logs/test_db", + client=AsyncWebClient(base_url=self.mock_api_server_base_url), + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + success_url="https://www.example.com/completion", + failure_url="https://www.example.com/failure", + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 200 + assert "https://www.example.com/completion" in resp.body + + @pytest.mark.asyncio + async def test_handle_callback_invalid_state(self): + oauth_flow = AsyncOAuthFlow.sqlite3( + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 400 + + @pytest.mark.asyncio + async def test_handle_callback_using_options(self): + async def success(args: AsyncSuccessArgs) -> BoltResponse: + assert args.request is not None + return BoltResponse(status=200, body="customized") + + async def failure(args: AsyncFailureArgs) -> BoltResponse: + assert args.request is not None + assert args.reason is not None + return BoltResponse(status=502, body="customized") + + oauth_flow = AsyncOAuthFlow.sqlite3( + client=AsyncWebClient(base_url=self.mock_api_server_base_url), + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + callback_options=AsyncCallbackOptions(success=success, failure=failure,), + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 200 + assert resp.body == "customized" + + req = AsyncBoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 502 + assert resp.body == "customized" From 871140b0f6dacc46c26b2170859f45e90d122bf9 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Sep 2020 14:42:25 +0900 Subject: [PATCH 050/865] Add tests for ack etc --- tests/slack_bolt/context/test_ack.py | 65 +++++++++++++++++ tests/slack_bolt/request/test_internals.py | 19 +++++ .../context/test_async_ack.py | 70 +++++++++++++++++++ 3 files changed, 154 insertions(+) diff --git a/tests/slack_bolt/context/test_ack.py b/tests/slack_bolt/context/test_ack.py index 73d7c1369..e1d9d9bf1 100644 --- a/tests/slack_bolt/context/test_ack.py +++ b/tests/slack_bolt/context/test_ack.py @@ -16,6 +16,36 @@ def test_text(self): response: BoltResponse = ack(text="foo") assert (response.status, response.body) == (200, "foo") + sample_attachments = [ + { + "fallback": "Plain-text summary of the attachment.", + "color": "#2eb886", + "pretext": "Optional text that appears above the attachment block", + "author_name": "Bobby Tables", + "author_link": "http://flickr.com/bobby/", + "author_icon": "http://flickr.com/icons/bobby.jpg", + "title": "Slack API Documentation", + "title_link": "https://api.slack.com/", + "text": "Optional text that appears within the attachment", + "fields": [{"title": "Priority", "value": "High", "short": False}], + "image_url": "http://my-website.com/path/to/image.jpg", + "thumb_url": "http://example.com/path/to/thumb.png", + "footer": "Slack API", + "footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png", + "ts": 123456789, + } + ] + + def test_attachments(self): + ack = Ack() + response: BoltResponse = ack(text="foo", attachments=self.sample_attachments) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", ' + '"attachments": [{"fallback": "Plain-text summary of the attachment.", "color": "#2eb886", "pretext": "Optional text that appears above the attachment block", "author_name": "Bobby Tables", "author_link": "http://flickr.com/bobby/", "author_icon": "http://flickr.com/icons/bobby.jpg", "title": "Slack API Documentation", "title_link": "https://api.slack.com/", "text": "Optional text that appears within the attachment", "fields": [{"title": "Priority", "value": "High", "short": false}], "image_url": "http://my-website.com/path/to/image.jpg", "thumb_url": "http://example.com/path/to/thumb.png", "footer": "Slack API", "footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png", "ts": 123456789}]' + "}", + ) + def test_blocks(self): ack = Ack() response: BoltResponse = ack(text="foo", blocks=[{"type": "divider"}]) @@ -24,6 +54,41 @@ def test_blocks(self): '{"text": "foo", "blocks": [{"type": "divider"}]}', ) + sample_options = [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}] + + def test_options(self): + ack = Ack() + response: BoltResponse = ack(text="foo", options=self.sample_options) + assert response.status == 200 + assert ( + response.body + == '{"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]}' + ) + + sample_option_groups = [ + { + "label": {"type": "plain_text", "text": "Group 1"}, + "options": [ + {"text": {"type": "plain_text", "text": "Option 1"}, "value": "1-1"}, + {"text": {"type": "plain_text", "text": "Option 2"}, "value": "1-2"}, + ], + }, + { + "label": {"type": "plain_text", "text": "Group 2"}, + "options": [ + {"text": {"type": "plain_text", "text": "Option 1"}, "value": "2-1"}, + ], + }, + ] + + def test_option_groups(self): + ack = Ack() + response: BoltResponse = ack( + text="foo", option_groups=self.sample_option_groups + ) + assert response.status == 200 + assert response.body.startswith('{"option_groups":') + def test_response_type(self): ack = Ack() response: BoltResponse = ack(text="foo", response_type="in_channel") diff --git a/tests/slack_bolt/request/test_internals.py b/tests/slack_bolt/request/test_internals.py index 33e8db85b..204e09806 100644 --- a/tests/slack_bolt/request/test_internals.py +++ b/tests/slack_bolt/request/test_internals.py @@ -1,8 +1,12 @@ +import pytest + from slack_bolt.request.internals import ( extract_channel_id, extract_user_id, extract_team_id, extract_enterprise_id, + parse_query, + parse_body, ) @@ -65,3 +69,18 @@ def test_enterprise_id_extraction(self): for req in self.requests: team_id = extract_enterprise_id(req) assert team_id == "E111" + + def test_parse_query(self): + expected = {"foo": ["bar"], "baz": ["123"]} + + q = parse_query("foo=bar&baz=123") + assert q == expected + + q = parse_query({"foo": "bar", "baz": "123"}) + assert q == expected + + q = parse_query({"foo": ["bar"], "baz": ["123"]}) + assert q == expected + + with pytest.raises(ValueError): + parse_query({"foo": {"bar": "ZZZ"}, "baz": {"123": "111"}}) diff --git a/tests/slack_bolt_async/context/test_async_ack.py b/tests/slack_bolt_async/context/test_async_ack.py index 56daa06ea..bd7c68267 100644 --- a/tests/slack_bolt_async/context/test_async_ack.py +++ b/tests/slack_bolt_async/context/test_async_ack.py @@ -22,6 +22,76 @@ async def test_blocks(self): '{"text": "foo", "blocks": [{"type": "divider"}]}', ) + sample_attachments = [ + { + "fallback": "Plain-text summary of the attachment.", + "color": "#2eb886", + "pretext": "Optional text that appears above the attachment block", + "author_name": "Bobby Tables", + "author_link": "http://flickr.com/bobby/", + "author_icon": "http://flickr.com/icons/bobby.jpg", + "title": "Slack API Documentation", + "title_link": "https://api.slack.com/", + "text": "Optional text that appears within the attachment", + "fields": [{"title": "Priority", "value": "High", "short": False}], + "image_url": "http://my-website.com/path/to/image.jpg", + "thumb_url": "http://example.com/path/to/thumb.png", + "footer": "Slack API", + "footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png", + "ts": 123456789, + } + ] + + @pytest.mark.asyncio + async def test_attachments(self): + ack = AsyncAck() + response: BoltResponse = await ack( + text="foo", attachments=self.sample_attachments + ) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", ' + '"attachments": [{"fallback": "Plain-text summary of the attachment.", "color": "#2eb886", "pretext": "Optional text that appears above the attachment block", "author_name": "Bobby Tables", "author_link": "http://flickr.com/bobby/", "author_icon": "http://flickr.com/icons/bobby.jpg", "title": "Slack API Documentation", "title_link": "https://api.slack.com/", "text": "Optional text that appears within the attachment", "fields": [{"title": "Priority", "value": "High", "short": false}], "image_url": "http://my-website.com/path/to/image.jpg", "thumb_url": "http://example.com/path/to/thumb.png", "footer": "Slack API", "footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png", "ts": 123456789}]' + "}", + ) + + sample_options = [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}] + + @pytest.mark.asyncio + async def test_options(self): + ack = AsyncAck() + response: BoltResponse = await ack(text="foo", options=self.sample_options) + assert response.status == 200 + assert ( + response.body + == '{"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]}' + ) + + sample_option_groups = [ + { + "label": {"type": "plain_text", "text": "Group 1"}, + "options": [ + {"text": {"type": "plain_text", "text": "Option 1"}, "value": "1-1"}, + {"text": {"type": "plain_text", "text": "Option 2"}, "value": "1-2"}, + ], + }, + { + "label": {"type": "plain_text", "text": "Group 2"}, + "options": [ + {"text": {"type": "plain_text", "text": "Option 1"}, "value": "2-1"}, + ], + }, + ] + + @pytest.mark.asyncio + async def test_option_groups(self): + ack = AsyncAck() + response: BoltResponse = await ack( + text="foo", option_groups=self.sample_option_groups + ) + assert response.status == 200 + assert response.body.startswith('{"option_groups":') + @pytest.mark.asyncio async def test_response_type(self): ack = AsyncAck() From c1641f8badf90ab79e69810adc94b02f55ace60f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Sep 2020 15:03:54 +0900 Subject: [PATCH 051/865] Add tests for multi team auth --- tests/slack_bolt/oauth/test_oauth_flow.py | 36 ++++++++++++++++++- .../oauth/test_async_oauth_flow.py | 35 ++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/tests/slack_bolt/oauth/test_oauth_flow.py b/tests/slack_bolt/oauth/test_oauth_flow.py index 4b1f974d8..e228890b9 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow.py +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -1,10 +1,14 @@ +import json import re +from time import time +from urllib.parse import quote from slack_sdk import WebClient from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.signature import SignatureVerifier -from slack_bolt import BoltRequest, BoltResponse +from slack_bolt import BoltRequest, BoltResponse, App from slack_bolt.oauth import OAuthFlow from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs from slack_bolt.oauth.oauth_settings import OAuthSettings @@ -84,6 +88,36 @@ def test_handle_callback(self): assert resp.status == 200 assert "https://www.example.com/completion" in resp.body + app = App(signing_secret="signing_secret", oauth_flow=oauth_flow) + global_shortcut_body = { + "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(global_shortcut_body))}" + timestamp = str(int(time())) + signature_verifier = SignatureVerifier("signing_secret") + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [ + signature_verifier.generate_signature(body=body, timestamp=timestamp) + ], + "x-slack-request-timestamp": [timestamp], + } + request = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + def test_handle_callback_invalid_state(self): oauth_flow = OAuthFlow( settings=OAuthSettings( diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index 80798fddd..c70e3dfeb 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -1,12 +1,17 @@ import asyncio +import json import re +from time import time +from urllib.parse import quote import pytest from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore +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_callback_options import ( AsyncFailureArgs, AsyncSuccessArgs, @@ -99,6 +104,36 @@ async def test_handle_callback(self): assert resp.status == 200 assert "https://www.example.com/completion" in resp.body + app = AsyncApp(signing_secret="signing_secret", oauth_flow=oauth_flow) + global_shortcut_body = { + "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(global_shortcut_body))}" + timestamp = str(int(time())) + signature_verifier = SignatureVerifier("signing_secret") + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [ + signature_verifier.generate_signature(body=body, timestamp=timestamp) + ], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + @pytest.mark.asyncio async def test_handle_callback_invalid_state(self): oauth_flow = AsyncOAuthFlow( From 06c376b15c9129797ee4d20a7b50773a5461ae19 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Sep 2020 15:12:33 +0900 Subject: [PATCH 052/865] Restructure test packages --- .travis.yml | 9 +++++---- setup.py | 3 +-- .../__init__.py | 0 .../test_async_fastapi.py | 0 .../test_async_starlette.py | 0 tests/scenario_tests_async/__init__.py | 0 .../test_app.py | 0 .../test_attachment_actions.py | 0 .../test_block_actions.py | 0 .../test_block_suggestion.py | 0 .../test_dialogs.py | 0 .../test_error_handler.py | 0 .../test_events.py | 0 .../test_lazy.py | 0 .../test_message.py | 0 .../test_middleware.py | 0 .../test_shortcut.py | 0 .../test_slash_command.py | 0 .../test_ssl_check.py | 0 .../test_view_closed.py | 0 .../test_view_submission.py | 0 21 files changed, 6 insertions(+), 6 deletions(-) rename tests/{async_scenario_tests => adapter_tests_async}/__init__.py (100%) rename tests/{adapter_tests => adapter_tests_async}/test_async_fastapi.py (100%) rename tests/{adapter_tests => adapter_tests_async}/test_async_starlette.py (100%) create mode 100644 tests/scenario_tests_async/__init__.py rename tests/{async_scenario_tests => scenario_tests_async}/test_app.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_attachment_actions.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_block_actions.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_block_suggestion.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_dialogs.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_error_handler.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_events.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_lazy.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_message.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_middleware.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_shortcut.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_slash_command.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_ssl_check.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_view_closed.py (100%) rename tests/{async_scenario_tests => scenario_tests_async}/test_view_submission.py (100%) diff --git a/.travis.yml b/.travis.yml index f3545ae7b..693347598 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,14 +14,15 @@ script: # testing without aiohttp - travis_retry pytest tests/slack_bolt/ - travis_retry pytest tests/scenario_tests/ + # testing for adapters + - pip install -e ".[adapter]" + - travis_retry pytest tests/adapter_tests/ # testing with aiohttp - pip install -e ".[async]" - pip install "pytest-asyncio<1" - travis_retry pytest tests/slack_bolt_async/ - - travis_retry pytest tests/async_scenario_tests/ - # testing for adapters - - pip install -e ".[adapter]" - - travis_retry pytest tests/adapter_tests/ + - travis_retry pytest tests/scenario_tests_async/ + - travis_retry pytest tests/adapter_tests_async/ # run all tests just in case - travis_retry python setup.py test # Run pytype only for Python 3.8 diff --git a/setup.py b/setup.py index 0ea7c873a..2f4f32cf2 100755 --- a/setup.py +++ b/setup.py @@ -45,9 +45,8 @@ "aiohttp>=3,<4", ], # pip install -e ".[adapter]" + # NOTE: any of async ones requires pip install -e ".[async]" too "adapter": [ - # any of async ones - "aiohttp>=3,<4", # used only under src/slack_bolt/adapter "boto3<=2", "moto<=2", # For AWS tests diff --git a/tests/async_scenario_tests/__init__.py b/tests/adapter_tests_async/__init__.py similarity index 100% rename from tests/async_scenario_tests/__init__.py rename to tests/adapter_tests_async/__init__.py diff --git a/tests/adapter_tests/test_async_fastapi.py b/tests/adapter_tests_async/test_async_fastapi.py similarity index 100% rename from tests/adapter_tests/test_async_fastapi.py rename to tests/adapter_tests_async/test_async_fastapi.py diff --git a/tests/adapter_tests/test_async_starlette.py b/tests/adapter_tests_async/test_async_starlette.py similarity index 100% rename from tests/adapter_tests/test_async_starlette.py rename to tests/adapter_tests_async/test_async_starlette.py diff --git a/tests/scenario_tests_async/__init__.py b/tests/scenario_tests_async/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/async_scenario_tests/test_app.py b/tests/scenario_tests_async/test_app.py similarity index 100% rename from tests/async_scenario_tests/test_app.py rename to tests/scenario_tests_async/test_app.py diff --git a/tests/async_scenario_tests/test_attachment_actions.py b/tests/scenario_tests_async/test_attachment_actions.py similarity index 100% rename from tests/async_scenario_tests/test_attachment_actions.py rename to tests/scenario_tests_async/test_attachment_actions.py diff --git a/tests/async_scenario_tests/test_block_actions.py b/tests/scenario_tests_async/test_block_actions.py similarity index 100% rename from tests/async_scenario_tests/test_block_actions.py rename to tests/scenario_tests_async/test_block_actions.py diff --git a/tests/async_scenario_tests/test_block_suggestion.py b/tests/scenario_tests_async/test_block_suggestion.py similarity index 100% rename from tests/async_scenario_tests/test_block_suggestion.py rename to tests/scenario_tests_async/test_block_suggestion.py diff --git a/tests/async_scenario_tests/test_dialogs.py b/tests/scenario_tests_async/test_dialogs.py similarity index 100% rename from tests/async_scenario_tests/test_dialogs.py rename to tests/scenario_tests_async/test_dialogs.py diff --git a/tests/async_scenario_tests/test_error_handler.py b/tests/scenario_tests_async/test_error_handler.py similarity index 100% rename from tests/async_scenario_tests/test_error_handler.py rename to tests/scenario_tests_async/test_error_handler.py diff --git a/tests/async_scenario_tests/test_events.py b/tests/scenario_tests_async/test_events.py similarity index 100% rename from tests/async_scenario_tests/test_events.py rename to tests/scenario_tests_async/test_events.py diff --git a/tests/async_scenario_tests/test_lazy.py b/tests/scenario_tests_async/test_lazy.py similarity index 100% rename from tests/async_scenario_tests/test_lazy.py rename to tests/scenario_tests_async/test_lazy.py diff --git a/tests/async_scenario_tests/test_message.py b/tests/scenario_tests_async/test_message.py similarity index 100% rename from tests/async_scenario_tests/test_message.py rename to tests/scenario_tests_async/test_message.py diff --git a/tests/async_scenario_tests/test_middleware.py b/tests/scenario_tests_async/test_middleware.py similarity index 100% rename from tests/async_scenario_tests/test_middleware.py rename to tests/scenario_tests_async/test_middleware.py diff --git a/tests/async_scenario_tests/test_shortcut.py b/tests/scenario_tests_async/test_shortcut.py similarity index 100% rename from tests/async_scenario_tests/test_shortcut.py rename to tests/scenario_tests_async/test_shortcut.py diff --git a/tests/async_scenario_tests/test_slash_command.py b/tests/scenario_tests_async/test_slash_command.py similarity index 100% rename from tests/async_scenario_tests/test_slash_command.py rename to tests/scenario_tests_async/test_slash_command.py diff --git a/tests/async_scenario_tests/test_ssl_check.py b/tests/scenario_tests_async/test_ssl_check.py similarity index 100% rename from tests/async_scenario_tests/test_ssl_check.py rename to tests/scenario_tests_async/test_ssl_check.py diff --git a/tests/async_scenario_tests/test_view_closed.py b/tests/scenario_tests_async/test_view_closed.py similarity index 100% rename from tests/async_scenario_tests/test_view_closed.py rename to tests/scenario_tests_async/test_view_closed.py diff --git a/tests/async_scenario_tests/test_view_submission.py b/tests/scenario_tests_async/test_view_submission.py similarity index 100% rename from tests/async_scenario_tests/test_view_submission.py rename to tests/scenario_tests_async/test_view_submission.py From 72733ab582586ce749ac88fcb2f915d89b945130 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Sep 2020 15:20:12 +0900 Subject: [PATCH 053/865] Fix the inputs in adapter tests --- tests/adapter_tests/test_aws_lambda.py | 12 +++++++++--- tests/adapter_tests/test_cherrypy.py | 5 +++-- tests/adapter_tests/test_fastapi.py | 12 +++++++++--- tests/adapter_tests/test_flask.py | 12 +++++++++--- tests/adapter_tests/test_starlette.py | 12 +++++++++--- tests/adapter_tests_async/test_async_fastapi.py | 12 +++++++++--- tests/adapter_tests_async/test_async_starlette.py | 12 +++++++++--- 7 files changed, 57 insertions(+), 20 deletions(-) diff --git a/tests/adapter_tests/test_aws_lambda.py b/tests/adapter_tests/test_aws_lambda.py index 0838fdb1b..d0969c318 100644 --- a/tests/adapter_tests/test_aws_lambda.py +++ b/tests/adapter_tests/test_aws_lambda.py @@ -1,5 +1,6 @@ import json from time import time +from urllib.parse import quote from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient @@ -45,8 +46,13 @@ def generate_signature(self, body: str, timestamp: str): ) def build_headers(self, timestamp: str, body: str): + content_type = ( + "application/json" + if body.startswith("{") + else "application/x-www-form-urlencoded" + ) return { - "content-type": ["application/x-www-form-urlencoded"], + "content-type": [content_type], "x-slack-signature": [self.generate_signature(body, timestamp)], "x-slack-request-timestamp": [timestamp], } @@ -116,7 +122,7 @@ def shortcut_handler(ack): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" event = { "body": body, "queryStringParameters": {}, @@ -152,7 +158,7 @@ def command_handler(ack): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), input event = { "body": body, "queryStringParameters": {}, diff --git a/tests/adapter_tests/test_cherrypy.py b/tests/adapter_tests/test_cherrypy.py index 187aaed37..fec885343 100644 --- a/tests/adapter_tests/test_cherrypy.py +++ b/tests/adapter_tests/test_cherrypy.py @@ -1,5 +1,6 @@ import json from time import time +from urllib.parse import quote import cherrypy from cherrypy.test import helper @@ -120,7 +121,7 @@ def test_shortcuts(self): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" cherrypy.request.process_request_body = True self.getPage( "/slack/events", @@ -147,7 +148,7 @@ def test_commands(self): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), input cherrypy.request.process_request_body = True self.getPage( "/slack/events", diff --git a/tests/adapter_tests/test_fastapi.py b/tests/adapter_tests/test_fastapi.py index 699c471c1..6bc01c4ca 100644 --- a/tests/adapter_tests/test_fastapi.py +++ b/tests/adapter_tests/test_fastapi.py @@ -1,6 +1,7 @@ import json import re from time import time +from urllib.parse import quote from fastapi import FastAPI from slack_sdk.signature import SignatureVerifier @@ -39,8 +40,13 @@ def generate_signature(self, body: str, timestamp: str): ) def build_headers(self, timestamp: str, body: str): + content_type = ( + "application/json" + if body.startswith("{") + else "application/x-www-form-urlencoded" + ) return { - "content-type": "application/x-www-form-urlencoded", + "content-type": content_type, "x-slack-signature": self.generate_signature(body, timestamp), "x-slack-request-timestamp": timestamp, } @@ -112,7 +118,7 @@ def shortcut_handler(ack): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" api = FastAPI() app_handler = SlackRequestHandler(app) @@ -151,7 +157,7 @@ def command_handler(ack): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), input api = FastAPI() app_handler = SlackRequestHandler(app) diff --git a/tests/adapter_tests/test_flask.py b/tests/adapter_tests/test_flask.py index 4196521b7..6776b98f8 100644 --- a/tests/adapter_tests/test_flask.py +++ b/tests/adapter_tests/test_flask.py @@ -1,5 +1,6 @@ import json from time import time +from urllib.parse import quote from flask import Flask, request from slack_sdk.signature import SignatureVerifier @@ -36,8 +37,13 @@ def generate_signature(self, body: str, timestamp: str): ) def build_headers(self, timestamp: str, body: str): + content_type = ( + "application/json" + if body.startswith("{") + else "application/x-www-form-urlencoded" + ) return { - "content-type": ["application/x-www-form-urlencoded"], + "content-type": [content_type], "x-slack-signature": [self.generate_signature(body, timestamp)], "x-slack-request-timestamp": [timestamp], } @@ -108,7 +114,7 @@ def shortcut_handler(ack): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" flask_app = Flask(__name__) @@ -146,7 +152,7 @@ def command_handler(ack): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), input flask_app = Flask(__name__) diff --git a/tests/adapter_tests/test_starlette.py b/tests/adapter_tests/test_starlette.py index 2f07180c9..f662e8840 100644 --- a/tests/adapter_tests/test_starlette.py +++ b/tests/adapter_tests/test_starlette.py @@ -1,6 +1,7 @@ import json import re from time import time +from urllib.parse import quote from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient @@ -40,8 +41,13 @@ def generate_signature(self, body: str, timestamp: str): ) def build_headers(self, timestamp: str, body: str): + content_type = ( + "application/json" + if body.startswith("{") + else "application/x-www-form-urlencoded" + ) return { - "content-type": "application/x-www-form-urlencoded", + "content-type": content_type, "x-slack-signature": self.generate_signature(body, timestamp), "x-slack-request-timestamp": timestamp, } @@ -117,7 +123,7 @@ def shortcut_handler(ack): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" async def endpoint(req: Request): return await app_handler.handle(req) @@ -158,7 +164,7 @@ def command_handler(ack): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), input async def endpoint(req: Request): return await app_handler.handle(req) diff --git a/tests/adapter_tests_async/test_async_fastapi.py b/tests/adapter_tests_async/test_async_fastapi.py index a4ff5985f..6c08011a4 100644 --- a/tests/adapter_tests_async/test_async_fastapi.py +++ b/tests/adapter_tests_async/test_async_fastapi.py @@ -1,6 +1,7 @@ import json import re from time import time +from urllib.parse import quote from fastapi import FastAPI from slack_sdk.signature import SignatureVerifier @@ -39,8 +40,13 @@ def generate_signature(self, body: str, timestamp: str): ) def build_headers(self, timestamp: str, body: str): + content_type = ( + "application/json" + if body.startswith("{") + else "application/x-www-form-urlencoded" + ) return { - "content-type": "application/x-www-form-urlencoded", + "content-type": content_type, "x-slack-signature": self.generate_signature(body, timestamp), "x-slack-request-timestamp": timestamp, } @@ -112,7 +118,7 @@ async def shortcut_handler(ack): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" api = FastAPI() app_handler = AsyncSlackRequestHandler(app) @@ -151,7 +157,7 @@ async def command_handler(ack): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), input api = FastAPI() app_handler = AsyncSlackRequestHandler(app) diff --git a/tests/adapter_tests_async/test_async_starlette.py b/tests/adapter_tests_async/test_async_starlette.py index c549cb886..2214e5c59 100644 --- a/tests/adapter_tests_async/test_async_starlette.py +++ b/tests/adapter_tests_async/test_async_starlette.py @@ -1,6 +1,7 @@ import json import re from time import time +from urllib.parse import quote from slack_sdk.signature import SignatureVerifier from slack_sdk.web.async_client import AsyncWebClient @@ -40,8 +41,13 @@ def generate_signature(self, body: str, timestamp: str): ) def build_headers(self, timestamp: str, body: str): + content_type = ( + "application/json" + if body.startswith("{") + else "application/x-www-form-urlencoded" + ) return { - "content-type": "application/x-www-form-urlencoded", + "content-type": content_type, "x-slack-signature": self.generate_signature(body, timestamp), "x-slack-request-timestamp": timestamp, } @@ -115,7 +121,7 @@ async def shortcut_handler(ack): "trigger_id": "111.111.xxxxxx", } - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" async def endpoint(req: Request): return await app_handler.handle(req) @@ -156,7 +162,7 @@ async def command_handler(ack): "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" "&trigger_id=111.111.xxx" ) - timestamp, body = str(int(time())), json.dumps(input) + timestamp, body = str(int(time())), input async def endpoint(req: Request): return await app_handler.handle(req) From 9d2dfbb0e0dfa56860281c6c6d07809e6a45e658 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Sep 2020 16:52:49 +0900 Subject: [PATCH 054/865] Add Bottle tests, change the body parser a bit --- setup.py | 1 + slack_bolt/adapter/bottle/handler.py | 7 +- slack_bolt/request/internals.py | 8 +- tests/adapter_tests/test_bottle.py | 160 +++++++++++++++++++++++ tests/adapter_tests/test_bottle_oauth.py | 34 +++++ 5 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 tests/adapter_tests/test_bottle.py create mode 100644 tests/adapter_tests/test_bottle_oauth.py diff --git a/setup.py b/setup.py index 2f4f32cf2..f1089e78b 100755 --- a/setup.py +++ b/setup.py @@ -51,6 +51,7 @@ "boto3<=2", "moto<=2", # For AWS tests "bottle>=0.12,<1", + "boddle>=0.2,<0.3", # For Bottle app tests "chalice>=1,<2", "click>=7,<8", # for chalice "CherryPy>=18,<19", diff --git a/slack_bolt/adapter/bottle/handler.py b/slack_bolt/adapter/bottle/handler.py index 1467cf35f..4b4d9aef0 100644 --- a/slack_bolt/adapter/bottle/handler.py +++ b/slack_bolt/adapter/bottle/handler.py @@ -7,9 +7,10 @@ def to_bolt_request(req: Request) -> BoltRequest: - return BoltRequest( - body=req.body.read(), query=req.query_string, headers=req.headers, - ) + body = req.body.read() + if isinstance(body, bytes): + body = body.decode("utf-8") + return BoltRequest(body=body, query=req.query_string, headers=req.headers,) def set_response(bolt_resp: BoltResponse, resp: Response) -> None: diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 493e4f219..e2059dbae 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -31,9 +31,11 @@ def parse_query( def parse_body(body: str, content_type: Optional[str]) -> Dict[str, Any]: if not body: return {} - if content_type == "application/json" or body.startswith("{"): + if ( + content_type is not None and content_type == "application/json" + ) or body.startswith("{"): return json.loads(body) - elif content_type == "application/x-www-form-urlencoded": + else: if "payload" in body: params = dict(parse_qsl(body)) if "payload" in params: @@ -42,8 +44,6 @@ def parse_body(body: str, content_type: Optional[str]) -> Dict[str, Any]: return {} else: return dict(parse_qsl(body)) - else: - return {} def extract_enterprise_id(payload: Dict[str, Any]) -> Optional[str]: diff --git a/tests/adapter_tests/test_bottle.py b/tests/adapter_tests/test_bottle.py new file mode 100644 index 000000000..851146734 --- /dev/null +++ b/tests/adapter_tests/test_bottle.py @@ -0,0 +1,160 @@ +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.bottle import SlackRequestHandler +from slack_bolt.app import App +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 + +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,) +app = App(client=web_client, signing_secret=signing_secret,) +handler = SlackRequestHandler(app) + + +@app.event("app_mention") +def event_handler(): + pass + + +@app.shortcut("test-shortcut") +def shortcut_handler(ack): + ack() + + +@app.command("/hello-world") +def command_handler(ack): + ack() + + +from bottle import post, request, response +from boddle import boddle + + +@post("/slack/events") +def slack_events(): + return handler.handle(request, response) + + +class TestBottle: + signature_verifier = SignatureVerifier(signing_secret) + + 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): + return { + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + def test_events(self): + input = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(input) + + with boddle( + method="POST", + path="/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ): + response_body = slack_events() + assert response.status_code == 200 + assert response_body == "" + assert self.mock_received_requests["/auth.test"] == 1 + + def test_shortcuts(self): + 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))}" + + with boddle( + method="POST", + path="/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ): + response_body = slack_events() + assert response.status_code == 200 + assert response_body == "" + assert self.mock_received_requests["/auth.test"] == 1 + + def test_commands(self): + 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 + + with boddle( + method="POST", + path="/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ): + response_body = slack_events() + assert response.status_code == 200 + assert response_body == "" + assert self.mock_received_requests["/auth.test"] == 1 diff --git a/tests/adapter_tests/test_bottle_oauth.py b/tests/adapter_tests/test_bottle_oauth.py new file mode 100644 index 000000000..b45c71631 --- /dev/null +++ b/tests/adapter_tests/test_bottle_oauth.py @@ -0,0 +1,34 @@ +import re + +from slack_bolt.adapter.bottle import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings + +signing_secret = "secret" +app = App( + signing_secret=signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", client_secret="xxx", scopes=["chat:write", "commands"], + ), +) +handler = SlackRequestHandler(app) + +from bottle import get, request, response +from boddle import boddle + + +@get("/slack/install") +def install(): + return handler.handle(request, response) + + +class TestBottle: + def test_oauth(self): + with boddle(method="GET", path="/slack/install"): + response_body = install() + assert response_body == "" + assert response.status_code == 302 + assert re.match( + "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", + response.headers["Location"], + ) From a035652992cc01a12e84583db9f36337f85125be Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Sep 2020 17:03:22 +0900 Subject: [PATCH 055/865] Add Sanic tests --- tests/adapter_tests_async/test_async_sanic.py | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/adapter_tests_async/test_async_sanic.py diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py new file mode 100644 index 000000000..8b4d0bd4c --- /dev/null +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -0,0 +1,194 @@ +import json +import re +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient +from sanic import Sanic +from sanic.request import Request + +from slack_bolt.adapter.sanic.async_handler import AsyncSlackRequestHandler +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +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 TestSanic: + 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 test_events(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + + async def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(input) + + api = Sanic(name="awesome-slack-app") + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + _, response = api.test_client.post( + uri="/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_shortcuts(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + + 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))}" + + api = Sanic(name="awesome-slack-app") + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + _, response = api.test_client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_commands(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + + 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 + + api = Sanic(name="awesome-slack-app") + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + _, response = api.test_client.post( + "/slack/events", data=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_oauth(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + api = Sanic(name="awesome-slack-app") + app_handler = AsyncSlackRequestHandler(app) + + @api.get("/slack/install") + async def endpoint(req: Request): + return await app_handler.handle(req) + + _, response = api.test_client.get("/slack/install", allow_redirects=False) + assert response.status_code == 302 + assert re.match( + "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", + response.headers["Location"], + ) From 4e67d623d944d92dfc5c22f246c98f9a9cf50401 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Sep 2020 17:17:24 +0900 Subject: [PATCH 056/865] Add Falcon tests --- slack_bolt/adapter/falcon/resource.py | 4 +- tests/adapter_tests/test_falcon.py | 186 ++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 tests/adapter_tests/test_falcon.py diff --git a/slack_bolt/adapter/falcon/resource.py b/slack_bolt/adapter/falcon/resource.py index 2ba4481d5..29ec02867 100644 --- a/slack_bolt/adapter/falcon/resource.py +++ b/slack_bolt/adapter/falcon/resource.py @@ -1,4 +1,5 @@ from datetime import datetime +from http import HTTPStatus from falcon import Request, Response @@ -50,7 +51,8 @@ def _to_bolt_request(self, req: Request) -> BoltRequest: def _write_response(self, bolt_resp: BoltResponse, resp: Response): resp.body = bolt_resp.body - resp.status = str(bolt_resp.status) + status = HTTPStatus(bolt_resp.status) + resp.status = str(f"{status.value} {status.phrase}") resp.set_headers(bolt_resp.first_headers_without_set_cookie()) for cookie in bolt_resp.cookies(): for name, c in cookie.items(): diff --git a/tests/adapter_tests/test_falcon.py b/tests/adapter_tests/test_falcon.py new file mode 100644 index 000000000..8a00d90fe --- /dev/null +++ b/tests/adapter_tests/test_falcon.py @@ -0,0 +1,186 @@ +import json +import re +from time import time +from urllib.parse import quote + +import falcon +from falcon import testing +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.falcon import SlackAppResource +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +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 TestFalcon: + 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_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 test_events(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(input) + + api = falcon.API() + resource = SlackAppResource(app) + api.add_route("/slack/events", resource) + + client = testing.TestClient(api) + response = client.simulate_post( + "/slack/events", body=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 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) + + 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))}" + + api = falcon.API() + resource = SlackAppResource(app) + api.add_route("/slack/events", resource) + + client = testing.TestClient(api) + response = client.simulate_post( + "/slack/events", body=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + 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) + + 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 + + api = falcon.API() + resource = SlackAppResource(app) + api.add_route("/slack/events", resource) + + client = testing.TestClient(api) + response = client.simulate_post( + "/slack/events", body=body, headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 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"], + ), + ) + api = falcon.API() + resource = SlackAppResource(app) + api.add_route("/slack/install", resource) + + client = testing.TestClient(api) + response = client.simulate_get("/slack/install") + assert response.status_code == 302 + assert re.match( + "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", + response.headers["Location"], + ) From 767095be8cdf9421cfd335e3598719dcba4beba4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Sep 2020 17:39:54 +0900 Subject: [PATCH 057/865] Fix conflicts with other async tests --- tests/adapter_tests_async/test_async_sanic.py | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py index 8b4d0bd4c..05e7da262 100644 --- a/tests/adapter_tests_async/test_async_sanic.py +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -1,8 +1,10 @@ +import asyncio import json import re from time import time from urllib.parse import quote +import pytest from slack_sdk.signature import SignatureVerifier from slack_sdk.web.async_client import AsyncWebClient from sanic import Sanic @@ -25,13 +27,17 @@ class TestSanic: 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) + @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) def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( @@ -50,7 +56,8 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": timestamp, } - def test_events(self): + @pytest.mark.asyncio + async def test_events(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) async def event_handler(): @@ -87,13 +94,14 @@ async def event_handler(): async def endpoint(req: Request): return await app_handler.handle(req) - _, response = api.test_client.post( - uri="/slack/events", data=body, headers=self.build_headers(timestamp, body), + _, response = await api.asgi_client.post( + url="/slack/events", data=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 - def test_shortcuts(self): + @pytest.mark.asyncio + async def test_shortcuts(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) async def shortcut_handler(ack): @@ -125,13 +133,14 @@ async def shortcut_handler(ack): async def endpoint(req: Request): return await app_handler.handle(req) - _, response = api.test_client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + _, response = await api.asgi_client.post( + url="/slack/events", data=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 - def test_commands(self): + @pytest.mark.asyncio + async def test_commands(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) async def command_handler(ack): @@ -163,13 +172,14 @@ async def command_handler(ack): async def endpoint(req: Request): return await app_handler.handle(req) - _, response = api.test_client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + _, response = await api.asgi_client.post( + url="/slack/events", data=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 - def test_oauth(self): + @pytest.mark.asyncio + async def test_oauth(self): app = AsyncApp( client=self.web_client, signing_secret=self.signing_secret, @@ -186,7 +196,9 @@ def test_oauth(self): async def endpoint(req: Request): return await app_handler.handle(req) - _, response = api.test_client.get("/slack/install", allow_redirects=False) + _, response = await api.asgi_client.get( + url="/slack/install", allow_redirects=False + ) assert response.status_code == 302 assert re.match( "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", From e4a5e089619ea76367cfb4580207e99fb4de636e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Sep 2020 18:04:32 +0900 Subject: [PATCH 058/865] Apply formatter to samples --- samples/oauth_app_settings.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/samples/oauth_app_settings.py b/samples/oauth_app_settings.py index 103cbe8ea..0d1e39e81 100644 --- a/samples/oauth_app_settings.py +++ b/samples/oauth_app_settings.py @@ -16,31 +16,32 @@ logging.basicConfig(level=logging.DEBUG) + def success(args: SuccessArgs) -> BoltResponse: return BoltResponse(status=200, body="Thanks!") + def failure(args: FailureArgs) -> BoltResponse: return BoltResponse(status=args.suggested_status_code, body=args.reason) + app = App( signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), installation_store=FileInstallationStore(), oauth_settings=OAuthSettings( client_id=os.environ.get("SLACK_CLIENT_ID"), client_secret=os.environ.get("SLACK_CLIENT_SECRET"), - scopes=["app_mentions:read","channels:history","im:history","chat:write"], + scopes=["app_mentions:read", "channels:history", "im:history", "chat:write"], user_scopes=[], redirect_uri=None, install_path="/slack/install", redirect_uri_path="/slack/oauth_redirect", state_store=FileOAuthStateStore(expiration_seconds=600), - callback_options=CallbackOptions( - success=success, - failure=failure - ) - ) + callback_options=CallbackOptions(success=success, failure=failure), + ), ) + @app.command("/hello-bolt-python") def test_command(body, respond, client, ack, logger): logger.info(body) From 844d1ae2b07a472a89c6dbe6e2897a67c57c699b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Sep 2020 18:06:18 +0900 Subject: [PATCH 059/865] version 0.5.0a0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 6d9370c41..497d1f186 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.4.0a0" +__version__ = "0.5.0a0" From 7004e7abd2af8c18eb0fd746b25ac83cfc3d5dd1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 12 Sep 2020 15:52:44 +0900 Subject: [PATCH 060/865] Fix #81 by supporting input validations in dialogs --- samples/dialogs_app.py | 19 ++++++++++++++++--- slack_bolt/context/ack/internals.py | 11 ++++++++++- tests/slack_bolt/context/test_ack.py | 15 +++++++++++++++ .../context/test_async_ack.py | 16 ++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/samples/dialogs_app.py b/samples/dialogs_app.py index 902788edd..cbeedf47c 100644 --- a/samples/dialogs_app.py +++ b/samples/dialogs_app.py @@ -9,7 +9,7 @@ logging.basicConfig(level=logging.DEBUG) -from slack_bolt import App +from slack_bolt import App, Ack app = App() @@ -52,8 +52,21 @@ def test_command(body, client, ack, logger): @app.action({"type": "dialog_submission", "callback_id": "dialog-callback-id"}) -def dialog_submission(ack): - ack() +def dialog_submission(ack: Ack, body: dict): + errors = [] + submission = body["submission"] + if len(submission["loc_origin"]) <= 3: + errors = [ + { + "name": "loc_origin", + "error": "Pickup Location must be longer than 3 characters" + } + ] + if len(errors) > 0: + # or ack({"errors": errors}) + ack(errors=errors) + else: + ack() @app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"}) diff --git a/slack_bolt/context/ack/internals.py b/slack_bolt/context/ack/internals.py index 3b98fc5e7..81c5246e2 100644 --- a/slack_bolt/context/ack/internals.py +++ b/slack_bolt/context/ack/internals.py @@ -62,6 +62,10 @@ def _set_response( if view: body["view"] = convert_to_dict(view) self.response = BoltResponse(status=200, body=body) + elif errors: + # dialogs: errors without response_action + body = {"errors": convert_to_dict_list(errors)} + self.response = BoltResponse(status=200, body=body) else: if len(body) == 1 and "text" in body: self.response = BoltResponse(status=200, body=body["text"]) @@ -79,7 +83,12 @@ def _set_response( if "option_groups" in body: body["option_groups"] = convert_to_dict_list(body["option_groups"]) if "errors" in body: - body["errors"] = convert_to_dict(body["errors"]) + if body.get("response_action", "") == "errors": + # modal + body["errors"] = convert_to_dict(body["errors"]) + else: + # dialog + body["errors"] = convert_to_dict_list(body["errors"]) if "view" in body: body["view"] = convert_to_dict(body["view"]) # no modification for response_type, response_action here diff --git a/tests/slack_bolt/context/test_ack.py b/tests/slack_bolt/context/test_ack.py index e1d9d9bf1..9ecb17a69 100644 --- a/tests/slack_bolt/context/test_ack.py +++ b/tests/slack_bolt/context/test_ack.py @@ -97,6 +97,21 @@ def test_response_type(self): '{"text": "foo", "response_type": "in_channel"}', ) + def test_dialog_errors(self): + expected_body = '{"errors": [{"name": "loc_origin", "error": "Pickup Location must be longer than 3 characters"}]}' + errors = [ + { + "name": "loc_origin", + "error": "Pickup Location must be longer than 3 characters", + } + ] + + ack = Ack() + response: BoltResponse = ack(errors=errors) + assert (response.status, response.body) == (200, expected_body) + response: BoltResponse = ack({"errors": errors}) + assert (response.status, response.body) == (200, expected_body) + def test_view_errors(self): ack = Ack() response: BoltResponse = ack( diff --git a/tests/slack_bolt_async/context/test_async_ack.py b/tests/slack_bolt_async/context/test_async_ack.py index bd7c68267..fa74e198f 100644 --- a/tests/slack_bolt_async/context/test_async_ack.py +++ b/tests/slack_bolt_async/context/test_async_ack.py @@ -101,6 +101,22 @@ async def test_response_type(self): '{"text": "foo", "response_type": "in_channel"}', ) + @pytest.mark.asyncio + async def test_dialog_errors(self): + expected_body = '{"errors": [{"name": "loc_origin", "error": "Pickup Location must be longer than 3 characters"}]}' + errors = [ + { + "name": "loc_origin", + "error": "Pickup Location must be longer than 3 characters", + } + ] + + ack = AsyncAck() + response: BoltResponse = await ack(errors=errors) + assert (response.status, response.body) == (200, expected_body) + response: BoltResponse = await ack({"errors": errors}) + assert (response.status, response.body) == (200, expected_body) + @pytest.mark.asyncio async def test_view_errors(self): ack = AsyncAck() From ffe718fdc49109f1d4ffa0d93c52988a497b971e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 12 Sep 2020 15:59:20 +0900 Subject: [PATCH 061/865] Correct type hints --- slack_bolt/context/ack/internals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/context/ack/internals.py b/slack_bolt/context/ack/internals.py index 81c5246e2..c6ee73755 100644 --- a/slack_bolt/context/ack/internals.py +++ b/slack_bolt/context/ack/internals.py @@ -20,7 +20,7 @@ def _set_response( option_groups: Optional[List[Union[dict, OptionGroup]]] = None, # view_submission response_action: Optional[str] = None, - errors: Optional[Dict[str, str]] = None, + errors: Optional[Union[Dict[str, str], List[Dict[str, str]]]] = None, view: Optional[Union[dict, View]] = None, ) -> BoltResponse: if isinstance(text_or_whole_response, str): From ba14b1f23e5fbd0376a6c90fa1e17ef4a4eb27a3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 12 Sep 2020 16:12:01 +0900 Subject: [PATCH 062/865] Fix a bug brought by #74 --- slack_bolt/adapter/aws_lambda/chalice_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/adapter/aws_lambda/chalice_handler.py b/slack_bolt/adapter/aws_lambda/chalice_handler.py index 3c7fee983..d835055fb 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_handler.py +++ b/slack_bolt/adapter/aws_lambda/chalice_handler.py @@ -20,7 +20,7 @@ def __init__(self, app: App, chalice: Chalice): # type: ignore self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler) self.app.lazy_listener_runner = ChaliceLazyListenerRunner(logger=self.logger) if self.app.oauth_flow is not None: - self.app.oauth_flow.redirect_uri_page_renderer.install_path = "?" + self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?" @classmethod def clear_all_log_handlers(cls): From a65c125ccc8b7b06ef0ff042620fdb261b040aec Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 12 Sep 2020 16:48:17 +0900 Subject: [PATCH 063/865] Fix #80 by making app.action/options more flexible --- samples/dialogs_app.py | 38 +++++-- slack_bolt/listener_matcher/builtins.py | 103 +++++++++++++----- .../scenario_tests/test_attachment_actions.py | 21 ++++ tests/scenario_tests/test_dialogs.py | 66 +++++++++++ .../test_attachment_actions.py | 23 ++++ tests/scenario_tests_async/test_dialogs.py | 70 ++++++++++++ 6 files changed, 287 insertions(+), 34 deletions(-) diff --git a/samples/dialogs_app.py b/samples/dialogs_app.py index cbeedf47c..46265afbc 100644 --- a/samples/dialogs_app.py +++ b/samples/dialogs_app.py @@ -51,8 +51,13 @@ def test_command(body, client, ack, logger): logger.info(res) -@app.action({"type": "dialog_submission", "callback_id": "dialog-callback-id"}) -def dialog_submission(ack: Ack, body: dict): +@app.action("dialog-callback-id") +def dialog_submission_or_cancellation(ack: Ack, body: dict): + if body["type"] == "dialog_cancellation": + # This can be sent only when notify_on_cancel is True + ack() + return + errors = [] submission = body["submission"] if len(submission["loc_origin"]) <= 3: @@ -69,7 +74,30 @@ def dialog_submission(ack: Ack, body: dict): ack() -@app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"}) +# @app.action({"type": "dialog_submission", "callback_id": "dialog-callback-id"}) +# def dialog_submission_or_cancellation(ack: Ack, body: dict): +# errors = [] +# submission = body["submission"] +# if len(submission["loc_origin"]) <= 3: +# errors = [ +# { +# "name": "loc_origin", +# "error": "Pickup Location must be longer than 3 characters" +# } +# ] +# if len(errors) > 0: +# # or ack({"errors": errors}) +# ack(errors=errors) +# else: +# ack() +# +# @app.action({"type": "dialog_cancellation", "callback_id": "dialog-callback-id"}) +# def dialog_cancellation(ack): +# ack() + + +# @app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"}) +@app.options("dialog-callback-id") def dialog_suggestion(ack): ack( { @@ -88,10 +116,6 @@ def dialog_suggestion(ack): ) -@app.action({"type": "dialog_cancellation", "callback_id": "dialog-callback-id"}) -def dialog_cancellation(ack): - ack() - if __name__ == "__main__": app.start(3000) diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index fe07535db..9f7bc58c3 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -179,7 +179,18 @@ def action( asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): - return block_action(constraints, asyncio) + + def func(body: Dict[str, Any]) -> bool: + return ( + _block_action(constraints, body) + or _attachment_action(constraints, body) + or _dialog_submission(constraints, body) + or _dialog_cancellation(constraints, body) + or _workflow_step_edit(constraints, body) + ) + + return build_listener_matcher(func, asyncio) + elif "type" in constraints: action_type = constraints["type"] if action_type == "block_actions": @@ -206,66 +217,89 @@ def action( ) +def _block_action( + constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], + body: Dict[str, Any], +) -> bool: + if is_block_actions(body) is False: + return False + + action = to_action(body) + if isinstance(constraints, (str, Pattern)): + action_id = constraints + return _matches(action_id, action["action_id"]) + elif isinstance(constraints, dict): + # block_id matching is optional + block_id: Optional[Union[str, Pattern]] = constraints.get("block_id") + block_id_matched = block_id is None or _matches( + block_id, action.get("block_id") + ) + action_id_matched = _matches(constraints["action_id"], action["action_id"]) + return block_id_matched and action_id_matched + + def block_action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: - if is_block_actions(body) is False: - return False - - action = to_action(body) - if isinstance(constraints, (str, Pattern)): - action_id = constraints - return _matches(action_id, action["action_id"]) - elif isinstance(constraints, dict): - # block_id matching is optional - block_id: Optional[Union[str, Pattern]] = constraints.get("block_id") - block_id_matched = block_id is None or _matches( - block_id, action.get("block_id") - ) - action_id_matched = _matches(constraints["action_id"], action["action_id"]) - return block_id_matched and action_id_matched + return _block_action(constraints, body) return build_listener_matcher(func, asyncio) +def _attachment_action(callback_id: Union[str, Pattern], body: Dict[str, Any],) -> bool: + return is_attachment_action(body) and _matches(callback_id, body["callback_id"]) + + def attachment_action( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: - return is_attachment_action(body) and _matches(callback_id, body["callback_id"]) + return _attachment_action(callback_id, body) return build_listener_matcher(func, asyncio) +def _dialog_submission(callback_id: Union[str, Pattern], body: Dict[str, Any],) -> bool: + return is_dialog_submission(body) and _matches(callback_id, body["callback_id"]) + + def dialog_submission( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: - return is_dialog_submission(body) and _matches(callback_id, body["callback_id"]) + return _dialog_submission(callback_id, body) return build_listener_matcher(func, asyncio) +def _dialog_cancellation( + callback_id: Union[str, Pattern], body: Dict[str, Any], +) -> bool: + return is_dialog_cancellation(body) and _matches(callback_id, body["callback_id"]) + + def dialog_cancellation( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: - return is_dialog_cancellation(body) and _matches( - callback_id, body["callback_id"] - ) + return _dialog_cancellation(callback_id, body) return build_listener_matcher(func, asyncio) +def _workflow_step_edit( + callback_id: Union[str, Pattern], body: Dict[str, Any], +) -> bool: + return is_workflow_step_edit(body) and _matches(callback_id, body["callback_id"]) + + def workflow_step_edit( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: - return is_workflow_step_edit(body) and _matches( - callback_id, body["callback_id"] - ) + return _workflow_step_edit(callback_id, body) return build_listener_matcher(func, asyncio) @@ -322,7 +356,14 @@ def options( asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): - return block_suggestion(constraints, asyncio) + + def func(body: Dict[str, Any]) -> bool: + return _block_suggestion(constraints, body) or _dialog_suggestion( + constraints, body + ) + + return build_listener_matcher(func, asyncio) + if "action_id" in constraints: return block_suggestion(constraints["action_id"], asyncio) if "callback_id" in constraints: @@ -333,20 +374,28 @@ def options( ) +def _block_suggestion(action_id: Union[str, Pattern], body: Dict[str, Any],) -> bool: + return is_block_suggestion(body) and _matches(action_id, body["action_id"]) + + def block_suggestion( action_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: - return is_block_suggestion(body) and _matches(action_id, body["action_id"]) + return _block_suggestion(action_id, body) return build_listener_matcher(func, asyncio) +def _dialog_suggestion(callback_id: Union[str, Pattern], body: Dict[str, Any],) -> bool: + return is_dialog_suggestion(body) and _matches(callback_id, body["callback_id"]) + + def dialog_suggestion( callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: - return is_dialog_suggestion(body) and _matches(callback_id, body["callback_id"]) + return _dialog_suggestion(callback_id, body) return build_listener_matcher(func, asyncio) diff --git a/tests/scenario_tests/test_attachment_actions.py b/tests/scenario_tests/test_attachment_actions.py index 39403fdfa..490c330cc 100644 --- a/tests/scenario_tests/test_attachment_actions.py +++ b/tests/scenario_tests/test_attachment_actions.py @@ -51,6 +51,15 @@ def test_mock_server_is_running(self): resp = self.web_client.api_test() assert resp != None + def test_success_without_type(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + app.action("pick_channel_for_fun")(simple_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + def test_success(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) app.action( @@ -86,6 +95,18 @@ def test_process_before_response(self): assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 1 + def test_failure_without_type(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + app.action("unknown")(simple_listener) + response = app.dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 2 + def test_failure(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) request = self.build_valid_request() diff --git a/tests/scenario_tests/test_dialogs.py b/tests/scenario_tests/test_dialogs.py index fd17907ad..df8e31d22 100644 --- a/tests/scenario_tests/test_dialogs.py +++ b/tests/scenario_tests/test_dialogs.py @@ -49,6 +49,30 @@ def test_mock_server_is_running(self): resp = self.web_client.api_test() assert resp != None + def test_success_without_type(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + app.options("dialog-callback-id")(handle_suggestion) + app.action("dialog-callback-id")(handle_submission_cancellation) + + request = self.build_valid_request(suggestion_raw_body) + response = app.dispatch(request) + assert response.status == 200 + assert response.body != "" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + assert self.mock_received_requests["/auth.test"] == 1 + + request = self.build_valid_request(submission_raw_body) + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert self.mock_received_requests["/auth.test"] == 2 + + request = self.build_valid_request(cancellation_raw_body) + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert self.mock_received_requests["/auth.test"] == 3 + def test_success(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"})( @@ -169,6 +193,18 @@ def test_process_before_response_2(self): assert response.body == "" assert self.mock_received_requests["/auth.test"] == 3 + def test_suggestion_failure_without_type(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + request = self.build_valid_request(suggestion_raw_body) + response = app.dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + app.options("dialog-callback-iddddd")(handle_suggestion) + response = app.dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 2 + def test_suggestion_failure(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) request = self.build_valid_request(suggestion_raw_body) @@ -195,6 +231,18 @@ def test_suggestion_failure_2(self): assert response.status == 404 assert self.mock_received_requests["/auth.test"] == 2 + def test_submission_failure_without_type(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + request = self.build_valid_request(suggestion_raw_body) + response = app.dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + app.action("dialog-callback-iddddd")(handle_submission) + response = app.dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 2 + def test_submission_failure(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) request = self.build_valid_request(suggestion_raw_body) @@ -221,6 +269,18 @@ def test_submission_failure_2(self): assert response.status == 404 assert self.mock_received_requests["/auth.test"] == 2 + def test_cancellation_failure_without_type(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + request = self.build_valid_request(suggestion_raw_body) + response = app.dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + app.action("dialog-callback-iddddd")(handle_cancellation) + response = app.dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 2 + def test_cancellation_failure(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) request = self.build_valid_request(suggestion_raw_body) @@ -336,3 +396,9 @@ def handle_cancellation(ack, body, payload, action): assert body == action assert payload == action ack() + + +def handle_submission_cancellation(ack, body, payload, action): + assert body == action + assert payload == action + ack() diff --git a/tests/scenario_tests_async/test_attachment_actions.py b/tests/scenario_tests_async/test_attachment_actions.py index d48211d93..564219203 100644 --- a/tests/scenario_tests_async/test_attachment_actions.py +++ b/tests/scenario_tests_async/test_attachment_actions.py @@ -58,6 +58,16 @@ async def test_mock_server_is_running(self): resp = await self.web_client.api_test() assert resp != None + @pytest.mark.asyncio + async def test_success_without_type(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.action("pick_channel_for_fun")(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + @pytest.mark.asyncio async def test_success(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) @@ -110,6 +120,19 @@ async def test_process_before_response_2(self): assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 1 + @pytest.mark.asyncio + async def test_failure_without_type(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + app.action("unknown")(simple_listener) + response = await app.async_dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 2 + @pytest.mark.asyncio async def test_failure(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) diff --git a/tests/scenario_tests_async/test_dialogs.py b/tests/scenario_tests_async/test_dialogs.py index b6a33f317..b5eb31f9b 100644 --- a/tests/scenario_tests_async/test_dialogs.py +++ b/tests/scenario_tests_async/test_dialogs.py @@ -56,6 +56,31 @@ async def test_mock_server_is_running(self): resp = await self.web_client.api_test() assert resp != None + @pytest.mark.asyncio + async def test_success_without_type(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.options("dialog-callback-id")(handle_suggestion) + app.action("dialog-callback-id")(handle_submission_or_cancellation) + + request = self.build_valid_request(suggestion_raw_body) + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body != "" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + assert self.mock_received_requests["/auth.test"] == 1 + + request = self.build_valid_request(submission_raw_body) + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + assert self.mock_received_requests["/auth.test"] == 2 + + request = self.build_valid_request(cancellation_raw_body) + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + assert self.mock_received_requests["/auth.test"] == 3 + @pytest.mark.asyncio async def test_success(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) @@ -180,6 +205,19 @@ async def test_process_before_response_2(self): assert response.body == "" assert self.mock_received_requests["/auth.test"] == 3 + @pytest.mark.asyncio + async def test_suggestion_failure_without_type(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + request = self.build_valid_request(suggestion_raw_body) + response = await app.async_dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + app.options("dialog-callback-iddddd")(handle_suggestion) + response = await app.async_dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 2 + @pytest.mark.asyncio async def test_suggestion_failure(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) @@ -208,6 +246,19 @@ async def test_suggestion_failure_2(self): assert response.status == 404 assert self.mock_received_requests["/auth.test"] == 2 + @pytest.mark.asyncio + async def test_submission_failure_without_type(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + request = self.build_valid_request(suggestion_raw_body) + response = await app.async_dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + app.action("dialog-callback-iddddd")(handle_submission) + response = await app.async_dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 2 + @pytest.mark.asyncio async def test_submission_failure(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) @@ -236,6 +287,19 @@ async def test_submission_failure_2(self): assert response.status == 404 assert self.mock_received_requests["/auth.test"] == 2 + @pytest.mark.asyncio + async def test_cancellation_failure_without_type(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + request = self.build_valid_request(suggestion_raw_body) + response = await app.async_dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + app.action("dialog-callback-iddddd")(handle_cancellation) + response = await app.async_dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 2 + @pytest.mark.asyncio async def test_cancellation_failure(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) @@ -353,3 +417,9 @@ async def handle_cancellation(ack, body, payload, action): assert body == action assert payload == action await ack() + + +async def handle_submission_or_cancellation(ack, body, payload, action): + assert body == action + assert payload == action + await ack() From 8566095fab8d38db0a71adf8532db22435f2e2b1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 12 Sep 2020 17:15:55 +0900 Subject: [PATCH 064/865] Add tests for Pyramid framework --- slack_bolt/adapter/pyramid/handler.py | 10 +- tests/adapter_tests/test_pyramid.py | 178 ++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 tests/adapter_tests/test_pyramid.py diff --git a/slack_bolt/adapter/pyramid/handler.py b/slack_bolt/adapter/pyramid/handler.py index 47512e4cd..47fe96645 100644 --- a/slack_bolt/adapter/pyramid/handler.py +++ b/slack_bolt/adapter/pyramid/handler.py @@ -8,10 +8,14 @@ def to_bolt_request(request: Request) -> BoltRequest: + body: str = "" + if request.body is not None: + if isinstance(request.body, bytes): + body = request.body.decode("utf-8") + else: + body = request.body bolt_req = BoltRequest( - body=request.body.decode("utf-8"), - query=request.query_string, - headers=request.headers, + body=body, query=request.query_string, headers=request.headers, ) return bolt_req diff --git a/tests/adapter_tests/test_pyramid.py b/tests/adapter_tests/test_pyramid.py new file mode 100644 index 000000000..5e4180151 --- /dev/null +++ b/tests/adapter_tests/test_pyramid.py @@ -0,0 +1,178 @@ +import json +from time import time +from unittest import TestCase +from urllib.parse import quote + +from pyramid import testing +from pyramid.request import Request +from pyramid.response import Response +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.pyramid import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +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 TestPyramid(TestCase): + 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(self): + self.config = testing.setUp() + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def tearDown(self): + testing.tearDown() + 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 test_events(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(input) + + request: Request = testing.DummyRequest() + request.path = "/slack/events" + request.method = "POST" + request.body = body.encode("utf-8") + request.headers = self.build_headers(timestamp, body) + response: Response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 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) + + 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))}" + + request: Request = testing.DummyRequest() + request.path = "/slack/events" + request.method = "POST" + request.body = body.encode("utf-8") + request.headers = self.build_headers(timestamp, body) + response: Response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + 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) + + 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 + + request: Request = testing.DummyRequest() + request.path = "/slack/events" + request.method = "POST" + request.body = body.encode("utf-8") + request.headers = self.build_headers(timestamp, body) + response: Response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 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"], + ), + ) + + request: Request = testing.DummyRequest() + request.path = "/slack/install" + request.method = "GET" + response: Response = SlackRequestHandler(app).handle(request) + assert response.status_code == 302 From 2a50c1c60e6ab6346b0d5c39ae3c152e2d948046 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 12 Sep 2020 18:00:33 +0900 Subject: [PATCH 065/865] Add tests for Django adapter --- tests/adapter_tests/test_django.py | 176 ++++++++++++++++++++ tests/adapter_tests/test_django_settings.py | 4 + 2 files changed, 180 insertions(+) create mode 100644 tests/adapter_tests/test_django.py create mode 100644 tests/adapter_tests/test_django_settings.py diff --git a/tests/adapter_tests/test_django.py b/tests/adapter_tests/test_django.py new file mode 100644 index 000000000..d56de8785 --- /dev/null +++ b/tests/adapter_tests/test_django.py @@ -0,0 +1,176 @@ +import json +import os +from time import time +from urllib.parse import quote + +from django.test.client import RequestFactory +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.django import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +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 django.test import TestCase +from django.core.wsgi import get_wsgi_application + + +class TestDjango(TestCase): + 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,) + + os.environ["DJANGO_SETTINGS_MODULE"] = "tests.adapter_tests.test_django_settings" + application = get_wsgi_application() + databases = "__all__" + rf = RequestFactory() + + def setUp(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def tearDown(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): + return { + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + 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) + + input = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(input) + + request = self.rf.post( + "/slack/events", data=body, content_type="application/json" + ) + request.headers = self.build_headers(timestamp, body) + + response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 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) + + 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))}" + + request = self.rf.post( + "/slack/events", + data=body, + content_type="application/x-www-form-urlencoded", + ) + request.headers = self.build_headers(timestamp, body) + + response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + 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) + + 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 + + request = self.rf.post( + "/slack/events", + data=body, + content_type="application/x-www-form-urlencoded", + ) + request.headers = self.build_headers(timestamp, body) + + response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 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"], + ), + ) + request = self.rf.get("/slack/install") + response = SlackRequestHandler(app).handle(request) + assert response.status_code == 302 diff --git a/tests/adapter_tests/test_django_settings.py b/tests/adapter_tests/test_django_settings.py new file mode 100644 index 000000000..7e5f57df3 --- /dev/null +++ b/tests/adapter_tests/test_django_settings.py @@ -0,0 +1,4 @@ +SECRET_KEY = "XXX" +DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "logs/db.sqlite3",} +} From 78f21341a793cd2912115f2e15ad7b7e32568a1a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 12 Sep 2020 18:31:41 +0900 Subject: [PATCH 066/865] Add tests for Tornado adapter --- tests/adapter_tests/test_tornado.py | 167 ++++++++++++++++++++++ tests/adapter_tests/test_tornado_oauth.py | 43 ++++++ 2 files changed, 210 insertions(+) create mode 100644 tests/adapter_tests/test_tornado.py create mode 100644 tests/adapter_tests/test_tornado_oauth.py diff --git a/tests/adapter_tests/test_tornado.py b/tests/adapter_tests/test_tornado.py new file mode 100644 index 000000000..23cebd232 --- /dev/null +++ b/tests/adapter_tests/test_tornado.py @@ -0,0 +1,167 @@ +import json +from time import time +from urllib.parse import quote + +import tornado +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient +from tornado.httpclient import HTTPRequest, HTTPResponse +from tornado.testing import AsyncHTTPTestCase +from tornado.web import Application + +from slack_bolt.adapter.tornado import SlackEventsHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +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 + +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,) + +app = App(client=web_client, signing_secret=signing_secret) + + +@app.event("app_mention") +def event_handler(): + pass + + +@app.shortcut("test-shortcut") +def shortcut_handler(ack): + ack() + + +@app.command("/hello-world") +def command_handler(ack): + ack() + + +class TestTornado(AsyncHTTPTestCase): + signature_verifier = SignatureVerifier(signing_secret) + + def setUp(self): + AsyncHTTPTestCase.setUp(self) + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def tearDown(self): + AsyncHTTPTestCase.tearDown(self) + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def get_app(self): + return Application([("/slack/events", SlackEventsHandler, dict(app=app))]) + + 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, + } + + @tornado.testing.gen_test + async def test_events(self): + input = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(input) + + request = HTTPRequest( + url=self.get_url("/slack/events"), + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + response: HTTPResponse = await self.http_client.fetch(request) + assert response.code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + @tornado.testing.gen_test + async def test_shortcuts(self): + 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))}" + + request = HTTPRequest( + url=self.get_url("/slack/events"), + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + response: HTTPResponse = await self.http_client.fetch(request) + assert response.code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + @tornado.testing.gen_test + async def test_commands(self): + 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 + + request = HTTPRequest( + url=self.get_url("/slack/events"), + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + response: HTTPResponse = await self.http_client.fetch(request) + assert response.code == 200 + assert self.mock_received_requests["/auth.test"] == 1 diff --git a/tests/adapter_tests/test_tornado_oauth.py b/tests/adapter_tests/test_tornado_oauth.py new file mode 100644 index 000000000..71ffc9396 --- /dev/null +++ b/tests/adapter_tests/test_tornado_oauth.py @@ -0,0 +1,43 @@ +import pytest +import tornado +from tornado.httpclient import HTTPRequest, HTTPResponse +from tornado.testing import AsyncHTTPTestCase +from tornado.web import Application + +from slack_bolt.adapter.tornado import SlackOAuthHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.utils import remove_os_env_temporarily, restore_os_env + +signing_secret = "secret" + +app = App( + signing_secret=signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", client_secret="xxx", scopes=["chat:write", "commands"], + ), +) + + +class TestTornado(AsyncHTTPTestCase): + def get_app(self): + return Application([("/slack/install", SlackOAuthHandler, dict(app=app))]) + + def setUp(self): + AsyncHTTPTestCase.setUp(self) + self.old_os_env = remove_os_env_temporarily() + + def tearDown(self): + AsyncHTTPTestCase.tearDown(self) + restore_os_env(self.old_os_env) + + @tornado.testing.gen_test + async def test_oauth(self): + request = HTTPRequest( + url=self.get_url("/slack/install"), method="GET", follow_redirects=False + ) + try: + response: HTTPResponse = await self.http_client.fetch(request) + assert response.code == 302 + except tornado.httpclient.HTTPClientError as e: + assert e.code == 302 From 0e51ccc6528f28d9f7a40cc568860724f0778f84 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 12 Sep 2020 19:19:42 +0900 Subject: [PATCH 067/865] version 0.5.1a0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 497d1f186..e02df6330 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.5.0a0" +__version__ = "0.5.1a0" From 63d7e9a9175ee5a42abcdc277db7112172837571 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 13 Sep 2020 11:05:56 +0900 Subject: [PATCH 068/865] Remove the unnecessary lines from django tests --- tests/adapter_tests/test_django.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/adapter_tests/test_django.py b/tests/adapter_tests/test_django.py index d56de8785..634bb48c8 100644 --- a/tests/adapter_tests/test_django.py +++ b/tests/adapter_tests/test_django.py @@ -16,7 +16,6 @@ ) from tests.utils import remove_os_env_temporarily, restore_os_env from django.test import TestCase -from django.core.wsgi import get_wsgi_application class TestDjango(TestCase): @@ -27,8 +26,6 @@ class TestDjango(TestCase): web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) os.environ["DJANGO_SETTINGS_MODULE"] = "tests.adapter_tests.test_django_settings" - application = get_wsgi_application() - databases = "__all__" rf = RequestFactory() def setUp(self): From 7b9f55553d91b98dcb5740dd07d8202d3ff7d884 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 13 Sep 2020 15:09:10 +0900 Subject: [PATCH 069/865] Return 200 OK instead of 404 for lazy function invocation (just for clarity) --- slack_bolt/app/app.py | 6 ++++-- slack_bolt/app/async_app.py | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 7918a3826..64b07ac00 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -282,7 +282,8 @@ def run_listener( self.lazy_listener_runner.run( function=lazy_func, request=request ) - return None + # This HTTP response won't be sent to Slack API servers. + return BoltResponse(status=200) else: continue else: @@ -328,7 +329,8 @@ def run_ack_function_asynchronously(): self.lazy_listener_runner.run( function=lazy_func, request=request ) - return None + # This HTTP response won't be sent to Slack API servers. + return BoltResponse(status=200) else: continue else: diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index b11de7e44..ecd07e842 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -300,9 +300,11 @@ async def run_async_listener( if request.lazy_function_name: func_name = lazy_func.__name__ if func_name == request.lazy_function_name: - return await self.lazy_listener_runner.run( + await self.lazy_listener_runner.run( function=lazy_func, request=request ) + # This HTTP response won't be sent to Slack API servers. + return BoltResponse(status=200) else: continue else: @@ -354,9 +356,11 @@ async def run_ack_function_asynchronously( if request.lazy_function_name: func_name = lazy_func.__name__ if func_name == request.lazy_function_name: - return await self.lazy_listener_runner.run( + await self.lazy_listener_runner.run( function=lazy_func, request=request ) + # This HTTP response won't be sent to Slack API servers. + return BoltResponse(status=200) else: continue else: From b40025fde47a9a2422747894630a68d20330c5a6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 13 Sep 2020 15:11:16 +0900 Subject: [PATCH 070/865] Fix lazy listener errors in Chalice apps (missing change in #63) --- samples/aws_chalice/.chalice/config.json.simple | 4 +++- samples/aws_chalice/app.py | 12 ++++++++++-- .../aws_lambda/chalice_lazy_listener_runner.py | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/samples/aws_chalice/.chalice/config.json.simple b/samples/aws_chalice/.chalice/config.json.simple index 518945a25..97fe6749b 100644 --- a/samples/aws_chalice/.chalice/config.json.simple +++ b/samples/aws_chalice/.chalice/config.json.simple @@ -7,7 +7,9 @@ "environment_variables": { "SLACK_BOT_TOKEN": "xoxb-xxx", "SLACK_SIGNING_SECRET": "xxx" - } + }, + "manage_iam_role": false, + "iam_role_arn": "arn:aws:iam::1111111111111:role/bolt_python_lambda_invocation" } } } \ No newline at end of file diff --git a/samples/aws_chalice/app.py b/samples/aws_chalice/app.py index 10b9be077..5341fd54a 100644 --- a/samples/aws_chalice/app.py +++ b/samples/aws_chalice/app.py @@ -1,4 +1,5 @@ import logging +import time from chalice import Chalice, Response @@ -15,10 +16,17 @@ def handle_app_mentions(body, say, logger): say("What's up? I'm a Chalice app :wave:") -@bolt_app.command("/hello-bolt-python-chalice") def respond_to_slack_within_3_seconds(ack): - ack("Thanks!") + ack("Accepted!") +def say_it(say): + time.sleep(5) + say("Done!") + +bolt_app.command("/hello-bolt-python-chalice")( + ack=respond_to_slack_within_3_seconds, + lazy=[say_it] +) ChaliceSlackRequestHandler.clear_all_log_handlers() logging.basicConfig(format="%(asctime)s %(message)s", level=logging.DEBUG) diff --git a/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py b/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py index da71bef6d..ce339b043 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py +++ b/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py @@ -30,7 +30,7 @@ def start(self, function: Callable[..., None], request: BoltRequest) -> None: "pathParameters": {}, "stageVariables": {}, "requestContext": chalice_request["context"], - "body": request.body, + "body": request.raw_body, "isBase64Encoded": False, } invocation = self.lambda_client.invoke( From 0993a19e1f3d398399feb040d7b01ece761a28c6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 13 Sep 2020 15:11:25 +0900 Subject: [PATCH 071/865] Add more tests for AWS Lambda --- tests/adapter_tests/test_aws_chalice.py | 270 ++++++++++++++++++ tests/adapter_tests/test_aws_lambda.py | 60 ++++ .../test_lambda_s3_oauth_flow.py | 25 ++ 3 files changed, 355 insertions(+) create mode 100644 tests/adapter_tests/test_aws_chalice.py create mode 100644 tests/adapter_tests/test_lambda_s3_oauth_flow.py diff --git a/tests/adapter_tests/test_aws_chalice.py b/tests/adapter_tests/test_aws_chalice.py new file mode 100644 index 000000000..b03910f73 --- /dev/null +++ b/tests/adapter_tests/test_aws_chalice.py @@ -0,0 +1,270 @@ +import json +from time import time +from typing import Dict, Any +from urllib.parse import quote + +from chalice import Chalice, Response +from chalice.app import Request +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.aws_lambda.chalice_handler import ( + ChaliceSlackRequestHandler, + not_found, +) +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +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 chalice.config import Config +from chalice.local import LocalGateway + + +class TestAwsChalice: + 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_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 test_not_found(self): + response = not_found() + assert response.status_code == 404 + + def test_events(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + + @app.event("app_mention") + def event_handler(): + pass + + input = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(input) + + chalice_app = Chalice(app_name="bolt-python-chalice") + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + @chalice_app.route( + "/slack/events", + methods=["POST"], + content_types=["application/x-www-form-urlencoded", "application/json"], + ) + def events() -> Response: + return slack_handler.handle(chalice_app.current_request) + + response: Dict[str, Any] = LocalGateway(chalice_app, Config()).handle_request( + method="POST", + path="/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response["statusCode"] == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_shortcuts(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + + @app.shortcut("test-shortcut") + def shortcut_handler(ack): + ack() + + 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))}" + + chalice_app = Chalice(app_name="bolt-python-chalice") + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + @chalice_app.route( + "/slack/events", + methods=["POST"], + content_types=["application/x-www-form-urlencoded", "application/json"], + ) + def events() -> Response: + return slack_handler.handle(chalice_app.current_request) + + response: Dict[str, Any] = LocalGateway(chalice_app, Config()).handle_request( + method="POST", + path="/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response["statusCode"] == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_commands(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + + @app.command("/hello-world") + def command_handler(ack): + ack() + + 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 + + chalice_app = Chalice(app_name="bolt-python-chalice") + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + @chalice_app.route( + "/slack/events", + methods=["POST"], + content_types=["application/x-www-form-urlencoded", "application/json"], + ) + def events() -> Response: + return slack_handler.handle(chalice_app.current_request) + + response: Dict[str, Any] = LocalGateway(chalice_app, Config()).handle_request( + method="POST", + path="/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response["statusCode"] == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + def test_lazy_listeners(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + + def command_handler(ack): + ack() + + def say_it(say): + say("Done!") + + app.command("/hello-world")(ack=command_handler, lazy=[say_it]) + + 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 + + chalice_app = Chalice(app_name="bolt-python-chalice") + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + headers = self.build_headers(timestamp, body) + headers["x-slack-bolt-lazy-only"] = "1" + headers["x-slack-bolt-lazy-function-name"] = "say_it" + + request: Request = Request( + method="NONE", + query_params={}, + uri_params={}, + context={}, + stage_vars=None, + is_base64_encoded=False, + body=body, + headers=headers, + ) + response: Response = slack_handler.handle(request) + assert response.status_code == 200 + assert self.mock_received_requests["/auth.test"] == 1 + assert self.mock_received_requests["/chat.postMessage"] == 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"], + ), + ) + + chalice_app = Chalice(app_name="bolt-python-chalice") + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + @chalice_app.route("/slack/install", methods=["GET"]) + def install() -> Response: + return slack_handler.handle(chalice_app.current_request) + + response: Dict[str, Any] = LocalGateway(chalice_app, Config()).handle_request( + method="GET", path="/slack/install", body="", headers={} + ) + assert response["statusCode"] == 302 diff --git a/tests/adapter_tests/test_aws_lambda.py b/tests/adapter_tests/test_aws_lambda.py index d0969c318..560c48108 100644 --- a/tests/adapter_tests/test_aws_lambda.py +++ b/tests/adapter_tests/test_aws_lambda.py @@ -6,6 +6,8 @@ from slack_sdk.web import WebClient from slack_bolt.adapter.aws_lambda import SlackRequestHandler +from slack_bolt.adapter.aws_lambda.handler import not_found +from slack_bolt.adapter.aws_lambda.internals import _first_value from slack_bolt.app import App from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( @@ -57,6 +59,21 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } + def test_not_found(self): + response = not_found() + assert response["statusCode"] == 404 + + def test_first_value(self): + assert _first_value({"foo": [1, 2, 3]}, "foo") == 1 + assert _first_value({"foo": []}, "foo") is None + assert _first_value({}, "foo") is None + + @mock_lambda + def test_clear_all_log_handlers(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + handler = SlackRequestHandler(app) + handler.clear_all_log_handlers() + @mock_lambda def test_events(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -170,6 +187,49 @@ def command_handler(ack): assert response["statusCode"] == 200 assert self.mock_received_requests["/auth.test"] == 1 + @mock_lambda + def test_lazy_listeners(self): + app = App(client=self.web_client, signing_secret=self.signing_secret,) + + def command_handler(ack): + ack() + + def say_it(say): + say("Done!") + + app.command("/hello-world")(ack=command_handler, lazy=[say_it]) + + 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 + headers = self.build_headers(timestamp, body) + headers["x-slack-bolt-lazy-only"] = "1" + headers["x-slack-bolt-lazy-function-name"] = "say_it" + event = { + "body": body, + "queryStringParameters": {}, + "headers": headers, + "requestContext": {"http": {"method": "NONE"}}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert self.mock_received_requests["/auth.test"] == 1 + assert self.mock_received_requests["/chat.postMessage"] == 1 + @mock_lambda def test_oauth(self): app = App( diff --git a/tests/adapter_tests/test_lambda_s3_oauth_flow.py b/tests/adapter_tests/test_lambda_s3_oauth_flow.py new file mode 100644 index 000000000..1bf43d5f0 --- /dev/null +++ b/tests/adapter_tests/test_lambda_s3_oauth_flow.py @@ -0,0 +1,25 @@ +from moto import mock_s3 +from slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow import LambdaS3OAuthFlow +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestLambdaS3OAuthFlow: + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + + def teardown_method(self): + restore_os_env(self.old_os_env) + + @mock_s3 + def test_instantiation(self): + oauth_flow = LambdaS3OAuthFlow( + settings=OAuthSettings( + client_id="111.222", client_secret="xxx", scopes=["chat:write"], + ), + installation_bucket_name="dummy-installation", + oauth_state_bucket_name="dummy-state", + ) + assert oauth_flow is not None + assert oauth_flow.client is not None + assert oauth_flow.logger is not None From 57475254531a29b2aa7d86744c1035b6f7ff7d8f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 13 Sep 2020 16:15:00 +0900 Subject: [PATCH 072/865] Enable auth test flag for single team auth middleware This option is beneficial for improving Slack app's response time by minimizing the auth.test calls with the same token value. --- slack_bolt/app/app.py | 18 ++++++++- slack_bolt/app/async_app.py | 7 +++- .../async_single_team_authorization.py | 29 +++++++++----- .../middleware/authorization/internals.py | 20 ++++++++++ .../single_team_authorization.py | 38 ++++++++++++++----- 5 files changed, 89 insertions(+), 23 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 64b07ac00..3d4d2bea3 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -7,8 +7,8 @@ from http.server import SimpleHTTPRequestHandler, HTTPServer from typing import List, Union, Pattern, Callable, Dict, Optional +from slack_sdk.errors import SlackApiError from slack_sdk.oauth.installation_store import InstallationStore -from slack_sdk.oauth.state_store import OAuthStateStore from slack_sdk.web import WebClient from slack_bolt.error import BoltError @@ -145,7 +145,21 @@ def _init_middleware_list(self): if self._oauth_flow is None: if self._token: - self._middleware_list.append(SingleTeamAuthorization()) + if self._authorization_test_enabled: + self._middleware_list.append(SingleTeamAuthorization()) + else: + try: + auth_test_result = self._client.auth_test(token=self._token) + self._middleware_list.append( + SingleTeamAuthorization( + auth_test_result=auth_test_result, + verification_enabled=self._authorization_test_enabled, + ) + ) + except SlackApiError as err: + raise BoltError( + f"token is invalid (auth.test result: {err.response})" + ) else: raise BoltError( "Either an env variable SLACK_BOT_TOKEN or token argument in constructor is required." diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index ecd07e842..89333dc2e 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -6,7 +6,6 @@ from asyncio import Future from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable -from slack_sdk.oauth import OAuthStateStore from slack_sdk.oauth.installation_store.async_installation_store import ( AsyncInstallationStore, ) @@ -159,7 +158,11 @@ def _init_async_middleware_list(self): ) if self._async_oauth_flow is None: if self._token: - self._async_middleware_list.append(AsyncSingleTeamAuthorization()) + self._async_middleware_list.append( + AsyncSingleTeamAuthorization( + verification_enabled=self._authorization_test_enabled + ) + ) else: raise BoltError( "Either an env variable SLACK_BOT_TOKEN or token argument in constructor is required." diff --git a/slack_bolt/middleware/authorization/async_single_team_authorization.py b/slack_bolt/middleware/authorization/async_single_team_authorization.py index 3f3ad395a..dbe20f546 100644 --- a/slack_bolt/middleware/authorization/async_single_team_authorization.py +++ b/slack_bolt/middleware/authorization/async_single_team_authorization.py @@ -1,16 +1,19 @@ -from typing import Callable, Awaitable +from typing import Callable, Awaitable, Optional -from slack_bolt.auth import AuthorizationResult from slack_bolt.logger import get_bolt_logger from slack_bolt.middleware.authorization.async_authorization import AsyncAuthorization from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse +from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.errors import SlackApiError from .async_internals import _build_error_response, _is_no_auth_required +from .internals import _to_authorization_result class AsyncSingleTeamAuthorization(AsyncAuthorization): - def __init__(self): + def __init__(self, *, verification_enabled: bool = True): + self.verification_enabled = verification_enabled + self.auth_result: Optional[AsyncSlackResponse] = None self.logger = get_bolt_logger(AsyncSingleTeamAuthorization) async def async_process( @@ -22,16 +25,24 @@ async def async_process( ) -> BoltResponse: if _is_no_auth_required(req): return await next() + try: + if not self.verification_enabled: + if self.auth_result is None: + self.auth_result = await req.context.client.auth_test() + req.context["authorization_result"] = _to_authorization_result( + auth_test_result=self.auth_result, + bot_token=req.context.client.token, + request_user_id=req.context.user_id, + ) + return await next() + auth_result = await req.context.client.auth_test() if auth_result: - req.context["authorization_result"] = AuthorizationResult( - enterprise_id=auth_result.get("enterprise_id", None), - team_id=auth_result.get("team_id", None), - bot_user_id=auth_result.get("user_id", None), - bot_id=auth_result.get("bot_id", None), + req.context["authorization_result"] = _to_authorization_result( + auth_test_result=auth_result, bot_token=req.context.client.token, - user_id=req.context.user_id, + request_user_id=req.context.user_id, ) return await next() else: diff --git a/slack_bolt/middleware/authorization/internals.py b/slack_bolt/middleware/authorization/internals.py index 466166857..fd1d29836 100644 --- a/slack_bolt/middleware/authorization/internals.py +++ b/slack_bolt/middleware/authorization/internals.py @@ -1,3 +1,8 @@ +from typing import Optional, Union + +from slack_sdk.web import SlackResponse + +from slack_bolt.auth import AuthorizationResult from slack_bolt.request.request import BoltRequest from slack_bolt.response import BoltResponse @@ -27,3 +32,18 @@ def _build_error_response() -> BoltResponse: return BoltResponse( status=200, body=":x: Please install this app into the workspace :bow:", ) + + +def _to_authorization_result( # type: ignore + auth_test_result: Union[SlackResponse, "AsyncSlackResponse"], + bot_token: str, + request_user_id: Optional[str], +): + return AuthorizationResult( + enterprise_id=auth_test_result.get("enterprise_id", None), + team_id=auth_test_result.get("team_id", None), + bot_user_id=auth_test_result.get("user_id", None), + bot_id=auth_test_result.get("bot_id", None), + bot_token=bot_token, + user_id=request_user_id, + ) diff --git a/slack_bolt/middleware/authorization/single_team_authorization.py b/slack_bolt/middleware/authorization/single_team_authorization.py index c22e2117a..db6c3e718 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -1,16 +1,27 @@ -from typing import Callable +from typing import Callable, Optional -from slack_bolt.auth import AuthorizationResult from slack_bolt.logger import get_bolt_logger from slack_bolt.middleware.authorization import Authorization from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from slack_sdk.errors import SlackApiError -from .internals import _build_error_response, _is_no_auth_required +from slack_sdk.web import SlackResponse +from .internals import ( + _build_error_response, + _is_no_auth_required, + _to_authorization_result, +) class SingleTeamAuthorization(Authorization): - def __init__(self): + def __init__( + self, + *, + auth_test_result: Optional[SlackResponse] = None, + verification_enabled: bool = True, + ): + self.auth_test_result = auth_test_result + self.verification_enabled = verification_enabled self.logger = get_bolt_logger(SingleTeamAuthorization) def process( @@ -18,16 +29,23 @@ def process( ) -> BoltResponse: if _is_no_auth_required(req): return next() + + if not self.verification_enabled: + # Skip calling auth.test every time the app receives requests + req.context["authorization_result"] = _to_authorization_result( + auth_test_result=self.auth_test_result, + bot_token=req.context.client.token, + request_user_id=req.context.user_id, + ) + return next() + try: auth_result = req.context.client.auth_test() if auth_result: - req.context["authorization_result"] = AuthorizationResult( - enterprise_id=auth_result.get("enterprise_id", None), - team_id=auth_result.get("team_id", None), - bot_user_id=auth_result.get("user_id", None), - bot_id=auth_result.get("bot_id", None), + req.context["authorization_result"] = _to_authorization_result( + auth_test_result=auth_result, bot_token=req.context.client.token, - user_id=req.context.user_id, + request_user_id=req.context.user_id, ) return next() else: From d777e39bb114346d98688feb4ce675037a2525ae Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 13 Sep 2020 17:02:58 +0900 Subject: [PATCH 073/865] Update README --- README.md | 57 +++++++++++++++++++++++++++++++------ samples/readme_app.py | 4 +-- samples/readme_async_app.py | 13 +++++++-- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 16e388689..f32d1174d 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,6 @@ from slack_bolt import App # export SLACK_BOT_TOKEN=xoxb-*** app = App() -# Middleware -@app.middleware # or app.use(log_request) -def log_request(logger, body, next): - logger.info(body) - return next() - # Events API: https://api.slack.com/events-api @app.event("app_mention") def event_test(say): @@ -84,10 +78,10 @@ def open_modal(ack, client, logger, body): logger.debug(api_response) @app.view("view-id") -def view_submission(ack, body, logger): +def view_submission(ack, view, logger): ack() # Prints {'b': {'a': {'type': 'plain_text_input', 'value': 'Your Input'}}} - logger.info(body["view"]["state"]["values"]) + logger.info(view["state"]["values"]) if __name__ == "__main__": app.start(3000) # POST http://localhost:3000/slack/events @@ -104,6 +98,53 @@ python app.py ngrok http 3000 ``` +## AsyncApp Setup + +If you prefer building Slack apps using [asyncio](https://docs.python.org/3/library/asyncio.html), you can go with `AsyncApp` instead. You can use async/await style for everything in the app. To use `AsyncApp`, [AIOHTTP](https://docs.aiohttp.org/en/stable/) library is required for asynchronous Slack Web API calls and the default web server. + +```bash +python -m venv env +source env/bin/activate +pip install slack_bolt aiohttp +``` + +Import `slack_bolt.async_app.AsyncApp` instead of `slack_bolt.App`. All middleware/listeners must be async functions. Inside the functions, all utility methods such as `ack`, `say`, and `respond` requires `await` keyword. + +```python +from slack_bolt.async_app import AsyncApp + +app = AsyncApp() + +@app.event("app_mention") +async def event_test(body, say, logger): + logger.info(body) + await say("What's up?") + +@app.command("/hello-bolt-python") +async def command(ack, body, respond): + await ack() + await respond(f"Hi <@{body['user_id']}>!") + +if __name__ == "__main__": + app.start(3000) +``` + +Starting the app is exactly the same with the way using `slack_bolt.App`. + +```bash +export SLACK_SIGNING_SECRET=*** +export SLACK_BOT_TOKEN=xoxb-*** +python app.py + +# in another terminal +ngrok http 3000 +``` + +If you want to use another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at the built-in adapters and their samples. + +* [The Bolt app samples](https://github.com/slackapi/bolt-python/tree/main/samples) +* [The built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) + # Feedback We are keen to hear your feedback. Please feel free to [submit an issue](https://github.com/slackapi/bolt-python/issues)! diff --git a/samples/readme_app.py b/samples/readme_app.py index f6547583f..6cdbd8ec1 100644 --- a/samples/readme_app.py +++ b/samples/readme_app.py @@ -50,10 +50,10 @@ def open_modal(ack, client, logger, body): @app.view("view-id") -def view_submission(ack, body, logger): +def view_submission(ack, view, logger): ack() # Prints {'b': {'a': {'type': 'plain_text_input', 'value': 'Your Input'}}} - logger.info(body["view"]["state"]["values"]) + logger.info(view["state"]["values"]) if __name__ == "__main__": diff --git a/samples/readme_async_app.py b/samples/readme_async_app.py index a50b850ee..092dca64b 100644 --- a/samples/readme_async_app.py +++ b/samples/readme_async_app.py @@ -10,6 +10,15 @@ app = AsyncApp() +from slack_bolt.async_app import AsyncApp + +app = AsyncApp() + +@app.command("/hello-bolt-python") +async def command(ack, body, respond): + await ack() + await respond(f"Hi <@{body['user_id']}>!") + # Middleware @app.middleware # or app.use(log_request) async def log_request(logger, body, next): @@ -51,10 +60,10 @@ async def open_modal(ack, client, logger, body): @app.view("view-id") -async def view_submission(ack, body, logger): +async def view_submission(ack, view, logger): await ack() # Prints {'b': {'a': {'type': 'plain_text_input', 'value': 'Your Input'}}} - logger.info(body["view"]["state"]["values"]) + logger.info(view["state"]["values"]) if __name__ == "__main__": From eb1954fd7aaee55ac27b6e6d669f3c79a624bfea Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 14 Sep 2020 14:34:35 +0900 Subject: [PATCH 074/865] version 0.5.2a0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index e02df6330..f5dbeea42 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.5.1a0" +__version__ = "0.5.2a0" From 43b602085291213b1a248d5c4769a75edbe1cffe Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 14 Sep 2020 17:35:22 +0900 Subject: [PATCH 075/865] Add more tests --- tests/adapter_tests/test_aws_chalice.py | 4 +- tests/adapter_tests/test_aws_lambda.py | 2 +- tests/adapter_tests/test_django.py | 2 +- .../test_lambda_s3_oauth_flow.py | 1 + tests/adapter_tests/test_tornado.py | 1 - tests/adapter_tests/test_tornado_oauth.py | 1 - tests/adapter_tests_async/test_async_sanic.py | 4 +- tests/scenario_tests/test_middleware.py | 1 - tests/scenario_tests_async/test_events.py | 4 +- tests/scenario_tests_async/test_message.py | 4 +- tests/scenario_tests_async/test_middleware.py | 4 +- .../test_slash_command.py | 4 +- tests/scenario_tests_async/test_ssl_check.py | 4 +- .../test_custom_listener_matcher.py | 32 ++++++++ tests/slack_bolt/middleware/__init__.py | 0 .../middleware/authorization/__init__.py | 0 .../test_single_team_authorization.py | 68 ++++++++++++++++ .../oauth/test_oauth_flow_sqlite3.py | 3 - tests/slack_bolt/request/test_internals.py | 1 - .../context/test_async_say.py | 2 +- .../listener_matcher/__init__.py | 0 .../test_async_custom_listener_matcher.py | 33 ++++++++ tests/slack_bolt_async/middleware/__init__.py | 0 .../middleware/authorization/__init__.py | 0 .../test_single_team_authorization.py | 77 +++++++++++++++++++ .../oauth/test_async_oauth_flow_sqlite3.py | 3 - 26 files changed, 228 insertions(+), 27 deletions(-) create mode 100644 tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py create mode 100644 tests/slack_bolt/middleware/__init__.py create mode 100644 tests/slack_bolt/middleware/authorization/__init__.py create mode 100644 tests/slack_bolt/middleware/authorization/test_single_team_authorization.py create mode 100644 tests/slack_bolt_async/listener_matcher/__init__.py create mode 100644 tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py create mode 100644 tests/slack_bolt_async/middleware/__init__.py create mode 100644 tests/slack_bolt_async/middleware/authorization/__init__.py create mode 100644 tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py diff --git a/tests/adapter_tests/test_aws_chalice.py b/tests/adapter_tests/test_aws_chalice.py index b03910f73..3c76976c9 100644 --- a/tests/adapter_tests/test_aws_chalice.py +++ b/tests/adapter_tests/test_aws_chalice.py @@ -5,6 +5,8 @@ from chalice import Chalice, Response from chalice.app import Request +from chalice.config import Config +from chalice.local import LocalGateway from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient @@ -19,8 +21,6 @@ cleanup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env -from chalice.config import Config -from chalice.local import LocalGateway class TestAwsChalice: diff --git a/tests/adapter_tests/test_aws_lambda.py b/tests/adapter_tests/test_aws_lambda.py index 560c48108..c4463c513 100644 --- a/tests/adapter_tests/test_aws_lambda.py +++ b/tests/adapter_tests/test_aws_lambda.py @@ -2,6 +2,7 @@ from time import time from urllib.parse import quote +from moto import mock_lambda from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient @@ -15,7 +16,6 @@ cleanup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env -from moto import mock_lambda class LambdaContext: diff --git a/tests/adapter_tests/test_django.py b/tests/adapter_tests/test_django.py index 634bb48c8..108dc256d 100644 --- a/tests/adapter_tests/test_django.py +++ b/tests/adapter_tests/test_django.py @@ -3,6 +3,7 @@ from time import time from urllib.parse import quote +from django.test import TestCase from django.test.client import RequestFactory from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient @@ -15,7 +16,6 @@ cleanup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env -from django.test import TestCase class TestDjango(TestCase): diff --git a/tests/adapter_tests/test_lambda_s3_oauth_flow.py b/tests/adapter_tests/test_lambda_s3_oauth_flow.py index 1bf43d5f0..4e3644b64 100644 --- a/tests/adapter_tests/test_lambda_s3_oauth_flow.py +++ b/tests/adapter_tests/test_lambda_s3_oauth_flow.py @@ -1,4 +1,5 @@ from moto import mock_s3 + from slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow import LambdaS3OAuthFlow from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.utils import remove_os_env_temporarily, restore_os_env diff --git a/tests/adapter_tests/test_tornado.py b/tests/adapter_tests/test_tornado.py index 23cebd232..c5331b845 100644 --- a/tests/adapter_tests/test_tornado.py +++ b/tests/adapter_tests/test_tornado.py @@ -11,7 +11,6 @@ from slack_bolt.adapter.tornado import SlackEventsHandler from slack_bolt.app import App -from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, diff --git a/tests/adapter_tests/test_tornado_oauth.py b/tests/adapter_tests/test_tornado_oauth.py index 71ffc9396..dd892f3bc 100644 --- a/tests/adapter_tests/test_tornado_oauth.py +++ b/tests/adapter_tests/test_tornado_oauth.py @@ -1,4 +1,3 @@ -import pytest import tornado from tornado.httpclient import HTTPRequest, HTTPResponse from tornado.testing import AsyncHTTPTestCase diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py index 05e7da262..ef0438f1c 100644 --- a/tests/adapter_tests_async/test_async_sanic.py +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -5,10 +5,10 @@ from urllib.parse import quote import pytest -from slack_sdk.signature import SignatureVerifier -from slack_sdk.web.async_client import AsyncWebClient from sanic import Sanic from sanic.request import Request +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.adapter.sanic.async_handler import AsyncSlackRequestHandler from slack_bolt.app.async_app import AsyncApp diff --git a/tests/scenario_tests/test_middleware.py b/tests/scenario_tests/test_middleware.py index c533abe5b..517c1b1ce 100644 --- a/tests/scenario_tests/test_middleware.py +++ b/tests/scenario_tests/test_middleware.py @@ -1,6 +1,5 @@ import json from time import time -from urllib.parse import quote from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient diff --git a/tests/scenario_tests_async/test_events.py b/tests/scenario_tests_async/test_events.py index 33b72d43b..ef00c6d27 100644 --- a/tests/scenario_tests_async/test_events.py +++ b/tests/scenario_tests_async/test_events.py @@ -4,11 +4,11 @@ from time import time import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest -from slack_sdk.signature import SignatureVerifier -from slack_sdk.web.async_client import AsyncWebClient from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, diff --git a/tests/scenario_tests_async/test_message.py b/tests/scenario_tests_async/test_message.py index 57936e2bc..c2d6dda52 100644 --- a/tests/scenario_tests_async/test_message.py +++ b/tests/scenario_tests_async/test_message.py @@ -4,11 +4,11 @@ from time import time import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest -from slack_sdk.signature import SignatureVerifier -from slack_sdk.web.async_client import AsyncWebClient from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, diff --git a/tests/scenario_tests_async/test_middleware.py b/tests/scenario_tests_async/test_middleware.py index c3fd35da3..3b81a387f 100644 --- a/tests/scenario_tests_async/test_middleware.py +++ b/tests/scenario_tests_async/test_middleware.py @@ -3,11 +3,11 @@ from time import time import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest -from slack_sdk.signature import SignatureVerifier -from slack_sdk.web.async_client import AsyncWebClient from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, diff --git a/tests/scenario_tests_async/test_slash_command.py b/tests/scenario_tests_async/test_slash_command.py index f8cd36f53..e161b6ea2 100644 --- a/tests/scenario_tests_async/test_slash_command.py +++ b/tests/scenario_tests_async/test_slash_command.py @@ -3,11 +3,11 @@ from time import time import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest -from slack_sdk.signature import SignatureVerifier -from slack_sdk.web.async_client import AsyncWebClient from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, diff --git a/tests/scenario_tests_async/test_ssl_check.py b/tests/scenario_tests_async/test_ssl_check.py index 2ea86f98f..3a22e85b8 100644 --- a/tests/scenario_tests_async/test_ssl_check.py +++ b/tests/scenario_tests_async/test_ssl_check.py @@ -2,11 +2,11 @@ from time import time import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest -from slack_sdk.signature import SignatureVerifier -from slack_sdk.web.async_client import AsyncWebClient from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, diff --git a/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py b/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py new file mode 100644 index 000000000..e241298d5 --- /dev/null +++ b/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py @@ -0,0 +1,32 @@ +from slack_bolt import BoltRequest, BoltResponse, CustomListenerMatcher +from slack_bolt.listener_matcher import ListenerMatcher + + +def func(body, request, response, dummy): + assert body is not None + assert request is not None + assert response is not None + assert dummy is None + return body["result"] + + +class TestCustomListenerMatcher: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_instantiation(self): + matcher: ListenerMatcher = CustomListenerMatcher( + app_name="foo", func=func, + ) + resp = BoltResponse(status=201) + + req = BoltRequest(body='payload={"result":true}') + result = matcher.matches(req, resp) + assert result is True + + req = BoltRequest(body='payload={"result":false}') + result = matcher.matches(req, resp) + assert result is False diff --git a/tests/slack_bolt/middleware/__init__.py b/tests/slack_bolt/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/middleware/authorization/__init__.py b/tests/slack_bolt/middleware/authorization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py new file mode 100644 index 000000000..4b1965c64 --- /dev/null +++ b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py @@ -0,0 +1,68 @@ +from slack_sdk import WebClient + +from slack_bolt.middleware import SingleTeamAuthorization +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +def next(): + return BoltResponse(status=200) + + +class TestSingleTeamAuthorization: + mock_api_server_base_url = "http://localhost:8888" + + def setup_method(self): + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_normal_pattern(self): + authorization = SingleTeamAuthorization( + auth_test_result={}, verification_enabled=True, + ) + req = BoltRequest(body="payload={}", headers={}) + req.context["client"] = WebClient( + base_url=self.mock_api_server_base_url, token="xoxb-valid" + ) + resp = BoltResponse(status=404) + + resp = authorization.process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "" + + def test_failure_pattern(self): + authorization = SingleTeamAuthorization( + auth_test_result={}, verification_enabled=True, + ) + req = BoltRequest(body="payload={}", headers={}) + req.context["client"] = WebClient( + base_url=self.mock_api_server_base_url, token="dummy" + ) + resp = BoltResponse(status=404) + + resp = authorization.process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == ":x: Please install this app into the workspace :bow:" + + def test_normal_pattern_disabled(self): + authorization = SingleTeamAuthorization( + auth_test_result={"ok": True}, verification_enabled=False, + ) + req = BoltRequest(body="payload={}", headers={}) + req.context["client"] = WebClient( + base_url=self.mock_api_server_base_url, token="xoxb-valid" + ) + resp = BoltResponse(status=404) + + resp = authorization.process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "" diff --git a/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py index 1e0da3b32..367349cc2 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py +++ b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py @@ -1,13 +1,10 @@ import re from slack_sdk import WebClient -from slack_sdk.oauth.installation_store import FileInstallationStore -from slack_sdk.oauth.state_store import FileOAuthStateStore from slack_bolt import BoltRequest, BoltResponse from slack_bolt.oauth import OAuthFlow from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs -from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( cleanup_mock_web_api_server, setup_mock_web_api_server, diff --git a/tests/slack_bolt/request/test_internals.py b/tests/slack_bolt/request/test_internals.py index 204e09806..fce00681c 100644 --- a/tests/slack_bolt/request/test_internals.py +++ b/tests/slack_bolt/request/test_internals.py @@ -6,7 +6,6 @@ extract_team_id, extract_enterprise_id, parse_query, - parse_body, ) diff --git a/tests/slack_bolt_async/context/test_async_say.py b/tests/slack_bolt_async/context/test_async_say.py index 75350628b..c2afe0c58 100644 --- a/tests/slack_bolt_async/context/test_async_say.py +++ b/tests/slack_bolt_async/context/test_async_say.py @@ -1,8 +1,8 @@ import asyncio import pytest -from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_bolt.context.say.async_say import AsyncSay from tests.mock_web_api_server import ( diff --git a/tests/slack_bolt_async/listener_matcher/__init__.py b/tests/slack_bolt_async/listener_matcher/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py b/tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py new file mode 100644 index 000000000..6e226c9b6 --- /dev/null +++ b/tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py @@ -0,0 +1,33 @@ +import pytest + +from slack_bolt.listener_matcher.async_listener_matcher import ( + AsyncCustomListenerMatcher, + AsyncListenerMatcher, +) +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse + + +async def func(body, request, response, dummy): + assert body is not None + assert request is not None + assert response is not None + assert dummy is None + return body["result"] + + +class TestAsyncCustomListenerMatcher: + @pytest.mark.asyncio + async def test_instantiation(self): + matcher: AsyncListenerMatcher = AsyncCustomListenerMatcher( + app_name="foo", func=func, + ) + resp = BoltResponse(status=201) + + req = AsyncBoltRequest(body='payload={"result":true}') + result = await matcher.async_matches(req, resp) + assert result is True + + req = AsyncBoltRequest(body='payload={"result":false}') + result = await matcher.async_matches(req, resp) + assert result is False diff --git a/tests/slack_bolt_async/middleware/__init__.py b/tests/slack_bolt_async/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/middleware/authorization/__init__.py b/tests/slack_bolt_async/middleware/authorization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py new file mode 100644 index 000000000..37e901fb0 --- /dev/null +++ b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py @@ -0,0 +1,77 @@ +import asyncio + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.middleware.authorization.async_single_team_authorization import ( + AsyncSingleTeamAuthorization, +) +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +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 + + +async def next(): + return BoltResponse(status=200) + + +class TestSingleTeamAuthorization: + mock_api_server_base_url = "http://localhost:8888" + + @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_normal_pattern(self): + authorization = AsyncSingleTeamAuthorization(verification_enabled=True,) + req = AsyncBoltRequest(body="payload={}", headers={}) + req.context["client"] = AsyncWebClient( + base_url=self.mock_api_server_base_url, token="xoxb-valid" + ) + resp = BoltResponse(status=404) + + resp = await authorization.async_process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "" + + @pytest.mark.asyncio + async def test_failure_pattern(self): + authorization = AsyncSingleTeamAuthorization(verification_enabled=True,) + req = AsyncBoltRequest(body="payload={}", headers={}) + req.context["client"] = AsyncWebClient( + base_url=self.mock_api_server_base_url, token="dummy" + ) + resp = BoltResponse(status=404) + + resp = await authorization.async_process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == ":x: Please install this app into the workspace :bow:" + + @pytest.mark.asyncio + async def test_normal_pattern_disabled(self): + authorization = AsyncSingleTeamAuthorization(verification_enabled=False,) + req = AsyncBoltRequest(body="payload={}", headers={}) + req.context["client"] = AsyncWebClient( + base_url=self.mock_api_server_base_url, token="xoxb-valid" + ) + resp = BoltResponse(status=404) + + resp = await authorization.async_process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "" diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py index dd3f9aa42..4697f6e2a 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py @@ -2,8 +2,6 @@ import re import pytest -from slack_sdk.oauth.installation_store import FileInstallationStore -from slack_sdk.oauth.state_store import FileOAuthStateStore from slack_sdk.web.async_client import AsyncWebClient from slack_bolt import BoltResponse @@ -13,7 +11,6 @@ AsyncCallbackOptions, ) from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow -from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( cleanup_mock_web_api_server, From 6355714bb6917f39cc2cee96f21463bc3eac1d74 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 14 Sep 2020 18:07:58 +0900 Subject: [PATCH 076/865] Add more tests for say, respond --- .../context/test_respond_internals.py | 16 ++++++++++++++++ tests/slack_bolt/context/test_say.py | 16 ++++++++++++++++ .../slack_bolt_async/context/test_async_say.py | 18 ++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/tests/slack_bolt/context/test_respond_internals.py b/tests/slack_bolt/context/test_respond_internals.py index 35d8edc5f..173fdf003 100644 --- a/tests/slack_bolt/context/test_respond_internals.py +++ b/tests/slack_bolt/context/test_respond_internals.py @@ -27,3 +27,19 @@ def test_build_message_blocks2(self): assert message is not None assert isinstance(message["blocks"][0], dict) assert message["blocks"][0]["block_id"] == "foo" + + def test_build_message_attachments(self): + message = _build_message(attachments=[{}]) + assert message is not None + + def test_build_message_response_type(self): + message = _build_message(response_type="in_channel") + assert message is not None + + def test_build_message_replace_original(self): + message = _build_message(replace_original=True) + assert message is not None + + def test_build_message_delete_original(self): + message = _build_message(delete_original=True) + assert message is not None diff --git a/tests/slack_bolt/context/test_say.py b/tests/slack_bolt/context/test_say.py index 8b696168a..a28fbbd9d 100644 --- a/tests/slack_bolt/context/test_say.py +++ b/tests/slack_bolt/context/test_say.py @@ -1,3 +1,4 @@ +import pytest from slack_sdk import WebClient from slack_sdk.web import SlackResponse @@ -24,3 +25,18 @@ def test_say(self): say = Say(client=self.web_client, channel="C111") response: SlackResponse = say(text="Hi there!") assert response.status_code == 200 + + def test_say_dict(self): + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say({"text": "Hi!"}) + assert response.status_code == 200 + + def test_say_dict_channel(self): + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say({"text": "Hi!", "channel": "C111"}) + assert response.status_code == 200 + + def test_say_invalid(self): + say = Say(client=self.web_client, channel="C111") + with pytest.raises(ValueError): + say([]) diff --git a/tests/slack_bolt_async/context/test_async_say.py b/tests/slack_bolt_async/context/test_async_say.py index c2afe0c58..03a2978ec 100644 --- a/tests/slack_bolt_async/context/test_async_say.py +++ b/tests/slack_bolt_async/context/test_async_say.py @@ -31,3 +31,21 @@ async def test_say(self): say = AsyncSay(client=self.web_client, channel="C111") response: AsyncSlackResponse = await say(text="Hi there!") assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_say_dict(self): + say = AsyncSay(client=self.web_client, channel="C111") + response: AsyncSlackResponse = await say({"text": "Hi!"}) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_say_dict_channel(self): + say = AsyncSay(client=self.web_client, channel="C111") + response: AsyncSlackResponse = await say({"text": "Hi!", "channel": "C111"}) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_say_invalid(self): + say = AsyncSay(client=self.web_client, channel="C111") + with pytest.raises(ValueError): + await say([]) From ecdfb532d7f6da736350b9c80327d061c257fe9d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 16 Sep 2020 15:40:46 +0900 Subject: [PATCH 077/865] Fix #87 by correcting App internals (#88) * Fix #87 by correcting App internals * Remove unnecessary changes * Make an error message in tests consistent * Fix the issues detected by pytype --- slack_bolt/app/app.py | 28 +++++++++++-------- .../async_multi_teams_authorization.py | 2 +- .../multi_teams_authorization.py | 2 +- .../single_team_authorization.py | 2 +- slack_bolt/oauth/async_oauth_flow.py | 16 +++++------ tests/scenario_tests/test_events.py | 18 ++++++++++++ tests/scenario_tests_async/test_events.py | 14 ++++++++++ 7 files changed, 59 insertions(+), 23 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 3d4d2bea3..665c23b9b 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -323,16 +323,20 @@ def run_ack_function_asynchronously(): except Exception as e: # The default response status code is 500 in this case. # You can customize this by passing your own error handler. - if response is None: - response = BoltResponse(status=500) - response.status = 500 - if ack.response is not None: # already acknowledged - response = None - - self._listener_error_handler.handle( - error=e, request=request, response=response, - ) - ack.response = response + if listener.auto_acknowledgement: + self._listener_error_handler.handle( + error=e, request=request, response=response, + ) + else: + if response is None: + response = BoltResponse(status=500) + response.status = 500 + if ack.response is not None: # already acknowledged + response = None + self._listener_error_handler.handle( + error=e, request=request, response=response, + ) + ack.response = response self._listener_executor.submit(run_ack_function_asynchronously) @@ -768,12 +772,12 @@ def __init__( self._port: int = port self._bolt_endpoint_path: str = path self._bolt_app: App = app - self._bolt_oauth_flow: OAuthFlow = oauth_flow + self._bolt_oauth_flow: Optional[OAuthFlow] = oauth_flow _port: int = self._port _bolt_endpoint_path: str = self._bolt_endpoint_path _bolt_app: App = self._bolt_app - _bolt_oauth_flow: OAuthFlow = self._bolt_oauth_flow + _bolt_oauth_flow: Optional[OAuthFlow] = self._bolt_oauth_flow class SlackAppHandler(SimpleHTTPRequestHandler): def do_GET(self): diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index 3fe6d7286..fc2759d17 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -73,7 +73,7 @@ async def async_process( # TODO: bot -> user token req.context["token"] = bot.bot_token req.context["client"] = create_async_web_client(bot.bot_token) - return next() + return await next() except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index cdbe4425f..a427cf379 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -6,7 +6,7 @@ from slack_bolt.response import BoltResponse from slack_sdk.errors import SlackApiError from slack_sdk.oauth.installation_store import InstallationStore, Bot -from . import Authorization +from .authorization import Authorization from .internals import _build_error_response, _is_no_auth_required from ...util.utils import create_web_client diff --git a/slack_bolt/middleware/authorization/single_team_authorization.py b/slack_bolt/middleware/authorization/single_team_authorization.py index db6c3e718..8cf1b62cd 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -1,7 +1,7 @@ from typing import Callable, Optional from slack_bolt.logger import get_bolt_logger -from slack_bolt.middleware.authorization import Authorization +from .authorization import Authorization from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from slack_sdk.errors import SlackApiError diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 8c508adec..ff1dc6c22 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -11,7 +11,7 @@ AsyncFailureArgs, ) from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings -from slack_bolt.request import BoltRequest +from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from slack_sdk.errors import SlackApiError from slack_sdk.oauth import OAuthStateUtils @@ -140,18 +140,18 @@ def sqlite3( # Installation # ----------------------------- - async def handle_installation(self, request: BoltRequest) -> BoltResponse: + async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse: state = await self.issue_new_state(request) return await self.build_authorize_url_redirection(request, state) # ---------------------- # Internal methods for Installation - async def issue_new_state(self, request: BoltRequest) -> str: + async def issue_new_state(self, request: AsyncBoltRequest) -> str: return await self.settings.state_store.async_issue() async def build_authorize_url_redirection( - self, request: BoltRequest, state: str + self, request: AsyncBoltRequest, state: str ) -> BoltResponse: return BoltResponse( status=302, @@ -167,7 +167,7 @@ async def build_authorize_url_redirection( # Callback # ----------------------------- - async def handle_callback(self, request: BoltRequest) -> BoltResponse: + async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse: # failure due to end-user's cancellation or invalid redirection to slack.com error = request.query.get("error", [None])[0] @@ -175,14 +175,14 @@ async def handle_callback(self, request: BoltRequest) -> BoltResponse: return await self.failure_handler( AsyncFailureArgs( request=request, - reason=error, + reason=error, # type: ignore suggested_status_code=200, settings=self.settings, ) ) # state parameter verification - state = request.query.get("state", [None])[0] + state: Optional[str] = request.query.get("state", [None])[0] if not self.settings.state_utils.is_valid_browser(state, request.headers): return await self.failure_handler( AsyncFailureArgs( @@ -302,7 +302,7 @@ async def run_installation(self, code: str) -> Optional[Installation]: return None async def store_installation( - self, request: BoltRequest, installation: Installation + self, request: AsyncBoltRequest, installation: Installation ): # may raise BoltError await self.settings.installation_store.async_save(installation) diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index 83984d15e..366490571 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -143,3 +143,21 @@ def handle_app_mention(body, say, payload, event): assert self.mock_received_requests["/auth.test"] == 1 sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_stable_auto_ack(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + @app.event("reaction_added") + def handle_app_mention(): + raise Exception("Something wrong!") + + for _ in range(10): + timestamp, body = ( + str(int(time())), + json.dumps(self.valid_reaction_added_body), + ) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 diff --git a/tests/scenario_tests_async/test_events.py b/tests/scenario_tests_async/test_events.py index ef00c6d27..66322cfa2 100644 --- a/tests/scenario_tests_async/test_events.py +++ b/tests/scenario_tests_async/test_events.py @@ -129,6 +129,16 @@ async def test_reaction_added(self): await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 + @pytest.mark.asyncio + async def test_stable_auto_ack(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.event("reaction_added")(always_failing) + + for _ in range(10): + request = self.build_valid_reaction_added_request() + response = await app.async_dispatch(request) + assert response.status == 200 + app_mention_body = { "token": "verification_token", @@ -189,3 +199,7 @@ async def whats_up(body, say, payload, event): async def skip_middleware(req, resp, next): # return next() pass + + +async def always_failing(): + raise Exception("Something wrong!") From 557ae625b9e2d051304f80cdbb098ad2c3e9b93d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 16 Sep 2020 16:00:37 +0900 Subject: [PATCH 078/865] version 0.5.3a0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index f5dbeea42..33c9fe1a2 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.5.2a0" +__version__ = "0.5.3a0" From 7db2f7c1639780ec42be1ffc5755ab9c2a271f4e Mon Sep 17 00:00:00 2001 From: Shay DeWael Date: Fri, 28 Aug 2020 18:52:10 -0700 Subject: [PATCH 079/865] Add documentation infra (#51) * Add documentation infrastructure * modify sidebar * please github * change base url --- docs/.gitignore | 7 + docs/Gemfile | 3 + docs/_advanced/example.md | 14 + docs/_basic/example.md | 30 ++ docs/_config.yml | 59 ++++ docs/_includes/analytics.html | 7 + docs/_includes/head.html | 26 ++ docs/_includes/header.html | 12 + docs/_includes/sidebar.html | 57 ++++ docs/_includes/tag_manager.html | 12 + docs/_layouts/default.html | 52 +++ docs/_layouts/tutorial.html | 36 +++ docs/_tutorials/getting_started.md | 15 + docs/assets/basic-information-page.png | Bin 0 -> 402110 bytes docs/assets/bolt-favicon.png | Bin 0 -> 3376 bytes docs/assets/bolt-logo.svg | 1 + docs/assets/bolt-py-logo.svg | 1 + docs/assets/bot-token.png | Bin 0 -> 423392 bytes docs/assets/ngrok.gif | Bin 0 -> 49094 bytes docs/assets/request-url-config.png | Bin 0 -> 168494 bytes docs/assets/signing-secret.png | Bin 0 -> 289939 bytes docs/assets/style.css | 426 +++++++++++++++++++++++++ docs/index.md | 7 + docs/jp.md | 8 + docs/scripts/tutorial_nav.js | 41 +++ 25 files changed, 814 insertions(+) create mode 100644 docs/.gitignore create mode 100644 docs/Gemfile create mode 100644 docs/_advanced/example.md create mode 100644 docs/_basic/example.md create mode 100644 docs/_config.yml create mode 100644 docs/_includes/analytics.html create mode 100644 docs/_includes/head.html create mode 100644 docs/_includes/header.html create mode 100644 docs/_includes/sidebar.html create mode 100644 docs/_includes/tag_manager.html create mode 100644 docs/_layouts/default.html create mode 100644 docs/_layouts/tutorial.html create mode 100644 docs/_tutorials/getting_started.md create mode 100644 docs/assets/basic-information-page.png create mode 100644 docs/assets/bolt-favicon.png create mode 100644 docs/assets/bolt-logo.svg create mode 100644 docs/assets/bolt-py-logo.svg create mode 100644 docs/assets/bot-token.png create mode 100644 docs/assets/ngrok.gif create mode 100644 docs/assets/request-url-config.png create mode 100644 docs/assets/signing-secret.png create mode 100644 docs/assets/style.css create mode 100644 docs/index.md create mode 100644 docs/jp.md create mode 100644 docs/scripts/tutorial_nav.js diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 000000000..c48a717ef --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,7 @@ +_site +Gemfile.lock +.env +.jekyll-metadata +.vscode/ +.bundle/ +vendor/ diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 000000000..bcdec967b --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' +gem 'github-pages', group: :jekyll_plugins +gem 'dotenv' diff --git a/docs/_advanced/example.md b/docs/_advanced/example.md new file mode 100644 index 000000000..889ffb77c --- /dev/null +++ b/docs/_advanced/example.md @@ -0,0 +1,14 @@ +--- +title: Example +lang: en +slug: advanced-ex +order: 0 +--- + +
+An advanced section example. +
+ +```python +# We love Python <3 +``` diff --git a/docs/_basic/example.md b/docs/_basic/example.md new file mode 100644 index 000000000..9473acdd3 --- /dev/null +++ b/docs/_basic/example.md @@ -0,0 +1,30 @@ +--- +title: Example +lang: en +slug: ex +order: 0 +--- + +
+An example section +
+ +```python +# We love Python <3 +``` + +
+ +

Secondary section

+
+ +
+Some accessory information if need-be + +
+ +```python +# Ooo i'm coding +``` + +
diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 000000000..f576f4cdd --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,59 @@ +# For technical reasons, this file is *NOT* reloaded automatically when you use +# 'bundle exec jekyll serve'. If you change this file, please restart the server process. +title: Bolt +description: >- + A framework that makes Slack app development fast and straight-forward. + With a single interface for Slack’s Web API, Events API, and interactive features, + Bolt gives you the full power of the Slack platform out of the box. +baseurl: /bolt-python +url: https://slack.dev + +collections: + basic: + output: false + advanced: + output: false + tutorials: + output: true + permalink: /tutorials/:slug + +defaults: + - + scope: + path: "" + values: + layout: "default" + +# Translation strings used in templates - they are typically used using t[page.lang] +# so it's important to have corresponding strings for each translated language +t: + en: + basic: Basic concepts + advanced: Advanced concepts + start: Getting started + contribute: Contributing + ja-jp: + basic: 基本的な概念 + advanced: 応用コンセプト + start: Bolt 入門ガイド + contribute: 貢献 + +# Metadata +repo_name: bolt-python +github_username: SlackAPI + +code_of_conduct_url: https://slackhq.github.io/code-of-conduct +cla_url: https://cla-assistant.io/slackapi/bolt-python + +google_analytics: UA-56978219-13 +google_tag_manager: GTM-KFZ5MK7 + +# Build settings +markdown: kramdown +kramdown: + parse_block_html: true +plugins: + - jemoji + - jekyll-redirect-from + +repository: slackapi/bolt-python diff --git a/docs/_includes/analytics.html b/docs/_includes/analytics.html new file mode 100644 index 000000000..ec5f3a8a2 --- /dev/null +++ b/docs/_includes/analytics.html @@ -0,0 +1,7 @@ + + + diff --git a/docs/_includes/head.html b/docs/_includes/head.html new file mode 100644 index 000000000..c562a230d --- /dev/null +++ b/docs/_includes/head.html @@ -0,0 +1,26 @@ + + + + Slack | Bolt + + + + + + {% if page.lang == "ja-jp" %} + + {% endif %} + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/_includes/header.html b/docs/_includes/header.html new file mode 100644 index 000000000..55924b2d4 --- /dev/null +++ b/docs/_includes/header.html @@ -0,0 +1,12 @@ +
+
+ Code on GitHub + Slack Platform Home + + {% if page.lang == "ja-jp" %} + English + {% else %} + + {% endif %} +
+
diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html new file mode 100644 index 000000000..0b871cfaa --- /dev/null +++ b/docs/_includes/sidebar.html @@ -0,0 +1,57 @@ + diff --git a/docs/_includes/tag_manager.html b/docs/_includes/tag_manager.html new file mode 100644 index 000000000..9ffc5e093 --- /dev/null +++ b/docs/_includes/tag_manager.html @@ -0,0 +1,12 @@ + + + + + + + diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html new file mode 100644 index 000000000..948a77756 --- /dev/null +++ b/docs/_layouts/default.html @@ -0,0 +1,52 @@ +--- +sidebar_style: main +--- + + + +{% include head.html %} + + + {% include tag_manager.html %} +
+
+ {% include sidebar.html %} +
+ + +
+
+ {% include header.html %} +
+ +
+ {% assign basic_sections = site.basic | sort: "order" | where: "lang", page.lang %} + {% for section in basic_sections %} +
+

{{ section.title }}

+ + {{ section.content | markdownify }} + +
+
+ {% endfor %} +
+ +
+ {% assign advanced_sections = site.advanced | sort: "order" | where: "lang", page.lang %} + {% for section in advanced_sections %} +
+

{{ section.title }}

+ + {{ section.content | markdownify }} + +
+
+ {% endfor %} +
+
+ +
+ {% include analytics.html %} + + diff --git a/docs/_layouts/tutorial.html b/docs/_layouts/tutorial.html new file mode 100644 index 000000000..e54aa628b --- /dev/null +++ b/docs/_layouts/tutorial.html @@ -0,0 +1,36 @@ +--- +sidebar_style: main +--- + + + +{% include head.html %} + + + {% include tag_manager.html %} +
+
+ {% include sidebar.html %} +
+ + +
+
+ {% include header.html %} +
+ +
+
    +
    + +
    + {{ content | markdownify }} +
    +
    + +
    + + + {% include analytics.html %} + + diff --git a/docs/_tutorials/getting_started.md b/docs/_tutorials/getting_started.md new file mode 100644 index 000000000..d3ff59bc1 --- /dev/null +++ b/docs/_tutorials/getting_started.md @@ -0,0 +1,15 @@ +--- +title: Getting started +order: 0 +slug: getting-started +lang: en +layout: tutorial +permalink: /tutorial/getting-started +redirect_from: + - /getting-started +--- +# Getting started with Bolt for Python + +
    +This guide is meant to walk you through getting up and running with a Slack app using Bolt for Python. Along the way, we’ll create a new Slack app, set up your local environment, and develop an app that listens and responds to messages from a Slack workspace. +
    diff --git a/docs/assets/basic-information-page.png b/docs/assets/basic-information-page.png new file mode 100644 index 0000000000000000000000000000000000000000..0467c01f55730581e4509769313d4b45828b5a70 GIT binary patch literal 402110 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�Www}!B~;*bgSgF>MYz* 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%0DBnZf73s_u!U+}u2p?HYCzM;0-qZ9CciIh!C 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 PdzI{Mc}%{f!L$DZPUM>r literal 0 HcmV?d00001 diff --git a/docs/assets/bolt-favicon.png b/docs/assets/bolt-favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..bfe5456c172c5e76fa9b13b1e8ab0a793b6e95cf GIT binary patch literal 3376 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 literal 0 HcmV?d00001 diff --git a/docs/assets/bolt-logo.svg b/docs/assets/bolt-logo.svg new file mode 100644 index 000000000..5077600d5 --- /dev/null +++ b/docs/assets/bolt-logo.svg @@ -0,0 +1 @@ + diff --git a/docs/assets/bolt-py-logo.svg b/docs/assets/bolt-py-logo.svg new file mode 100644 index 000000000..c434147aa --- /dev/null +++ b/docs/assets/bolt-py-logo.svg @@ -0,0 +1 @@ +PY \ No newline at end of file diff --git a/docs/assets/bot-token.png b/docs/assets/bot-token.png new file mode 100644 index 0000000000000000000000000000000000000000..8fa723b1523bd9dd7c7a93fde6c46994366e8834 GIT binary patch literal 423392 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 literal 0 HcmV?d00001 diff --git a/docs/assets/signing-secret.png b/docs/assets/signing-secret.png new file mode 100644 index 0000000000000000000000000000000000000000..d32afa03e670f36d023355d91c76f0f475a690f8 GIT binary patch literal 289939 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Μ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

    =$cTRzP6g9AUM*{+>;Kt{TOp-U+J|Vgil81LN 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 z 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

    + + +When you're finished, you'll have this ⚡️[Getting Started with Slack app](https://github.com/slackapi/bolt-python/tree/main/samples/getting_started) to run, modify, and make your own. + +--- + +### Create an app +First thing's first: before you start developing with Bolt, you'll want to [create a Slack app](https://api.slack.com/apps/new). + +> 💡 We recommend using a workspace where you won't disrupt real work getting done — [you can create a new one for free](https://slack.com/get-started#create). + +After you fill out an app name (_you can change it later_) and pick a workspace to install it to, hit the `Create App` button and you'll land on your app's **Basic Information** page. + +This page contains an overview of your app in addition to important credentials you'll need later, like the `Signing Secret` under the **App Credentials** header. + +![Basic Information page](../assets/basic-information-page.png "Basic Information page") + +Look around, add an app icon and description, and then let's start configuring your app 🔩 + +--- + +### Tokens and installing apps +Slack apps use [OAuth to manage access to Slack's APIs](https://api.slack.com/docs/oauth). When an app is installed, you'll receive a token that the app can use to call API methods. + +There are two token types available to a Slack app: user (`xoxp`) and bot (`xoxb`) tokens. User tokens allow you to call API methods on behalf of users after they install or authenticate the app. There may be several user tokens for a single workspace. Bot tokens are associated with bot users, and are only granted once in a workspace where someone installs the app. The bot token your app uses will be the same no matter which user performed the installation. Bot tokens are the token type that _most_ apps use. + +For brevity, we're going to use bot tokens for this guide. + +Navigate to the **OAuth & Permissions** on the left sidebar and scroll down to the **Bot Token Scopes** section. Click **Add an OAuth Scope**. + +For now, we'll just add one scope: [`chat:write`](https://api.slack.com/scopes/chat:write). This grants your app the permission to post messages in channels it's a member of. + +Scroll up to the top of the OAuth & Permissions page and click **Install App to Workspace**. You'll be led through Slack's OAuth UI, where you should allow your app to be installed to your development workspace. + +Once you authorize the installation, you'll land on the **OAuth & Permissions** page and see a **Bot User OAuth Access Token**. + +![OAuth Tokens](../assets/bot-token.png "Bot OAuth Token") + +> 💡 Treat your token like a password and [keep it safe](https://api.slack.com/docs/oauth-safety). Your app uses it to post and retrieve information from Slack workspaces. + +--- + +### Setting up your local project +With the initial configuration handled, it's time to set up a new Bolt project. This is where you'll write the code that handles the logic for your app. + +If you don’t already have a project, let’s create a new one. Create an empty directory: + +```shell +mkdir first-bolt-app +cd first-bolt-app +``` + +Next, we recommend using a [Python virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment) to manage your project's dependencies. This is a great way to prevent conflicts with your system's Python packages. Let's create and activate a new virtual environment with [Python 3.6 or later](https://www.python.org/downloads/): + +```shell +python3 -m venv .venv +source .venv/bin/activate +``` + +We can confirm that the virtual environment is active by checking that the path to `python3` is _inside_ your project ([a similar command is available on Windows](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#activating-a-virtual-environment)): + +```shell +which python3 +# Output: /path/to/first-bolt-app/.venv/bin/python3 +``` + +Before we install the Bolt for Python package to your new project, let's save the bot token and signing secret that was generated when you configured your app. These should be stored as environment variables and should *not* be saved in version control. + +1. **Copy your Signing Secret from the Basic Information page** and then store it in a new environment variable. The following example works on Linux and macOS; but [similar commands are available on Windows](https://superuser.com/questions/212150/how-to-set-env-variable-in-windows-cmd-line/212153#212153). +```shell +export SLACK_SIGNING_SECRET= +``` + +2. **Copy your bot (xoxb) token from the OAuth & Permissions page** and store it in another environment variable. +```shell +export SLACK_BOT_TOKEN=xoxb- +``` + +Now, lets create your app. Install the `slack_bolt` Python package to your virtual environment using the following command: + +```shell +pip install slack_bolt +``` + +Create a new file called `app.py` in this directory and add the following code: + +```python +import os +from slack_bolt import App + +# Initializes your app with your bot token and signing secret +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# Start your app +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + +Your token and signing secret are enough to create your first Bolt app. Save your `app.py` file then on the command line run the following: + +```script +python3 app.py +``` + +Your app should let you know that it's up and running. + +--- + +### Setting up events +Your app behaves similarly to people on your team — it can post messages, add emoji reactions, and more. To listen for events happening in a Slack workspace (like when a message is posted or when a reaction is posted to a message) you'll use the [Events API to subscribe to event types](https://api.slack.com/events-api). + +To enable events for your app, start by going back to your app configuration page (click on the app [from your app management page](https://api.slack.com/apps)). Click **Event Subscriptions** on the left sidebar. Toggle the switch labeled **Enable Events**. + +You'll see a text input labeled **Request URL**. The Request URL is a public URL where Slack will send HTTP POST requests corresponding to events you specify. + +> ⚙️We've collected some of the most common hosting providers Slack developers use to host their apps [on our API site](https://api.slack.com/docs/hosting) + +When an event occurs, Slack will send your app some information about the event, like the user that triggered it and the channel it occurred in. Your app will process the details and can respond accordingly. + +
    + +

    Using a local Request URL for development

    +
    + +If you’re just getting started with your app's development, you probably don’t have a publicly accessible URL yet. Eventually, you’ll want to set one up, but for now a development proxy like [ngrok](https://ngrok.com/) will create a public URL and tunnel requests to your own development environment. We've written a separate tutorial about [using ngrok with Slack for local development](https://api.slack.com/tutorials/tunneling-with-ngrok) that should help you get everything set up. + +Once you’ve installed a development proxy, run it to begin forwarding requests to a specific port (we’re using port `3000` for this example, but if you customized the port used to initialize your app use that port instead): + +```shell +ngrok http 3000 +``` + +![Running ngrok](../assets/ngrok.gif "Running ngrok") + +The output should show a generated URL that you can use (we recommend the one that starts with `https://`). This URL will be the base of your request URL, in this case `https://8e8ec2d7.ngrok.io`. + +--- +
    + +Now you have a public-facing URL for your app that tunnels to your local machine. The Request URL that you use in your app configuration is composed of your public-facing URL combined with the URL your app is listening on. By default, Bolt apps listen at `/slack/events` so our full request URL would be `https://8e8ec2d7.ngrok.io/slack/events`. + +> ⚙️Bolt uses the `/slack/events` endpoint to listen to all incoming requests (whether shortcuts, events, or interactivity payloads). When configuring endpoints within your app configuration, you'll append `/slack/events` to all request URLs. + +Under the **Enable Events** switch in the **Request URL** box, go ahead and paste in your URL. As long as your Bolt app is still running, your URL should become verified. + +After your request URL is verified, scroll down to **Subscribe to Bot Events**. There are four events related to messages: +- `message.channels` listens for messages in public channels that your app is added to +- `message.groups` listens for messages in private channels that your app is added to +- `message.im` listens for messages in your app's DMs with users +- `message.mpim` listens for messages in multi-person DMs that your app is added to + +If you want your bot to listen to messages from everywhere it is added to, choose all four message events. After you’ve selected the events you want your bot to listen to, click the green **Save Changes** button. + +--- + +### Listening and responding to a message +Your app is now ready for some logic. Let's start by using the `message()` method to attach a listener for messages. + +The following example listens and responds to all messages in channels/DMs where your app has been added that contain the word "hello": + +```python +import os +from slack_bolt import App + +# Initializes your app with your bot token and signing secret +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# Listens to incoming messages that contain "hello" +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say(f"Hey there <@{message['user']}>!") + +# Start your app +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + +If you restart your app, so long as your bot user has been added to the channel/DM, when you send any message that contains "hello", it will respond. + +This is a basic example, but it gives you a place to start customizing your app based on your own goals. Let's try something a little more interactive by sending a button rather than plain text. + +--- + +### Sending and responding to actions + +To use features like buttons, select menus, datepickers, modals, and shortcuts, you’ll need to enable interactivity. Similar to events, you'll need to specify a URL for Slack to send the action (such as *user clicked a button*). + +Back on your app configuration page, click on **Interactivity & Shortcuts** on the left side. You'll see that there's another **Request URL** box. + +By default, Bolt is configured to use the same endpoint for interactive components that it uses for events, so use the same request URL as above (in the example, it was `https://8e8ec2d7.ngrok.io/slack/events`). Press the **Save Changes** button in the lower right hand corner, and that's it. Your app is set up to handle interactivity! + +![Configuring a Request URL](../assets/request-url-config.png "Configuring a Request URL") + +Now, let's go back to your app's code and add interactivity. This will consist of two steps: +- First, your app will send a message that contains a button. +- Next, your app will listen to the action of a user clicking the button and respond + +Below, the code from the last section is modified to send a message containing a button rather than just a string: + +```python +import os +from slack_bolt import App + +# Initializes your app with your bot token and signing secret +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# Listens to incoming messages that contain "hello" +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say( + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"Hey there <@{message['user']}>!" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Click Me" + }, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +# Start your app +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + +The value inside of `say()` is now an object that contains an array of `blocks`. Blocks are the building components of a Slack message and can range from text to images to datepickers. In this case, your app will respond with a section block that includes a button as an accessory. Since we're using `blocks`, the `text` is a fallback for notifications and accessibility. + +You'll notice in the button `accessory` object, there is an `action_id`. This will act as a unique identifier for the button so your app can specify what action it wants to respond to. + +> 💡 The [Block Kit Builder](https://app.slack.com/block-kit-builder) is an simple way to prototype your interactive messages. The builder lets you (or anyone on your team) mockup messages and generates the corresponding JSON that you can paste directly in your app. + +Now, if you restart your app and say "hello" in a channel your app is in, you'll see a message with a button. But if you click the button, nothing happens (*yet!*). + +Let's add a handler to send a followup message when someone clicks the button: + +```python +import os +from slack_bolt import App + +# Initializes your app with your bot token and signing secret +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# Listens to incoming messages that contain "hello" +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say( + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"Hey there <@{message['user']}>!" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Click Me" + }, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +@app.action("button_click") +def action_button_click(body, ack, say): + # Acknowledge the action + ack() + say(f"<@{body['user']['id']}> clicked the button") + +# Start your app +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + +You can see that we used `app.action()` to listen for the `action_id` that we named `button_click`. If you restart your app and click the button, you'll see a new message from your app that says you clicked the button. + +--- + +### Next steps +You just built your first [Bolt for Python app](https://github.com/slackapi/bolt-python/tree/main/samples/getting_started)! 🎉 + +Now that you have a basic app up and running, you can start exploring how to make your Bolt app stand out. Here are some ideas about what to explore next: + +* Read through the [Basic concepts](/bolt-python/concepts#basic) to learn about the different methods and features your Bolt app has access to. + +* Explore the different events your bot can listen to with the [`events()` method](/bolt-python/concepts#event-listening). All of the events are listed [on the API site](https://api.slack.com/events). + +* Bolt allows you to [call Web API methods](/bolt-python/concepts#web-api) with the client attached to your app. There are [over 220 methods](https://api.slack.com/methods) on our API site. + +* Learn more about the different token types [on our API site](https://api.slack.com/docs/token-types). Your app may need different tokens depending on the actions you want it to perform. diff --git a/samples/getting_started/.gitignore b/samples/getting_started/.gitignore new file mode 100644 index 000000000..bdfe36378 --- /dev/null +++ b/samples/getting_started/.gitignore @@ -0,0 +1,3 @@ +# Python +.venv/ +env*/ \ No newline at end of file diff --git a/samples/getting_started/README.md b/samples/getting_started/README.md new file mode 100644 index 000000000..4d5038b03 --- /dev/null +++ b/samples/getting_started/README.md @@ -0,0 +1,47 @@ +# Getting Started with ⚡️ Bolt for Python +> Slack app example from 📚 [Getting started with Bolt for Python][1] + +## Overview + +This is a Slack app built with the [Bolt for Python framework][2] that showcases +responding to events and interactive buttons. + +## Running locally + +### 1. Setup environment variables + +```zsh +# Replace with your signing secret and token +export SLACK_BOT_TOKEN= +export SLACK_SIGNING_SECRET= +``` + +### 2. Setup your local project + +```zsh +# Clone this project onto your machine +git clone https://github.com/slackapi/bolt-python.git + +# Change into this project +cd bolt-python/samples/getting_started/ + +# Setup virtual environment +python3 -m venv .venv +source .venv/bin/activate + +# Install the dependencies +pip install -r requirements.txt +``` + +### 3. Start servers + +[Setup ngrok][3] to create a local requests URL for development. + +```zsh +npm run ngrok +npm run start +``` + +[1]: https://slack.dev/bolt-python/tutorial/getting-started +[2]: https://slack.dev/bolt-python/ +[3]: https://slack.dev/bolt-python/tutorial/getting-started#setting-up-events \ No newline at end of file diff --git a/samples/getting_started/app.py b/samples/getting_started/app.py new file mode 100644 index 000000000..23fd2f96d --- /dev/null +++ b/samples/getting_started/app.py @@ -0,0 +1,43 @@ +import os +from slack_bolt import App + +# Initializes your app with your bot token and signing secret +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# Listens to incoming messages that contain "hello" +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say( + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"Hey there <@{message['user']}>!" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Click Me" + }, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +@app.action("button_click") +def action_button_click(body, ack, say): + # Acknowledge the action + ack() + say(f"<@{body['user']['id']}> clicked the button") + +# Start your app +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) \ No newline at end of file diff --git a/samples/getting_started/requirements.txt b/samples/getting_started/requirements.txt new file mode 100644 index 000000000..323ad5e29 --- /dev/null +++ b/samples/getting_started/requirements.txt @@ -0,0 +1 @@ +slack-bolt \ No newline at end of file From 3d1cf51e9e630872ee78a1681e4310202aec7d09 Mon Sep 17 00:00:00 2001 From: Shay DeWael Date: Fri, 18 Sep 2020 14:41:31 -0700 Subject: [PATCH 081/865] Update head.html (#91) --- docs/_includes/head.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_includes/head.html b/docs/_includes/head.html index c562a230d..3e34fd50d 100644 --- a/docs/_includes/head.html +++ b/docs/_includes/head.html @@ -1,7 +1,7 @@ - Slack | Bolt + Slack | Bolt for Python @@ -23,4 +23,4 @@ - \ No newline at end of file + From 2eb09259fd1f01d0a8ebc5368e971e549e0090b6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 19 Sep 2020 07:10:00 +0900 Subject: [PATCH 082/865] Update samples/getting_started/README.md Co-authored-by: Michael Brooks --- samples/getting_started/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/samples/getting_started/README.md b/samples/getting_started/README.md index 4d5038b03..0682fd9c5 100644 --- a/samples/getting_started/README.md +++ b/samples/getting_started/README.md @@ -38,10 +38,10 @@ pip install -r requirements.txt [Setup ngrok][3] to create a local requests URL for development. ```zsh -npm run ngrok -npm run start +ngrok http 3000 +python3 app.py ``` [1]: https://slack.dev/bolt-python/tutorial/getting-started [2]: https://slack.dev/bolt-python/ -[3]: https://slack.dev/bolt-python/tutorial/getting-started#setting-up-events \ No newline at end of file +[3]: https://slack.dev/bolt-python/tutorial/getting-started#setting-up-events From d1dc479d656e2a5fdf2479ff811f225f32484c8a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 19 Sep 2020 08:27:37 +0900 Subject: [PATCH 083/865] Migrate to slack_sdk 3.0.0a5 and fix builds --- scripts/build_pypi_package.sh | 4 ++++ scripts/deploy_to_prod_pypi_org.sh | 4 ++++ scripts/deploy_to_test_pypi_org.sh | 4 ++++ scripts/install_all_and_run_tests.sh | 1 + setup.py | 2 +- slack_bolt/oauth/async_oauth_settings.py | 3 +-- slack_bolt/oauth/oauth_settings.py | 3 +-- tests/adapter_tests/test_aws_chalice.py | 21 +++++++++++++-------- 8 files changed, 29 insertions(+), 13 deletions(-) diff --git a/scripts/build_pypi_package.sh b/scripts/build_pypi_package.sh index 668f31439..773436d23 100755 --- a/scripts/build_pypi_package.sh +++ b/scripts/build_pypi_package.sh @@ -1,5 +1,9 @@ #!/bin/bash +script_dir=`dirname $0` +cd ${script_dir}/.. +rm -rf ./slack_bolt.egg-info + pip install -U pip && \ python setup.py test && \ pip install twine wheel && \ diff --git a/scripts/deploy_to_prod_pypi_org.sh b/scripts/deploy_to_prod_pypi_org.sh index 73093dd10..24c09850f 100755 --- a/scripts/deploy_to_prod_pypi_org.sh +++ b/scripts/deploy_to_prod_pypi_org.sh @@ -1,5 +1,9 @@ #!/bin/bash +script_dir=`dirname $0` +cd ${script_dir}/.. +rm -rf ./slack_bolt.egg-info + pip install -U pip && \ python setup.py test && \ pip install twine wheel && \ diff --git a/scripts/deploy_to_test_pypi_org.sh b/scripts/deploy_to_test_pypi_org.sh index 74971811c..30bf560f4 100755 --- a/scripts/deploy_to_test_pypi_org.sh +++ b/scripts/deploy_to_test_pypi_org.sh @@ -1,5 +1,9 @@ #!/bin/bash +script_dir=`dirname $0` +cd ${script_dir}/.. +rm -rf ./slack_bolt.egg-info + pip install -U pip && \ python setup.py test && \ pip install twine wheel && \ diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index 61774fb20..c7eb6c1a6 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -5,6 +5,7 @@ script_dir=`dirname $0` cd ${script_dir}/.. +rm -rf ./slack_bolt.egg-info test_target="$1" diff --git a/setup.py b/setup.py index f1089e78b..b491f6af0 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["samples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk==3.0.0a4",], + install_requires=["slack_sdk>=3.0.0a5",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index fefdaf76c..f30091ab8 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -106,13 +106,12 @@ def __init__( cookie_name=self.state_cookie_name, expiration_seconds=self.state_expiration_seconds, ) - # TODO: add authorization_url support on the slack-sdk side self.authorize_url_generator = AuthorizeUrlGenerator( client_id=self.client_id, - client_secret=self.client_secret, redirect_uri=self.redirect_uri, scopes=self.scopes, user_scopes=self.user_scopes, + authorization_url=self.authorization_url, ) self.redirect_uri_page_renderer = RedirectUriPageRenderer( install_path=self.install_path, diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index a99e0fc18..7a138d0c4 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -103,13 +103,12 @@ def __init__( cookie_name=self.state_cookie_name, expiration_seconds=self.state_expiration_seconds, ) - # TODO: add authorization_url support on the slack-sdk side self.authorize_url_generator = AuthorizeUrlGenerator( client_id=self.client_id, - client_secret=self.client_secret, redirect_uri=self.redirect_uri, scopes=self.scopes, user_scopes=self.user_scopes, + authorization_url=self.authorization_url, ) self.redirect_uri_page_renderer = RedirectUriPageRenderer( install_path=self.install_path, diff --git a/tests/adapter_tests/test_aws_chalice.py b/tests/adapter_tests/test_aws_chalice.py index 3c76976c9..9ee64dd49 100644 --- a/tests/adapter_tests/test_aws_chalice.py +++ b/tests/adapter_tests/test_aws_chalice.py @@ -232,14 +232,19 @@ def say_it(say): headers["x-slack-bolt-lazy-function-name"] = "say_it" request: Request = Request( - method="NONE", - query_params={}, - uri_params={}, - context={}, - stage_vars=None, - is_base64_encoded=False, - body=body, - headers=headers, + { + "requestContext": { + "httpMethod": "NONE", + "resourcePath": "/slack/events", + }, + "multiValueQueryStringParameters": {}, + "pathParameters": {}, + "context": {}, + "stageVariables": None, + "isBase64Encoded": False, + "body": body, + "headers": headers, + } ) response: Response = slack_handler.handle(request) assert response.status_code == 200 From c30b0b0babaf57a488360d4e14b33f74898644ec Mon Sep 17 00:00:00 2001 From: Shay DeWael Date: Fri, 28 Aug 2020 18:52:10 -0700 Subject: [PATCH 084/865] Add documentation infra (#51) * Add documentation infrastructure * modify sidebar * please github * change base url --- docs/_includes/head.html | 3 +-- docs/_tutorials/getting_started.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/_includes/head.html b/docs/_includes/head.html index 3e34fd50d..e55079ed9 100644 --- a/docs/_includes/head.html +++ b/docs/_includes/head.html @@ -22,5 +22,4 @@ - - + diff --git a/docs/_tutorials/getting_started.md b/docs/_tutorials/getting_started.md index c64ace770..5c1d4af0f 100644 --- a/docs/_tutorials/getting_started.md +++ b/docs/_tutorials/getting_started.md @@ -329,4 +329,4 @@ Now that you have a basic app up and running, you can start exploring how to mak * Bolt allows you to [call Web API methods](/bolt-python/concepts#web-api) with the client attached to your app. There are [over 220 methods](https://api.slack.com/methods) on our API site. -* Learn more about the different token types [on our API site](https://api.slack.com/docs/token-types). Your app may need different tokens depending on the actions you want it to perform. +* Learn more about the different token types [on our API site](https://api.slack.com/docs/token-types). Your app may need different tokens depending on the actions you want it to perform. \ No newline at end of file From f07628d029e437e79dca378dcbc88eb62a2a6c8e Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Fri, 11 Sep 2020 16:28:04 -0700 Subject: [PATCH 085/865] first pass at basic concepts section of docs --- docs/_basic/acknowledging_events.md | 35 ++++++ docs/_basic/authenticating_oauth.md | 66 ++++++++++ docs/_basic/example.md | 30 ----- docs/_basic/listening_actions.md | 46 +++++++ docs/_basic/listening_events.md | 47 +++++++ docs/_basic/listening_messages.md | 43 +++++++ docs/_basic/listening_modals.md | 43 +++++++ docs/_basic/listening_responding_commands.md | 27 ++++ docs/_basic/listening_responding_options.md | 34 +++++ docs/_basic/listening_responding_shortcuts.md | 117 ++++++++++++++++++ docs/_basic/opening_modals.md | 73 +++++++++++ docs/_basic/responding_actions.md | 44 +++++++ docs/_basic/sending_messages.md | 62 ++++++++++ docs/_basic/updating_pushing_modals.md | 56 +++++++++ docs/_basic/web_api.md | 24 ++++ 15 files changed, 717 insertions(+), 30 deletions(-) create mode 100644 docs/_basic/acknowledging_events.md create mode 100644 docs/_basic/authenticating_oauth.md delete mode 100644 docs/_basic/example.md create mode 100644 docs/_basic/listening_actions.md create mode 100644 docs/_basic/listening_events.md create mode 100644 docs/_basic/listening_messages.md create mode 100644 docs/_basic/listening_modals.md create mode 100644 docs/_basic/listening_responding_commands.md create mode 100644 docs/_basic/listening_responding_options.md create mode 100644 docs/_basic/listening_responding_shortcuts.md create mode 100644 docs/_basic/opening_modals.md create mode 100644 docs/_basic/responding_actions.md create mode 100644 docs/_basic/sending_messages.md create mode 100644 docs/_basic/updating_pushing_modals.md create mode 100644 docs/_basic/web_api.md diff --git a/docs/_basic/acknowledging_events.md b/docs/_basic/acknowledging_events.md new file mode 100644 index 000000000..6eb3bf2bd --- /dev/null +++ b/docs/_basic/acknowledging_events.md @@ -0,0 +1,35 @@ +--- +title: Acknowledging events +lang: en +slug: acknowledge +order: 7 +--- + +
    + +Actions, commands, and options events must **always** be acknowledged using the `ack()` function. This lets Slack know that the event was received and updates the Slack user interface accordingly. + +Depending on the type of event, your acknowledgement may be different. For example, when acknowledging a dialog submission you will call `ack()` with validation errors if the submission contains errors, or with no parameters if the submission is valid. + +We recommend calling `ack()` right away before sending a new message or fetching information from your database since you only have 3 seconds to respond. + +
    + +```python +# Listen for dialog submissions with a callback_id of ticket_submit +@app.action("ticket_submit") +def process_submission(ack, action): + # Regex to determine if this is a valid email + is_email = "^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$" + + if re.match(is_email, action["submission"]["email"]): + # It’s a valid email, accept the submission + ack() + else: + # If it isn’t a valid email, acknowledge with an error + errors = [{ + "name": "email_address", + "error": "Sorry, this isn’t a valid email" + }] + ack({"errors": errors}) +``` diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md new file mode 100644 index 000000000..5df92b292 --- /dev/null +++ b/docs/_basic/authenticating_oauth.md @@ -0,0 +1,66 @@ +--- +title: Authenticating with OAuth +lang: en +slug: authenticating-oauth +order: 14 +--- + +
    + +Slack apps installed on multiple workspaces will need to implement OAuth, then store installation information (like access tokens) securely. By providing `client_id`, `client_secret`, `scopes`, `installation_store`, and `state_store` when initializing App, Bolt for Python will handle the work of setting up OAuth routes and verifying state. If you’re implementing a custom receiver, you can make use of our [OAuth library](https://slack.dev/python-slack-sdk/oauth), which is what Bolt for Python uses under the hood. + +Bolt for Python will create a **Redirect URL** `slack/oauth_redirect`, which Slack uses to redirect users after they complete your app’s installation flow. You will need to add this **Redirect URL** in your app configuration settings under **OAuth and Permissions**. This path can be configured in the `OAuthSettings` argument described below. + +Bolt for Python will also create a `slack/install` route, where you can find an **Add to Slack** button for your app to perform direct installs of your app. If you need any additional authorizations (user tokens) from users inside a team when your app is already installed or a reason to dynamically generate an install URL, you can pass your own custom URL generator to `oauth_settings` as `authorize_url_generator`. + +To learn more about the OAuth installation flow with Slack, [read the API documentation](https://api.slack.com/authentication/oauth-v2). + +
    + +```python +oauth_settings = OAuthSettings( + client_id=os.environ["SLACK_CLIENT_ID"], + client_secret=os.environ["SLACK_CLIENT_SECRET"], + scopes=["channels:read", "groups:read", "chat:write"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120) +) + +app = App(signing_secret=os.environ["SIGNING_SECRET"], + oauth_settings=oauth_settings) +``` + +
    + +

    Customizing OAuth defaults

    +
    + +
    +You can override the default OAuth using `oauth_settings`, which can be passed in during the initialization of App. You can override the following: + +- `authorization_url`: Used to toggle between new Slack Apps and Classic Slack Apps +- `install_path`: Override default path for "Add to Slack" button +- `redirect_uri`: Override default redirect url path +- `callback_options`: Provide custom success and failure pages at the end of the OAuth flow +- `state_store`: Provide a custom state store instead of using the built in `OAuthStateStore` + +
    + +```python +oauth_settings = OAuthSettings( + client_id=os.environ["SLACK_CLIENT_ID"], + client_secret=os.environ["SLACK_CLIENT_SECRET"], + scopes=["channels:read", "groups:read", "chat:write", "incoming-webhook"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + install_path="/slack/install_app", + redirect_uri_path="/slack/redirect", + callback_options=CallbackOptions(success=success_handler, + failure=failure_handler) +) + +app = App(signing_secret=os.environ["SIGNING_SECRET"], + oauth_settings=oauth_settings) +``` + +
    diff --git a/docs/_basic/example.md b/docs/_basic/example.md deleted file mode 100644 index 9473acdd3..000000000 --- a/docs/_basic/example.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -title: Example -lang: en -slug: ex -order: 0 ---- - -
    -An example section -
    - -```python -# We love Python <3 -``` - -
    - -

    Secondary section

    -
    - -
    -Some accessory information if need-be - -
    - -```python -# Ooo i'm coding -``` - -
    diff --git a/docs/_basic/listening_actions.md b/docs/_basic/listening_actions.md new file mode 100644 index 000000000..a1b545980 --- /dev/null +++ b/docs/_basic/listening_actions.md @@ -0,0 +1,46 @@ +--- +title: Listening to actions +lang: en +slug: action-listening +order: 5 +--- + +
    +Your app can listen to user actions like button clicks, and menu selects, using the `action` method. + +Actions can be filtered on an `action_id` of type `str` or `RegExp` object. `action_id`s act as unique identifiers for interactive components on the Slack platform. + +You’ll notice in all `action()` examples, `ack()` is used. It is required to call the `ack()` function within an action listener to acknowledge that the event was received from Slack. This is discussed in the [acknowledging events section](#acknowledge). + +
    + +```python +# Your middleware will be called every time an interactive component with the action_id "approve_button" is triggered +@app.action("approve_button") +def update_message(ack): + ack() + # Update the message to reflect the action +``` + +
    + +

    Listening to actions using a constraint object

    +
    + +
    + +You can use a constraints object to listen to `callback_id`s, `block_id`s, and `action_id`s (or any combination of them). Constraints in the object can be of type `str` or `RegExp` object. + +
    + +```python +# Your function will only be called when the action_id matches 'select_user' AND the block_id matches 'assign_ticket' +@app.action({"action_id": "select_user", "block_id": "assign_ticket"}) +def update_message(ack, action, client): + ack() + client.reactions_add(name='white_check_mark', + timestamp=action['ts'], + channel=action['channel']) +``` + +
    diff --git a/docs/_basic/listening_events.md b/docs/_basic/listening_events.md new file mode 100644 index 000000000..4cd8e8a59 --- /dev/null +++ b/docs/_basic/listening_events.md @@ -0,0 +1,47 @@ +--- +title: Listening to events +lang: en +slug: event-listening +order: 3 +--- + +
    + +You can listen to [any Events API event](https://api.slack.com/events) using the `event()` method after subscribing to it in your app configuration. This allows your app to take action when something happens in Slack, like a user reacting to a message or joining a channel. + +The `event()` method requires an `eventType` of type `str`. + +
    + +```python +# When a user joins the team, send a message in a predefined channel asking them to introduce themselves +@app.event("team_join") +def ask_for_introduction(payload, say): + welcome_channel_id = "C12345"; + user_id = payload["event"]["user"] + text = f"Welcome to the team, <@{user_id}>! 🎉 You can introduce yourself in this channel." + say(text=text, channel=welcome_channel_id) +``` + +
    + + +

    Filtering on message subtypes

    +
    + +
    +The `message()` listener is equivalent to `event("message")`. + +You can filter on subtypes of events by passing in the additional key `subtype`. Common message subtypes like `bot_message` and `message_replied` can be found [on the message event page](https://api.slack.com/events/message#message_subtypes). + +
    + +```python +# Matches all messages from bot users +@app.message({"subtype": "message_changed"}) +def log_message_change(logger, payload): + message = payload["event"] + logger.info(f"The user {message['user']} changed the message to {message['text']}") +``` + +
    diff --git a/docs/_basic/listening_messages.md b/docs/_basic/listening_messages.md new file mode 100644 index 000000000..93e462345 --- /dev/null +++ b/docs/_basic/listening_messages.md @@ -0,0 +1,43 @@ +--- +title: Listening to messages +lang: en +slug: message-listening +order: 1 +--- + +
    + +To listen to messages that [your app has access to receive](https://api.slack.com/messaging/retrieving#permissions), you can use the `message()` method which filters out events that aren’t of type `message`. + +`message()` accepts an argument of type `str` or `RegEx` object that filters out any messages that don’t match the pattern. + +
    + +```python +# This will match any message that contains 👋 +@app.message(":wave:") +def say_hello(payload, say): + user = payload['event']['user'] + say(f"Hi there, <@{user}>!") +``` + +
    + +

    Using a RegEx pattern

    +
    + +
    + +The `re.compile()` method can be used instead of a string for more granular matching. + +
    + +```python +@app.message(re.compile("(hi|hello|hey)")) +def say_hello_regex(say, context): + # RegExp matches are inside of context.matches + greeting = context['matches'][0] + say(f"{greeting}, how are you?") +``` + +
    diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md new file mode 100644 index 000000000..13856d7c9 --- /dev/null +++ b/docs/_basic/listening_modals.md @@ -0,0 +1,43 @@ +--- +title: Listening for view submissions +lang: en +slug: view_submissions +order: 12 +--- + +
    + +If a view payload contains any input blocks, you must listen to `view_submission` events to receive their values. To listen to `view_submission` events, you can use the built-in `view()` method. `view()` requires a `callback_id` of type `str` or `RegExp`. + +You can access the value of the `input` blocks by accessing the `state` object. `state` contains a `values` object that uses the `block_id` and unique `action_id` to store the input values. + +Read more about view submissions in our API documentation. + +
    + +```python +# Handle a view_submission event +@app.view("view_b") +def handle_submission(ack, body, client, view): + # Acknowledge the view_submission event + ack() + + # Do whatever you want with the input data - here we're saving it to a DB then sending the user a verifcation of their submission + + # Assume there's an input block with `block_1` as the block_id and `input_a` + val = view["state"]["values"]["block_1"]["input_a"] + user = body["user"]["id"] + + # Message to send user + msg = "" + + # TODO: Store in DB + + if results: + msg = "Your submission was successful" + else: + msg = "There was an error with your submission" + + # Message the user + client.chat_postMessage(channel=user, text=msg) +``` diff --git a/docs/_basic/listening_responding_commands.md b/docs/_basic/listening_responding_commands.md new file mode 100644 index 000000000..4ad3aee3b --- /dev/null +++ b/docs/_basic/listening_responding_commands.md @@ -0,0 +1,27 @@ +--- +title: Listening and responding to commands +lang: en +slug: commands +order: 9 +--- + +
    + +Your app can use the `command()` method to listen to incoming slash command events. The method requires a `command_name` of type `str`. + +Commands must be acknowledged with `ack()` to inform Slack your app has received the event. + +There are two ways to respond to slash commands. The first way is to use `say()`, which accepts a string or JSON payload. The second is `respond()` which is a utility for the `response_url`. These are explained in more depth in the [responding to actions](#action-respond) section. + +When configuring commands within your app configuration, you'll continue to append `/slack/events` to your request URL. + +
    + +```python +# The echo command simply echoes on command +@app.command("/echo") +def repeat_text(ack, say, command): + # Acknowledge command request + ack() + say(f"{command['text']}") +``` diff --git a/docs/_basic/listening_responding_options.md b/docs/_basic/listening_responding_options.md new file mode 100644 index 000000000..444c90c41 --- /dev/null +++ b/docs/_basic/listening_responding_options.md @@ -0,0 +1,34 @@ +--- +title: Listening and responding to options +lang: en +slug: options +order: 13 +--- + +
    +The `options()` method listens for incoming option request payloads from Slack. [Similar to `action()`](#action-listening), +an `action_id` or constraints object is required. In order to load external data into your select menus, you must provide an options load URL in your app configuration. + +While it's recommended to use `action_id` for `external_select` menus, dialogs do not yet support Block Kit so you'll have to +use the constraints object to filter on a `callback_id`. + +To respond to options requests, you'll need to `ack()` with valid options. Both [external select response examples](https://api.slack.com/reference/messaging/block-elements#external-select) and [dialog response examples](https://api.slack.com/dialogs#dynamic_select_elements_external) can be found on our API site. +
    + +```python +# Example of responding to an external_select options request +@app.options("external_action") +def show_options(ack): + options = [ + { + "text": {"type": "plain_text", "text": "Option 1"}, + "value": "1-1", + }, + { + "text": {"type": "plain_text", "text": "Option 2"}, + "value": "1-2", + }, + ] + + ack({"options": options}) +``` diff --git a/docs/_basic/listening_responding_shortcuts.md b/docs/_basic/listening_responding_shortcuts.md new file mode 100644 index 000000000..948750842 --- /dev/null +++ b/docs/_basic/listening_responding_shortcuts.md @@ -0,0 +1,117 @@ +--- +title: Listening and responding to shortcuts +lang: en +slug: shortcuts +order: 8 +--- + +
    + +The `shortcut()` method supports both [global shortcuts](https://api.slack.com/interactivity/shortcuts/using#global_shortcuts) and [message shortcuts](https://api.slack.com/interactivity/shortcuts/using#message_shortcuts). + +Shortcuts are invokable entry points to apps. Global shortcuts are available from within search in Slack. Message shortcuts are available in the context menus of messages. Your app can use the `shortcut()` method to listen to incoming shortcut events. The method requires a `callback_id` parameter of type `str` or `RegExp`. + +Shortcuts must be acknowledged with `ack()` to inform Slack that your app has received the event. + +Shortcuts include a `trigger_id` which an app can use to [open a modal](#creating-modals) that confirms the action the user is taking. + +When configuring shortcuts within your app configuration, you'll continue to append `/slack/events` to your request URL. + +⚠️ Note that global shortcuts do **not** include a channel ID. If your app needs access to a channel ID, you may use a [`conversations_select`](https://api.slack.com/reference/block-kit/block-elements#conversation_select) element within a modal. Message shortcuts do include channel ID. + +
    + +```python + +# The open_modal shortcut opens a plain old modal +@app.shortcut("open_modal") +def open_modal(ack, client): + # Acknowledge the shortcut request + ack() + # Call the views_open method using one of the built-in WebClients + client.views_open( + trigger_id=payload["trigger_id"], + view={ + "type": "modal", + "title": { + "type": "plain_text", + "text": "My App" + }, + "close": { + "type": "plain_text", + "text": "Close" + }, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "About the simplest modal you could conceive of :smile:\n\nMaybe or ." + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "Psssst this modal was designed using " + } + ] + } + ] + } + ) +``` + +
    + +

    Listening to shortcuts using a constraint object

    +
    + +
    + You can use a constraints object to listen to `callback_id`s, and `type`s. Constraints in the object can be of type `str` or `RegExp` object. +
    + +```python + +# Your middleware will only be called when the callback_id matches 'open_modal' AND the type matches 'message_action' +@app.shortcut({"callback_id": "open_modal", "type": "message_action"}) +def open_modal(ack, client): + # acknowledge the shortcut request + ack() + # call the views_open method using one of the built-in WebClients + client.views_open( + trigger_id=payload["trigger_id"], + view={ + "type": "modal", + "title": { + "type": "plain_text", + "text": "My App" + }, + "close": { + "type": "plain_text", + "text": "Close" + }, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "About the simplest modal you could conceive of :smile:\n\nMaybe or ." + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "Psssst this modal was designed using " + } + ] + } + ] + } + ) +``` + +
    diff --git a/docs/_basic/opening_modals.md b/docs/_basic/opening_modals.md new file mode 100644 index 000000000..cee2c90f5 --- /dev/null +++ b/docs/_basic/opening_modals.md @@ -0,0 +1,73 @@ +--- +title: Opening modals +lang: en +slug: opening-modals +order: 10 +--- + +
    +Modals are focused surfaces that allow you to collect user data and display dynamic information. You can open a modal by passing a valid trigger_id and a view payload to the built-in client's views.open method. + +Your app receives trigger_ids in payloads sent to your Request URL triggered user invocation like a slash command, button press, or interaction with a select menu. + +Read more about modal composition in the API documentation. + +
    + +```python +# Listen for a slash command invocation +@app.command("/ticket") +def open_modal(ack, payload, client): + # Acknowledge the command request + ack(); + + # Call views_open with the built-in client + client.views_open({ + # Pass a valid trigger_id within 3 seconds of receiving it + trigger_id=payload["trigger_id"], + # View payload + view={ + "type": "modal", + # View identifier + "callback_id": "view_1", + "title": { + "type": "plain_text", + "text": "Modal title" + }, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Welcome to a modal with _blocks_" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Click me!" + }, + "action_id": "button_abc" + } + }, + { + "type": "input", + "block_id": "input_c", + "label": { + "type": "plain_text", + "text": "What are your hopes and dreams?" + }, + "element": { + "type": "plain_text_input", + "action_id": "dreamy_input", + "multiline": True + } + } + ], + "submit": { + "type": "plain_text", + "text": "Submit" + } + } + }) +``` diff --git a/docs/_basic/responding_actions.md b/docs/_basic/responding_actions.md new file mode 100644 index 000000000..dea3a9c59 --- /dev/null +++ b/docs/_basic/responding_actions.md @@ -0,0 +1,44 @@ +--- +title: Responding to actions +lang: en +slug: action-respond +order: 6 +--- + +
    + +There are two main ways to respond to actions. The first (and most common) way is to use the `say` function. The `say` function sends a message back to the conversation where the incoming event took place. + +The second way to respond to actions is using `respond()`, which is a simple utility to use the `response_url` associated with an action. + +
    + +```python +# Your middleware will be called every time an interactive component with the action_id “approve_button” is triggered +@app.action("approve_button") +def approve_request(ack, say): + # Acknowledge action request + ack(); + say("Request approved 👍"); +``` + +
    + +

    Using respond()

    +
    + +
    + +Since `respond()` is a utility for calling the `response_url`, it behaves in the same way. You can pass a JSON object with a new message payload that will be published back to the source of the original interaction with optional properties like `response_type` (which has a value of `in_channel` or `ephemeral`), `replace_original`, and `delete_original`. + +
    + +```python +# Listens to actions triggered with action_id of “user_select” +@app.action("user_select") +def select_user(ack, action, respond): + ack(); + respond(f"You selected <@{action['selected_user']}>") +``` + +
    diff --git a/docs/_basic/sending_messages.md b/docs/_basic/sending_messages.md new file mode 100644 index 000000000..c43dc8391 --- /dev/null +++ b/docs/_basic/sending_messages.md @@ -0,0 +1,62 @@ +--- +title: Sending messages +lang: en +slug: message-sending +order: 2 +--- + +
    + +Within your listener function, `say()` is available whenever there is an associated conversation (for example, a conversation where the event or action which triggered the listener occurred). `say()` accepts a string to post simple messages and JSON payloads to send more complex messages. The message payload you pass in will be sent to the associated conversation. + +In the case that you’d like to send a message outside of a listener or you want to do something more advanced (like handle specific errors), you can call `client.chat_postMessage` [using the client attached to your Bolt instance](#web-api). + +
    + +```python +# Listens for messages containing "knock knock" and responds with an italicized "who's there?" +@app.message("knock knock") +def ask_who(logger, payload, say): + say("_Who's there?_") +``` + +
    + +

    Sending a message with blocks

    +
    + +
    +`say()` accepts more complex message payloads to make it easy to add functionality and structure to your messages. + +To explore adding rich message layouts to your app, read through [the guide on our API site](https://api.slack.com/messaging/composing/layouts) and look through templates of common app flows [in the Block Kit Builder](https://api.slack.com/tools/block-kit-builder?template=1). + +
    + +```python +# Sends a section block with datepicker when someone reacts with a 📅 emoji +@app.event("reaction_added") +def show_datepicker(logger, payload, say): + reaction = payload["event"]["reaction"] + if reaction == "calendar": + blocks = [{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Pick a date for me to remind you" + }, + "accessory": { + "type": "datepicker", + "action_id": "datepicker_remind", + "initial_date": "2020-05-04", + "placeholder": { + "type": "plain_text", + "text": "Select a date" + } + } + }] + + channel_id = payload["event"]["item"]["channel"] + say(blocks=blocks, channel=channel_id) +``` + +
    diff --git a/docs/_basic/updating_pushing_modals.md b/docs/_basic/updating_pushing_modals.md new file mode 100644 index 000000000..1cbc5fe1e --- /dev/null +++ b/docs/_basic/updating_pushing_modals.md @@ -0,0 +1,56 @@ +--- +title: Updating and pushing views +lang: en +slug: updating-pushing-views +order: 11 +--- + +
    + +Modals contain a stack of views. When you call `views_open`, you add the root view to the modal. After the initial call, you can dynamically update a view by calling `views_update`, or stack a new view on top of the root view by calling `views_push`. + +views_update
    +To update a view, you can use the built-in client to call views_update with the view_id that was generated when you opened the view, and a new view including the updated blocks array. If you're updating the view when a user interacts with an element inside of an existing view, the view_id will be available in the body of the request. + +views_push
    +To push a new view onto the view stack, you can use the built-in client to call views_push with a valid trigger_id a new view payload. The arguments for `views_push` is the same as opening modals. After you open a modal, you may only push two additional views onto the view stack. + +Learn more about updating and pushing views in our API documentation. + +
    + +```python +# Listen for a button invocation with action_id `button_abc` (assume it's inside of a modal) +@app.action("button_abc") +def update_modal(ack, view, client): + # Acknowledge the button request + ack() + client.views_update( + # Pass the view_id + view_id=view["id"], + # View payload with updated blocks + view={ + "type": "modal", + # View identifier + "callback_id": "view_1", + "title": { + "type": "plain_text", + "text": "Updated modal" + }, + "blocks": [ + { + "type": "section", + "text": { + "type": "plain_text", + "text": "You updated the modal!" + } + }, + { + "type": "image", + "image_url": "https://media.giphy.com/media/SVZGEcYt7brkFUyU90/giphy.gif", + "alt_text": "Yay! The modal was updated" + } + ] + } + ) +``` diff --git a/docs/_basic/web_api.md b/docs/_basic/web_api.md new file mode 100644 index 000000000..bfb247ad7 --- /dev/null +++ b/docs/_basic/web_api.md @@ -0,0 +1,24 @@ +--- +title: Using the Web API +lang: en +slug: web-api +order: 4 +--- + +
    +You can call [any Web API method](https://api.slack.com/methods) using the [`WebClient`](https://slack.dev/node-slack-sdk/web-api) provided to your Bolt app as `app.client` (given that your app has the appropriate scopes). When you call one the client’s methods, it returns a `SlackResponse` which contains the response from Slack. + +The token used to initialize Bolt can be found in the `context` object, which is required for most Web API methods. + +
    + +```python +@app.message("wake me up") +def say_hello(client, payload): + # Unix Epoch time for September 30, 2020 11:59:59 PM + when_september_ends = 1601510399 + channel_id = payload['event']['channel'] + client.chat_scheduleMessage(channel=channel_id, + post_at=when_september_ends, + text="Summer has come and passed") +``` From 4958dabde57afa27edc8e4d87272ae70886ccc44 Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Mon, 14 Sep 2020 15:22:54 -0700 Subject: [PATCH 086/865] update docs > basics with kaz feedback --- docs/_basic/acknowledging_events.md | 4 ++- docs/_basic/authenticating_oauth.md | 34 +++++++++++-------- docs/_basic/listening_actions.md | 10 +++--- docs/_basic/listening_events.md | 7 ++-- docs/_basic/listening_messages.md | 6 ++-- docs/_basic/listening_modals.md | 2 +- docs/_basic/listening_responding_options.md | 2 +- docs/_basic/listening_responding_shortcuts.md | 18 +++++----- docs/_basic/opening_modals.md | 4 +-- docs/_basic/sending_messages.md | 9 +++-- docs/_basic/web_api.md | 6 ++-- 11 files changed, 54 insertions(+), 48 deletions(-) diff --git a/docs/_basic/acknowledging_events.md b/docs/_basic/acknowledging_events.md index 6eb3bf2bd..51258e1ca 100644 --- a/docs/_basic/acknowledging_events.md +++ b/docs/_basic/acknowledging_events.md @@ -16,6 +16,8 @@ We recommend calling `ack()` right away before sending a new message or fetching ```python +import re + # Listen for dialog submissions with a callback_id of ticket_submit @app.action("ticket_submit") def process_submission(ack, action): @@ -31,5 +33,5 @@ def process_submission(ack, action): "name": "email_address", "error": "Sorry, this isn’t a valid email" }] - ack({"errors": errors}) + ack(errors=errors) ``` diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md index 5df92b292..f4c506564 100644 --- a/docs/_basic/authenticating_oauth.md +++ b/docs/_basic/authenticating_oauth.md @@ -18,6 +18,10 @@ To learn more about the OAuth installation flow with Slack, [read the API docume ```python +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + oauth_settings = OAuthSettings( client_id=os.environ["SLACK_CLIENT_ID"], client_secret=os.environ["SLACK_CLIENT_SECRET"], @@ -38,29 +42,31 @@ app = App(signing_secret=os.environ["SIGNING_SECRET"],
    You can override the default OAuth using `oauth_settings`, which can be passed in during the initialization of App. You can override the following: -- `authorization_url`: Used to toggle between new Slack Apps and Classic Slack Apps - `install_path`: Override default path for "Add to Slack" button - `redirect_uri`: Override default redirect url path - `callback_options`: Provide custom success and failure pages at the end of the OAuth flow -- `state_store`: Provide a custom state store instead of using the built in `OAuthStateStore` +- `state_store`: Provide a custom state store instead of using the built in `FileOAuthStateStore` +- `installation_store`: Provide a custom installation store instead of the built-in `FileInstallationStore`
    ```python -oauth_settings = OAuthSettings( - client_id=os.environ["SLACK_CLIENT_ID"], - client_secret=os.environ["SLACK_CLIENT_SECRET"], - scopes=["channels:read", "groups:read", "chat:write", "incoming-webhook"], +app = App( + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), installation_store=FileInstallationStore(), - state_store=FileOAuthStateStore(expiration_seconds=120), - install_path="/slack/install_app", - redirect_uri_path="/slack/redirect", - callback_options=CallbackOptions(success=success_handler, - failure=failure_handler) + oauth_settings=OAuthSettings( + client_id=os.environ.get("SLACK_CLIENT_ID"), + client_secret=os.environ.get("SLACK_CLIENT_SECRET"), + scopes=["app_mentions:read", "channels:history", "im:history", "chat:write"], + user_scopes=[], + redirect_uri=None, + install_path="/slack/install", + redirect_uri_path="/slack/oauth_redirect", + state_store=FileOAuthStateStore(expiration_seconds=600), + callback_options=CallbackOptions(success=success, + failure=failure), + ), ) - -app = App(signing_secret=os.environ["SIGNING_SECRET"], - oauth_settings=oauth_settings) ``` diff --git a/docs/_basic/listening_actions.md b/docs/_basic/listening_actions.md index a1b545980..3db538099 100644 --- a/docs/_basic/listening_actions.md +++ b/docs/_basic/listening_actions.md @@ -8,7 +8,7 @@ order: 5
    Your app can listen to user actions like button clicks, and menu selects, using the `action` method. -Actions can be filtered on an `action_id` of type `str` or `RegExp` object. `action_id`s act as unique identifiers for interactive components on the Slack platform. +Actions can be filtered on an `action_id` of type `str` or `re.Pattern`. `action_id`s act as unique identifiers for interactive components on the Slack platform. You’ll notice in all `action()` examples, `ack()` is used. It is required to call the `ack()` function within an action listener to acknowledge that the event was received from Slack. This is discussed in the [acknowledging events section](#acknowledge). @@ -29,18 +29,18 @@ def update_message(ack):
    -You can use a constraints object to listen to `callback_id`s, `block_id`s, and `action_id`s (or any combination of them). Constraints in the object can be of type `str` or `RegExp` object. +You can use a constraints object to listen to `callback_id`s, `block_id`s, and `action_id`s (or any combination of them). Constraints in the object can be of type `str` or `re.Pattern`.
    ```python # Your function will only be called when the action_id matches 'select_user' AND the block_id matches 'assign_ticket' @app.action({"action_id": "select_user", "block_id": "assign_ticket"}) -def update_message(ack, action, client): +def update_message(ack, action, body, client): ack() client.reactions_add(name='white_check_mark', - timestamp=action['ts'], - channel=action['channel']) + timestamp=action['action_ts'], + channel=body['channel']['id']) ``` diff --git a/docs/_basic/listening_events.md b/docs/_basic/listening_events.md index 4cd8e8a59..d06040ecd 100644 --- a/docs/_basic/listening_events.md +++ b/docs/_basic/listening_events.md @@ -16,9 +16,9 @@ The `event()` method requires an `eventType` of type `str`. ```python # When a user joins the team, send a message in a predefined channel asking them to introduce themselves @app.event("team_join") -def ask_for_introduction(payload, say): +def ask_for_introduction(event, say): welcome_channel_id = "C12345"; - user_id = payload["event"]["user"] + user_id = event["user"]["id"] text = f"Welcome to the team, <@{user_id}>! 🎉 You can introduce yourself in this channel." say(text=text, channel=welcome_channel_id) ``` @@ -39,8 +39,7 @@ You can filter on subtypes of events by passing in the additional key `subtype`. ```python # Matches all messages from bot users @app.message({"subtype": "message_changed"}) -def log_message_change(logger, payload): - message = payload["event"] +def log_message_change(logger, message): logger.info(f"The user {message['user']} changed the message to {message['text']}") ``` diff --git a/docs/_basic/listening_messages.md b/docs/_basic/listening_messages.md index 93e462345..6d8f96177 100644 --- a/docs/_basic/listening_messages.md +++ b/docs/_basic/listening_messages.md @@ -16,8 +16,8 @@ To listen to messages that [your app has access to receive](https://api.slack.co ```python # This will match any message that contains 👋 @app.message(":wave:") -def say_hello(payload, say): - user = payload['event']['user'] +def say_hello(message, say): + user = message['user'] say(f"Hi there, <@{user}>!") ``` @@ -35,7 +35,7 @@ The `re.compile()` method can be used instead of a string for more granular matc ```python @app.message(re.compile("(hi|hello|hey)")) def say_hello_regex(say, context): - # RegExp matches are inside of context.matches + # RegEx matches are inside of context.matches greeting = context['matches'][0] say(f"{greeting}, how are you?") ``` diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md index 13856d7c9..8ef663021 100644 --- a/docs/_basic/listening_modals.md +++ b/docs/_basic/listening_modals.md @@ -7,7 +7,7 @@ order: 12
    -If a view payload contains any input blocks, you must listen to `view_submission` events to receive their values. To listen to `view_submission` events, you can use the built-in `view()` method. `view()` requires a `callback_id` of type `str` or `RegExp`. +If a view payload contains any input blocks, you must listen to `view_submission` events to receive their values. To listen to `view_submission` events, you can use the built-in `view()` method. `view()` requires a `callback_id` of type `str` or `re.Pattern`. You can access the value of the `input` blocks by accessing the `state` object. `state` contains a `values` object that uses the `block_id` and unique `action_id` to store the input values. diff --git a/docs/_basic/listening_responding_options.md b/docs/_basic/listening_responding_options.md index 444c90c41..ff07ed84e 100644 --- a/docs/_basic/listening_responding_options.md +++ b/docs/_basic/listening_responding_options.md @@ -30,5 +30,5 @@ def show_options(ack): }, ] - ack({"options": options}) + ack(options=options) ``` diff --git a/docs/_basic/listening_responding_shortcuts.md b/docs/_basic/listening_responding_shortcuts.md index 948750842..1d370b558 100644 --- a/docs/_basic/listening_responding_shortcuts.md +++ b/docs/_basic/listening_responding_shortcuts.md @@ -9,7 +9,7 @@ order: 8 The `shortcut()` method supports both [global shortcuts](https://api.slack.com/interactivity/shortcuts/using#global_shortcuts) and [message shortcuts](https://api.slack.com/interactivity/shortcuts/using#message_shortcuts). -Shortcuts are invokable entry points to apps. Global shortcuts are available from within search in Slack. Message shortcuts are available in the context menus of messages. Your app can use the `shortcut()` method to listen to incoming shortcut events. The method requires a `callback_id` parameter of type `str` or `RegExp`. +Shortcuts are invokable entry points to apps. Global shortcuts are available from within search in Slack. Message shortcuts are available in the context menus of messages. Your app can use the `shortcut()` method to listen to incoming shortcut events. The method requires a `callback_id` parameter of type `str` or `re.Pattern`. Shortcuts must be acknowledged with `ack()` to inform Slack that your app has received the event. @@ -25,12 +25,12 @@ When configuring shortcuts within your app configuration, you'll continue to app # The open_modal shortcut opens a plain old modal @app.shortcut("open_modal") -def open_modal(ack, client): +def open_modal(ack, shortcut, client): # Acknowledge the shortcut request ack() # Call the views_open method using one of the built-in WebClients client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=shortcut["trigger_id"], view={ "type": "modal", "title": { @@ -69,19 +69,19 @@ def open_modal(ack, client):
    - You can use a constraints object to listen to `callback_id`s, and `type`s. Constraints in the object can be of type `str` or `RegExp` object. + You can use a constraints object to listen to `callback_id`s, and `type`s. Constraints in the object can be of type `str` or `re.Pattern`.
    ```python # Your middleware will only be called when the callback_id matches 'open_modal' AND the type matches 'message_action' -@app.shortcut({"callback_id": "open_modal", "type": "message_action"}) -def open_modal(ack, client): - # acknowledge the shortcut request +@app.message_shortcut("open_modal") +def open_modal(ack, shortcut, client): + # Acknowledge the shortcut request ack() - # call the views_open method using one of the built-in WebClients + # Call the views_open method using one of the built-in WebClients client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=shortcut["trigger_id"], view={ "type": "modal", "title": { diff --git a/docs/_basic/opening_modals.md b/docs/_basic/opening_modals.md index cee2c90f5..74f4c7697 100644 --- a/docs/_basic/opening_modals.md +++ b/docs/_basic/opening_modals.md @@ -17,14 +17,14 @@ Read more about modal composition in the diff --git a/docs/_basic/web_api.md b/docs/_basic/web_api.md index bfb247ad7..92c17b648 100644 --- a/docs/_basic/web_api.md +++ b/docs/_basic/web_api.md @@ -6,7 +6,7 @@ order: 4 ---
    -You can call [any Web API method](https://api.slack.com/methods) using the [`WebClient`](https://slack.dev/node-slack-sdk/web-api) provided to your Bolt app as `app.client` (given that your app has the appropriate scopes). When you call one the client’s methods, it returns a `SlackResponse` which contains the response from Slack. +You can call [any Web API method](https://api.slack.com/methods) using the [`WebClient`](https://slack.dev/python-slack-sdk/basic_usage.html) provided to your Bolt app as `app.client` (given that your app has the appropriate scopes). When you call one the client’s methods, it returns a `SlackResponse` which contains the response from Slack. The token used to initialize Bolt can be found in the `context` object, which is required for most Web API methods. @@ -14,10 +14,10 @@ The token used to initialize Bolt can be found in the `context` object, which is ```python @app.message("wake me up") -def say_hello(client, payload): +def say_hello(client, message): # Unix Epoch time for September 30, 2020 11:59:59 PM when_september_ends = 1601510399 - channel_id = payload['event']['channel'] + channel_id = message['channel'] client.chat_scheduleMessage(channel=channel_id, post_at=when_september_ends, text="Summer has come and passed") From 2519e4e880a7ea16879f5f694df769bbf339a194 Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Tue, 15 Sep 2020 17:02:14 -0700 Subject: [PATCH 087/865] update docs > basics with shay feedback --- docs/_basic/authenticating_oauth.md | 2 +- docs/_basic/listening_actions.md | 2 +- docs/_basic/listening_events.md | 2 +- docs/_basic/listening_modals.md | 47 ++++++++++--------- docs/_basic/listening_responding_commands.md | 2 +- docs/_basic/listening_responding_options.md | 9 ++-- docs/_basic/listening_responding_shortcuts.md | 4 +- docs/_basic/opening_modals.md | 13 ++--- docs/_basic/responding_actions.md | 2 +- docs/_basic/updating_pushing_modals.md | 10 ++-- docs/_basic/web_api.md | 4 +- 11 files changed, 50 insertions(+), 47 deletions(-) diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md index f4c506564..77ee5ec65 100644 --- a/docs/_basic/authenticating_oauth.md +++ b/docs/_basic/authenticating_oauth.md @@ -7,7 +7,7 @@ order: 14
    -Slack apps installed on multiple workspaces will need to implement OAuth, then store installation information (like access tokens) securely. By providing `client_id`, `client_secret`, `scopes`, `installation_store`, and `state_store` when initializing App, Bolt for Python will handle the work of setting up OAuth routes and verifying state. If you’re implementing a custom receiver, you can make use of our [OAuth library](https://slack.dev/python-slack-sdk/oauth), which is what Bolt for Python uses under the hood. +Slack apps installed on multiple workspaces will need to implement OAuth, then store installation information (like access tokens) securely. By providing `client_id`, `client_secret`, `scopes`, `installation_store`, and `state_store` when initializing App, Bolt for Python will handle the work of setting up OAuth routes and verifying state. If you’re implementing a custom receiver, you can make use of our [OAuth library](https://slack.dev/python-slack-sdk/oauth/), which is what Bolt for Python uses under the hood. Bolt for Python will create a **Redirect URL** `slack/oauth_redirect`, which Slack uses to redirect users after they complete your app’s installation flow. You will need to add this **Redirect URL** in your app configuration settings under **OAuth and Permissions**. This path can be configured in the `OAuthSettings` argument described below. diff --git a/docs/_basic/listening_actions.md b/docs/_basic/listening_actions.md index 3db538099..dcf7bd147 100644 --- a/docs/_basic/listening_actions.md +++ b/docs/_basic/listening_actions.md @@ -6,7 +6,7 @@ order: 5 ---
    -Your app can listen to user actions like button clicks, and menu selects, using the `action` method. +Your app can listen to user actions, like button clicks, and menu selects, using the `action` method. Actions can be filtered on an `action_id` of type `str` or `re.Pattern`. `action_id`s act as unique identifiers for interactive components on the Slack platform. diff --git a/docs/_basic/listening_events.md b/docs/_basic/listening_events.md index d06040ecd..5dc164a00 100644 --- a/docs/_basic/listening_events.md +++ b/docs/_basic/listening_events.md @@ -7,7 +7,7 @@ order: 3
    -You can listen to [any Events API event](https://api.slack.com/events) using the `event()` method after subscribing to it in your app configuration. This allows your app to take action when something happens in Slack, like a user reacting to a message or joining a channel. +You can listen to [any Events API event](https://api.slack.com/events) using the `event()` method after subscribing to it in your app configuration. This allows your app to take action when something happens in a workspace where it's installed, like a user reacting to a message or joining a channel. The `event()` method requires an `eventType` of type `str`. diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md index 8ef663021..60fb37499 100644 --- a/docs/_basic/listening_modals.md +++ b/docs/_basic/listening_modals.md @@ -17,27 +17,28 @@ Read more about view submissions in our diff --git a/docs/_basic/listening_responding_options.md b/docs/_basic/listening_responding_options.md index ff07ed84e..28d8ee3ca 100644 --- a/docs/_basic/listening_responding_options.md +++ b/docs/_basic/listening_responding_options.md @@ -7,12 +7,12 @@ order: 13
    The `options()` method listens for incoming option request payloads from Slack. [Similar to `action()`](#action-listening), -an `action_id` or constraints object is required. In order to load external data into your select menus, you must provide an options load URL in your app configuration. +an `action_id` or constraints object is required. In order to load external data into your select menus, you must provide an options load URL in your app configuration, appended with `/slack/events`. -While it's recommended to use `action_id` for `external_select` menus, dialogs do not yet support Block Kit so you'll have to -use the constraints object to filter on a `callback_id`. +While it's recommended to use `action_id` for `external_select` menus, dialogs do not support Block Kit so you'll have to use the constraints object to filter on a `callback_id`. + +To respond to options requests, you'll need to call `ack()` with a valid `options` or `option_groups` list. Both [external select response examples](https://api.slack.com/reference/messaging/block-elements#external-select) and [dialog response examples](https://api.slack.com/dialogs#dynamic_select_elements_external) can be found on our API site. -To respond to options requests, you'll need to `ack()` with valid options. Both [external select response examples](https://api.slack.com/reference/messaging/block-elements#external-select) and [dialog response examples](https://api.slack.com/dialogs#dynamic_select_elements_external) can be found on our API site.
    ```python @@ -29,6 +29,5 @@ def show_options(ack): "value": "1-2", }, ] - ack(options=options) ``` diff --git a/docs/_basic/listening_responding_shortcuts.md b/docs/_basic/listening_responding_shortcuts.md index 1d370b558..329fee67b 100644 --- a/docs/_basic/listening_responding_shortcuts.md +++ b/docs/_basic/listening_responding_shortcuts.md @@ -15,9 +15,9 @@ Shortcuts must be acknowledged with `ack()` to inform Slack that your app has re Shortcuts include a `trigger_id` which an app can use to [open a modal](#creating-modals) that confirms the action the user is taking. -When configuring shortcuts within your app configuration, you'll continue to append `/slack/events` to your request URL. +When setting up shortcuts within your app configuration, as with other URLs, you'll append `/slack/events` to your request URL. -⚠️ Note that global shortcuts do **not** include a channel ID. If your app needs access to a channel ID, you may use a [`conversations_select`](https://api.slack.com/reference/block-kit/block-elements#conversation_select) element within a modal. Message shortcuts do include channel ID. +⚠️ Note that global shortcuts do **not** include a channel ID. If your app needs access to a channel ID, you may use a [`conversations_select`](https://api.slack.com/reference/block-kit/block-elements#conversation_select) element within a modal. Message shortcuts do include a channel ID.
    diff --git a/docs/_basic/opening_modals.md b/docs/_basic/opening_modals.md index 74f4c7697..aef905b4d 100644 --- a/docs/_basic/opening_modals.md +++ b/docs/_basic/opening_modals.md @@ -6,23 +6,24 @@ order: 10 ---
    -Modals are focused surfaces that allow you to collect user data and display dynamic information. You can open a modal by passing a valid trigger_id and a view payload to the built-in client's views.open method. -Your app receives trigger_ids in payloads sent to your Request URL triggered user invocation like a slash command, button press, or interaction with a select menu. +Modals are focused surfaces that allow you to collect user data and display dynamic information. You can open a modal by passing a valid `trigger_id` and a view payload to the built-in client's `views.open` method. + +Your app receives `trigger_id`s in payloads sent to your Request URL that are triggered by user invocations, like a shortcut, button press, or interaction with a select menu. Read more about modal composition in the API documentation.
    ```python -# Listen for a slash command invocation -@app.command("/ticket") +# Listen for a shortcut invocation +@app.shortcut("open_modal") def open_modal(ack, body, client): # Acknowledge the command request ack(); # Call views_open with the built-in client - client.views_open({ + client.views_open( # Pass a valid trigger_id within 3 seconds of receiving it trigger_id=body["trigger_id"], # View payload @@ -69,5 +70,5 @@ def open_modal(ack, body, client): "text": "Submit" } } - }) + ) ``` diff --git a/docs/_basic/responding_actions.md b/docs/_basic/responding_actions.md index dea3a9c59..1a7e2ca59 100644 --- a/docs/_basic/responding_actions.md +++ b/docs/_basic/responding_actions.md @@ -9,7 +9,7 @@ order: 6 There are two main ways to respond to actions. The first (and most common) way is to use the `say` function. The `say` function sends a message back to the conversation where the incoming event took place. -The second way to respond to actions is using `respond()`, which is a simple utility to use the `response_url` associated with an action. +The second way to respond to actions is using `respond()`, which is a utility to use the `response_url` associated with the action.
    diff --git a/docs/_basic/updating_pushing_modals.md b/docs/_basic/updating_pushing_modals.md index 1cbc5fe1e..56ae4748b 100644 --- a/docs/_basic/updating_pushing_modals.md +++ b/docs/_basic/updating_pushing_modals.md @@ -9,11 +9,11 @@ order: 11 Modals contain a stack of views. When you call `views_open`, you add the root view to the modal. After the initial call, you can dynamically update a view by calling `views_update`, or stack a new view on top of the root view by calling `views_push`. -views_update
    -To update a view, you can use the built-in client to call views_update with the view_id that was generated when you opened the view, and a new view including the updated blocks array. If you're updating the view when a user interacts with an element inside of an existing view, the view_id will be available in the body of the request. +**`views_update`**
    +To update a view, you can use the built-in client to call `views_update` with the `view_id` that was generated when you opened the view, and a new `view` including the updated `blocks` list. If you're updating the view when a user interacts with an element inside of an existing view, the `view_id` will be available in the `body` of the request. -views_push
    -To push a new view onto the view stack, you can use the built-in client to call views_push with a valid trigger_id a new view payload. The arguments for `views_push` is the same as opening modals. After you open a modal, you may only push two additional views onto the view stack. +**`views_push`**
    +To push a new view onto the view stack, you can use the built-in client to call `views_push` with a valid `trigger_id` a new view payload. The arguments for `views_push` is the same as opening modals. After you open a modal, you may only push two additional views onto the view stack. Learn more about updating and pushing views in our API documentation. @@ -28,6 +28,8 @@ def update_modal(ack, view, client): client.views_update( # Pass the view_id view_id=view["id"], + # String that represents view state to protect against race conditions + hash=view["hash"], # View payload with updated blocks view={ "type": "modal", diff --git a/docs/_basic/web_api.md b/docs/_basic/web_api.md index 92c17b648..fb4385928 100644 --- a/docs/_basic/web_api.md +++ b/docs/_basic/web_api.md @@ -8,7 +8,7 @@ order: 4
    You can call [any Web API method](https://api.slack.com/methods) using the [`WebClient`](https://slack.dev/python-slack-sdk/basic_usage.html) provided to your Bolt app as `app.client` (given that your app has the appropriate scopes). When you call one the client’s methods, it returns a `SlackResponse` which contains the response from Slack. -The token used to initialize Bolt can be found in the `context` object, which is required for most Web API methods. +The token used to initialize Bolt can be found in the `context` object, which is required to call most Web API methods.
    @@ -17,7 +17,7 @@ The token used to initialize Bolt can be found in the `context` object, which is def say_hello(client, message): # Unix Epoch time for September 30, 2020 11:59:59 PM when_september_ends = 1601510399 - channel_id = message['channel'] + channel_id = message["channel"] client.chat_scheduleMessage(channel=channel_id, post_at=when_september_ends, text="Summer has come and passed") From 79d80b0b772129cdb136c2d7784c97bbf7df2737 Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Wed, 16 Sep 2020 10:31:45 -0700 Subject: [PATCH 088/865] minor text change + remove dialog submission ref/example --- docs/_basic/acknowledging_events.md | 34 +++++++++++++---------------- docs/_basic/responding_actions.md | 2 +- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/docs/_basic/acknowledging_events.md b/docs/_basic/acknowledging_events.md index 51258e1ca..b341ca673 100644 --- a/docs/_basic/acknowledging_events.md +++ b/docs/_basic/acknowledging_events.md @@ -9,29 +9,25 @@ order: 7 Actions, commands, and options events must **always** be acknowledged using the `ack()` function. This lets Slack know that the event was received and updates the Slack user interface accordingly. -Depending on the type of event, your acknowledgement may be different. For example, when acknowledging a dialog submission you will call `ack()` with validation errors if the submission contains errors, or with no parameters if the submission is valid. +Depending on the type of event, your acknowledgement may be different. For example, when acknowledging a menu selection associated with an external data source, you would call `ack()` with a list of relevant [options](https://api.slack.com/reference/block-kit/composition-objects#option). We recommend calling `ack()` right away before sending a new message or fetching information from your database since you only have 3 seconds to respond.
    ```python -import re - -# Listen for dialog submissions with a callback_id of ticket_submit -@app.action("ticket_submit") -def process_submission(ack, action): - # Regex to determine if this is a valid email - is_email = "^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$" - - if re.match(is_email, action["submission"]["email"]): - # It’s a valid email, accept the submission - ack() - else: - # If it isn’t a valid email, acknowledge with an error - errors = [{ - "name": "email_address", - "error": "Sorry, this isn’t a valid email" - }] - ack(errors=errors) +# Example of responding to an external_select options request +@app.options("menu_selection") +def show_menu_options(ack): + options = [ + { + "text": {"type": "plain_text", "text": "Option 1"}, + "value": "1-1", + }, + { + "text": {"type": "plain_text", "text": "Option 2"}, + "value": "1-2", + }, + ] + ack(options=options) ``` diff --git a/docs/_basic/responding_actions.md b/docs/_basic/responding_actions.md index 1a7e2ca59..dcd83eabf 100644 --- a/docs/_basic/responding_actions.md +++ b/docs/_basic/responding_actions.md @@ -7,7 +7,7 @@ order: 6
    -There are two main ways to respond to actions. The first (and most common) way is to use the `say` function. The `say` function sends a message back to the conversation where the incoming event took place. +There are two main ways to respond to actions. The first (and most common) way is to use `say()`, which sends a message back to the conversation where the incoming event took place. The second way to respond to actions is using `respond()`, which is a utility to use the `response_url` associated with the action. From 007d278ed20cfed642de6c1ddc54a68350867311 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 19 Sep 2020 09:17:26 +0900 Subject: [PATCH 089/865] Add a few adjustments --- docs/_basic/authenticating_oauth.md | 38 ++++++++++--- docs/_basic/listening_events.md | 6 +- docs/_basic/listening_messages.md | 6 +- docs/_basic/listening_modals.md | 57 +++++++++++-------- docs/_basic/listening_responding_shortcuts.md | 2 +- docs/_basic/sending_messages.md | 14 +++-- docs/_basic/web_api.md | 8 ++- docs/assets/style.css | 2 +- 8 files changed, 84 insertions(+), 49 deletions(-) diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md index 77ee5ec65..36d4baf5a 100644 --- a/docs/_basic/authenticating_oauth.md +++ b/docs/_basic/authenticating_oauth.md @@ -18,6 +18,7 @@ To learn more about the OAuth installation flow with Slack, [read the API docume
    ```python +import os from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore @@ -26,12 +27,14 @@ oauth_settings = OAuthSettings( client_id=os.environ["SLACK_CLIENT_ID"], client_secret=os.environ["SLACK_CLIENT_SECRET"], scopes=["channels:read", "groups:read", "chat:write"], - installation_store=FileInstallationStore(), - state_store=FileOAuthStateStore(expiration_seconds=120) + installation_store=FileInstallationStore(base_dir="./data"), + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data") ) -app = App(signing_secret=os.environ["SIGNING_SECRET"], - oauth_settings=oauth_settings) +app = App( + signing_secret=os.environ["SIGNING_SECRET"], + oauth_settings=oauth_settings +) ```
    @@ -51,9 +54,29 @@ You can override the default OAuth using `oauth_settings`, which can be passed i
    ```python +from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs +import slack_bolt.response.BoltResponse + +def success(args: SuccessArgs) -> BoltResponse: + assert args.request is not None + return BoltResponse( + status=200, # you can redirect users too + body="Your own response to end-users here" + ) + +def failure(args: FailureArgs) -> BoltResponse: + assert args.request is not None + assert args.reason is not None + return BoltResponse( + status=args.suggested_status_code, + body="Your own response to end-users here" + ) + +callback_options = CallbackOptions(success=success, failure=failure) + app = App( signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), - installation_store=FileInstallationStore(), + installation_store=FileInstallationStore(base_dir="./data"), oauth_settings=OAuthSettings( client_id=os.environ.get("SLACK_CLIENT_ID"), client_secret=os.environ.get("SLACK_CLIENT_SECRET"), @@ -62,9 +85,8 @@ app = App( redirect_uri=None, install_path="/slack/install", redirect_uri_path="/slack/oauth_redirect", - state_store=FileOAuthStateStore(expiration_seconds=600), - callback_options=CallbackOptions(success=success, - failure=failure), + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data"), + callback_options=callback_options, ), ) ``` diff --git a/docs/_basic/listening_events.md b/docs/_basic/listening_events.md index 5dc164a00..5e8059f2f 100644 --- a/docs/_basic/listening_events.md +++ b/docs/_basic/listening_events.md @@ -38,9 +38,9 @@ You can filter on subtypes of events by passing in the additional key `subtype`. ```python # Matches all messages from bot users -@app.message({"subtype": "message_changed"}) -def log_message_change(logger, message): - logger.info(f"The user {message['user']} changed the message to {message['text']}") +@app.event({"type": "message", "subtype": "message_changed"}) +def log_message_change(logger, event): + logger.info(f"The user {event['user']} changed the message to {event['text']}") ``` diff --git a/docs/_basic/listening_messages.md b/docs/_basic/listening_messages.md index 6d8f96177..22b777ab3 100644 --- a/docs/_basic/listening_messages.md +++ b/docs/_basic/listening_messages.md @@ -9,7 +9,7 @@ order: 1 To listen to messages that [your app has access to receive](https://api.slack.com/messaging/retrieving#permissions), you can use the `message()` method which filters out events that aren’t of type `message`. -`message()` accepts an argument of type `str` or `RegEx` object that filters out any messages that don’t match the pattern. +`message()` accepts an argument of type `str` or `re.Pattern` object that filters out any messages that don’t match the pattern.
    @@ -23,7 +23,7 @@ def say_hello(message, say):
    -

    Using a RegEx pattern

    +

    Using a regular expression pattern

    @@ -35,7 +35,7 @@ The `re.compile()` method can be used instead of a string for more granular matc ```python @app.message(re.compile("(hi|hello|hey)")) def say_hello_regex(say, context): - # RegEx matches are inside of context.matches + # regular expression matches are inside of context.matches greeting = context['matches'][0] say(f"{greeting}, how are you?") ``` diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md index 60fb37499..76a40a684 100644 --- a/docs/_basic/listening_modals.md +++ b/docs/_basic/listening_modals.md @@ -17,28 +17,37 @@ Read more about view submissions in our 0: + ack(response_action="errors", errors=errors) + return + + # Acknowledge the view_submission event and close the modal + ack() + + # Do whatever you want with the input data - here we're saving it to a DB + # then sending the user a verification of their submission + + # Message to send user + msg = "" + + try: + # Save to DB + msg = f"Your submission of {val} was successful" + except Exception as e: + # Handle error + msg = "There was an error with your submission" + finally: + # Message the user + client.chat_postMessage(channel=user, text=msg) ``` diff --git a/docs/_basic/listening_responding_shortcuts.md b/docs/_basic/listening_responding_shortcuts.md index 329fee67b..7096ad0a8 100644 --- a/docs/_basic/listening_responding_shortcuts.md +++ b/docs/_basic/listening_responding_shortcuts.md @@ -75,7 +75,7 @@ def open_modal(ack, shortcut, client): ```python # Your middleware will only be called when the callback_id matches 'open_modal' AND the type matches 'message_action' -@app.message_shortcut("open_modal") +@app.shortcut({"callback_id": "open_modal", "type": "message_action"}) def open_modal(ack, shortcut, client): # Acknowledge the shortcut request ack() diff --git a/docs/_basic/sending_messages.md b/docs/_basic/sending_messages.md index 5ccb44740..be2291ab4 100644 --- a/docs/_basic/sending_messages.md +++ b/docs/_basic/sending_messages.md @@ -36,9 +36,9 @@ To explore adding rich message layouts to your app, read through [the guide on o # Sends a section block with datepicker when someone reacts with a 📅 emoji @app.event("reaction_added") def show_datepicker(event, say): - reaction = event["reaction"] - if reaction == "calendar": - blocks = [{ + reaction = event["reaction"] + if reaction == "calendar": + blocks = [{ "type": "section", "text": { "type": "mrkdwn", @@ -53,9 +53,11 @@ def show_datepicker(event, say): "text": "Select a date" } } - }] - - say(blocks=blocks) + }] + say( + blocks=blocks, + text="Pick a date for me to remind you" + ) ```
    diff --git a/docs/_basic/web_api.md b/docs/_basic/web_api.md index fb4385928..e29812106 100644 --- a/docs/_basic/web_api.md +++ b/docs/_basic/web_api.md @@ -18,7 +18,9 @@ def say_hello(client, message): # Unix Epoch time for September 30, 2020 11:59:59 PM when_september_ends = 1601510399 channel_id = message["channel"] - client.chat_scheduleMessage(channel=channel_id, - post_at=when_september_ends, - text="Summer has come and passed") + client.chat_scheduleMessage( + channel=channel_id, + post_at=when_september_ends, + text="Summer has come and passed" + ) ``` diff --git a/docs/assets/style.css b/docs/assets/style.css index 07390ce24..f40ec15f6 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -241,7 +241,7 @@ span.beta { pre { background-color: var(--light-grey) !important; background-image: none; - padding: 2em; + padding: 1.5em; border: 1px solid #DDDDDD; } From f18f7734b1015b2151a09f5daac35f91a9b3f988 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 19 Sep 2020 09:40:27 +0900 Subject: [PATCH 090/865] Update the project settings and README --- .deepsource.toml | 24 ----------------------- .github/ISSUE_TEMPLATE/02_enhancement.md | 1 + .github/maintainers_guide.md | 16 ++++++++++++++- .github/pull_request_template.md | 4 +++- README.md | 25 ++++++++++++------------ 5 files changed, 32 insertions(+), 38 deletions(-) delete mode 100644 .deepsource.toml diff --git a/.deepsource.toml b/.deepsource.toml deleted file mode 100644 index 6740e2f9a..000000000 --- a/.deepsource.toml +++ /dev/null @@ -1,24 +0,0 @@ -# https://deepsource.io/docs/config/deepsource-toml.html -# https://deepsource.io/docs/analyzer/python.html - -version = 1 - -test_patterns = [ - "tests/*.py", - "tests/**/*.py" -] - -exclude_patterns = [ - "setup.py", - "samples/**", - "tests/**", -] - -[[analyzers]] -name = "python" -enabled = true - - [analyzers.meta] - runtime_version = "3.x.x" - max_line_length = 125 - diff --git a/.github/ISSUE_TEMPLATE/02_enhancement.md b/.github/ISSUE_TEMPLATE/02_enhancement.md index 658f22f11..0556ff91f 100644 --- a/.github/ISSUE_TEMPLATE/02_enhancement.md +++ b/.github/ISSUE_TEMPLATE/02_enhancement.md @@ -13,6 +13,7 @@ assignees: '' * [ ] **slack_bolt.App** and/or its core components * [ ] **slack_bolt.async_app.AsyncApp** and/or its core components * [ ] Adapters in **slack_bolt.adapter** +* [ ] Others ## Requirements diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index 367771f4d..e1e00eb40 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -62,12 +62,26 @@ use-feature = 2020-resolver #### Run All the Unit Tests -If you make some changes to this SDK, please write corresponding unit tests as much as possible. You can easily run all the tests by running the following script +If you make some changes to this SDK, please write corresponding unit tests as much as possible. You can easily run all the tests by running the following script. + +If this is your first time to run tests, although it may take a bit long time, running the following script is the easiest. + +```bash +$ ./scripts/install_all_and_run_tests.sh +``` + +Once you installed all the required dependencies, you can use the following one. ```bash $ ./scripts/run_tests.sh ``` +Also, you can run a single test this way. + +```bash +$ ./scripts/run_tests.sh tests/scenario_tests/test_app.py +``` + #### Run the Samples If you make changes to `slack_bolt/adapter/*`, please verify if it surely works by running the apps under `samples` directory. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6b8e8c8c7..6ae896f2b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,6 +5,8 @@ * [ ] `slack_bolt.App` and/or its core components * [ ] `slack_bolt.async_app.AsyncApp` and/or its core components * [ ] Adapters in `slack_bolt.adapter` +* [ ] Document pages under `/docs` +* [ ] Others ## Requirements (place an `x` in each `[ ]`) @@ -12,4 +14,4 @@ Please read the [Contributing guidelines](https://github.com/slackapi/bolt-pytho * [ ] I've read and understood the [Contributing Guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and have done my best effort to follow them. * [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct). -* [ ] I've run `./scripts/run_tests.sh` after making the changes. +* [ ] I've run `./scripts/install_all_and_run_tests.sh` after making the changes. diff --git a/README.md b/README.md index f32d1174d..f53bdb6c7 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,20 @@ -# ⚠️ Important Notice ⚠️ - -## 🔄 Still Work In Progress 🔄 - -This project is **still in alpha**, and may have bugs in it. Also, the public APIs can be changed until the v1 release. We are keen to hear your feedback. Please feel free to [submit an issue](https://github.com/slackapi/bolt-python/issues)! - -# Bolt for Python (still in alpha) +# Bolt for Python (still in beta) [![Python Version][python-version]][pypi-url] [![pypi package][pypi-image]][pypi-url] [![Build Status][travis-image]][travis-url] [![Codecov][codecov-image]][codecov-url] -A Python framework to build Slack apps in a flash with the latest platform features. Check the [samples](https://github.com/slackapi/bolt-python/tree/main/samples) to know how to use this framework. +A Python framework to build Slack apps in a flash with the latest platform features. Check the [document](https://slack.dev/bolt-python/) and [samples](https://github.com/slackapi/bolt-python/tree/main/samples) to know how to use this framework. ## Setup ```bash -python -m venv env -source env/bin/activate +# Python 3.6+ required +python -m venv .venv +source .venv/bin/activate + +pip install -U pip pip install slack_bolt ``` @@ -103,8 +100,12 @@ ngrok http 3000 If you prefer building Slack apps using [asyncio](https://docs.python.org/3/library/asyncio.html), you can go with `AsyncApp` instead. You can use async/await style for everything in the app. To use `AsyncApp`, [AIOHTTP](https://docs.aiohttp.org/en/stable/) library is required for asynchronous Slack Web API calls and the default web server. ```bash -python -m venv env -source env/bin/activate +# Python 3.6+ required +python -m venv .venv +source .venv/bin/activate + +pip install -U pip +# aiohttp is required pip install slack_bolt aiohttp ``` From 18d73f57c27f2328235f5013fff65fec446fc81c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 19 Sep 2020 12:27:11 +0900 Subject: [PATCH 091/865] Fix #79 by advanced topics and some adjustments --- docs/_advanced/adapters.md | 63 +++++++++++ docs/_advanced/async.md | 89 +++++++++++++++ docs/_advanced/example.md | 14 --- docs/_advanced/lazy_listener_functions.md | 102 ++++++++++++++++++ docs/_basic/listening_modals.md | 17 ++- docs/_basic/listening_responding_shortcuts.md | 10 +- docs/_basic/opening_modals.md | 27 ++--- docs/_basic/updating_pushing_modals.md | 10 +- docs/_tutorials/getting_started.md | 20 +--- docs/assets/style.css | 6 +- 10 files changed, 277 insertions(+), 81 deletions(-) create mode 100644 docs/_advanced/adapters.md create mode 100644 docs/_advanced/async.md delete mode 100644 docs/_advanced/example.md create mode 100644 docs/_advanced/lazy_listener_functions.md diff --git a/docs/_advanced/adapters.md b/docs/_advanced/adapters.md new file mode 100644 index 000000000..4612322f3 --- /dev/null +++ b/docs/_advanced/adapters.md @@ -0,0 +1,63 @@ +--- +title: Using in Web frameworks +lang: en +slug: adapters +order: 0 +--- + +
    +`App#start()` starts a Web server process using the [`http.server` standard library](https://docs.python.org/3/library/http.server.html). As mentioned in its document, it is not recommended to use the module for production. + +Once you're done with local development, it's about time to choose the right Web framework and production-ready server. + +Let's try using [Flask](https://flask.palletsprojects.com/) framework along with the [Gunicorn](https://gunicorn.org/) WSGI HTTP server here. + +```bash +pip install slack_bolt flask gunicorn +export SLACK_SIGNING_SECRET=*** +export SLACK_BOT_TOKEN=xoxb-*** +# Save the source code as main.py +gunicorn --bind :3000 --workers 1 --threads 2 --timeout 0 main:flask_app +``` + +We currently support the following frameworks. As long as a Web framework works, you can run Bolt app in any web servers. + +* [Django](https://www.djangoproject.com/) +* [Flask](https://flask.palletsprojects.com/) +* [Starlette](https://www.starlette.io/) & [FastAPI](https://fastapi.tiangolo.com/) +* [Sanic](https://sanicframework.org/) +* [Tornado](https://www.tornadoweb.org/) +* [Falcon](https://falcon.readthedocs.io/) +* [Bottle](https://bottlepy.org/) +* [CherryPy](https://cherrypy.org/) +* [Pyramid](https://trypyramid.com/) + +Check [samples](https://github.com/slackapi/bolt-python/tree/main/samples) in the GitHub repository to learn how to configure your app with frameworks. + +
    + +```python +from slack_bolt import App +app = App() + +# There is nothing specific to Flask here! +# App is completely framework/runtime agnostic +@app.command("/hello-bolt") +def hello(body, ack): + ack(f"Hi <@{body['user_id']}>!") + +# Initialize Flask app +from flask import Flask, request +flask_app = Flask(__name__) + +# SlackRequestHandler translates WSGI requests to Bolt's interface +# and builds WSGI response from Bolt's response. +from slack_bolt.adapter.flask import SlackRequestHandler +handler = SlackRequestHandler(app) + +# Register routes to Flask app +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + # handler runs App's dispatch method + return handler.handle(request) +``` diff --git a/docs/_advanced/async.md b/docs/_advanced/async.md new file mode 100644 index 000000000..104db22a9 --- /dev/null +++ b/docs/_advanced/async.md @@ -0,0 +1,89 @@ +--- +title: Async Bolt +lang: en +slug: advanced-async +order: 1 +--- + +
    +In the Basic concepts section, all the code snippets are not in the asynchronous programming style. You may be wondering if Bolt for Python is not available for asynchronous frameworks and their runtime such as the standard `asyncio` library. + +No worries! You can use Bolt with [Starlette](https://www.starlette.io/), [FastAPI](https://fastapi.tiangolo.com/), [Sanic](https://sanicframework.org/), [AIOHTTP](https://docs.aiohttp.org/), and whatever you want to use. + +`AsyncApp` internally relies on AIOHTTP for making HTTP requests to Slack API servers. So, to use the async version of Bolt, add `aiohttp` to `requirements.txt` or run `pip install aiohttp`. + +You can find sample projects in [samples](https://github.com/slackapi/bolt-python/tree/main/samples) directory in the GitHub repository. + +
    + +```python +# required: pip install aiohttp +from slack_bolt.async_app import AsyncApp +app = AsyncApp() + +@app.event("app_mention") +async def handle_mentions(event, client, say): # async function + api_response = await client.reactions_add( + channel=event["channel"], + timestamp=event["ts"], + name="eyes", + ) + await say("What's up?") + +if __name__ == "__main__": + app.start(3000) +``` + +
    + +

    Using other async frameworks

    +
    + +
    + +`AsyncApp#start()` internally uses [AIOHTTP](https://docs.aiohttp.org/)'s web server feature. However, this doesn't mean you have to use AIOHTTP. `AsyncApp` can handle incoming requests from Slack using any other frameworks. + +The code snippet in this section is an example using [Sanic](https://sanicframework.org/). You can start your Slack app server-side built with Sanic only by running the following commands. + +```bash +pip install slack_bolt sanic uvicorn +export SLACK_SIGNING_SECRET=*** +export SLACK_BOT_TOKEN=xoxb-*** +# save the source as async_app.py +uvicorn async_app:api --reload --port 3000 --log-level debug +``` + +If you would like to use other frameworks, check the list of the built-in adapters [here](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter). If your favorite framework is available there, you can use Bolt with it in a similar way. + +
    + +```python +from slack_bolt.async_app import AsyncApp +app = AsyncApp() + +# There is nothing specific to Sanic here! +# AsyncApp is completely framework/runtime agnostic +@app.event("app_mention") +async def handle_app_mentions(say): + await say("What's up?") + +import os +from sanic import Sanic +from sanic.request import Request +from slack_bolt.adapter.sanic import AsyncSlackRequestHandler + +# Create an adapter for Sanic with the App instance +app_handler = AsyncSlackRequestHandler(app) +# Create a Sanic app +api = Sanic(name="awesome-slack-app") + +@api.post("/slack/events") +async def endpoint(req: Request): + # app_handler internally runs the App's dispatch method + return await app_handler.handle(req) + +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) +``` + +
    diff --git a/docs/_advanced/example.md b/docs/_advanced/example.md deleted file mode 100644 index 889ffb77c..000000000 --- a/docs/_advanced/example.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Example -lang: en -slug: advanced-ex -order: 0 ---- - -
    -An advanced section example. -
    - -```python -# We love Python <3 -``` diff --git a/docs/_advanced/lazy_listener_functions.md b/docs/_advanced/lazy_listener_functions.md new file mode 100644 index 000000000..cf47fee18 --- /dev/null +++ b/docs/_advanced/lazy_listener_functions.md @@ -0,0 +1,102 @@ +--- +title: Lazy listener functions (beta) +lang: en +slug: lazy-listener-functions +order: 1 +--- + +
    +Lazy listener function is a **beta** feature that is available only in Bolt for Python. Your application can easily start asynchronous executions with Slack payloads. This feature is particularly useful for FaaS (Function as a Service) users. + +To learn the reason why this feature matters for FaaS users, click **"Why is this feature useful for FaaS users?"**. + +To deploy the code on the right side to [AWS Lambda](https://aws.amazon.com/lambda/) environment, follow the instructions below. + +```bash +pip install slack_bolt +# Save the source code as main.py +# and refer handler as `handler: main.handler` in config.yaml + +pip install python-lambda +# https://pypi.org/project/python-lambda/ +# Configure config.yml properly (AWSLambdaFullAccess required) + +export SLACK_SIGNING_SECRET=*** +export SLACK_BOT_TOKEN=xoxb-*** +echo 'slack_bolt' > requirements.txt +lambda deploy --config-file config.yaml --requirements requirements.txt +``` +
    + +```python +from slack_bolt import App +# process_before_response must be True when running on FaaS +app = App(process_before_response=True) + +def respond_to_slack_within_3_seconds(body, ack): + if "text" in body: + ack(":x: Usage: /start-process (description here)") + else: + ack(f"Accepted! (task: {body['text']})") + +import time +def run_long_process(respond, body): + time.sleep(5) # longer than 3 seconds + respond(f"Completed! (task: {body['text']})") + +app.command("/start-process")( + ack=respond_to_slack_within_3_seconds, # responsible for calling `ack()` + lazy=[run_long_process] # unable to call `ack()` / can have multiple functions +) + +from slack_bolt.adapter.aws_lambda import SlackRequestHandler +def handler(event, context): + slack_handler = SlackRequestHandler(app=app) + return slack_handler.handle(event, context) +``` + +
    + +

    Why is this feature useful for FaaS users?

    +
    + +
    + +For common Bolt apps, you can call `ack()` at the beginning of a listener function this way: + +```python +@app.shortcut("callback-id-here") +def open_modal(ack, body, client): + ack() # acknowledge within 3 seconds + run_time_consuming_operation_here() +``` + +However, if you run your app on FaaS or a similar runtime (that doesn't allow running threads/processes after returning an HTTP response), you will use the `process_before_response=True` option to hold off sending an HTTP response util completing all the tasks in a listener. In this case, all your listener functions must complete within 3 seconds. + +```python +app = App(process_before_response=True) + +@app.command("/hello") +def this_always_times_out(ack): + ack() # will be held off for 5 seconds + time.sleep(5) +``` + +To deal with this, you can use keyword args `ack: Callable` and `lazy: List[Callable]`: + +* `ack: Callable` is responsible for calling `ack()` +* `lazy: List[Callable]` are unable to call `ack()` but can do any time consuming operations in a separate execution (in a thread, another AWS Lambda invocation, and so on) + +Instead of acting as a decorator for a method, `App`/`AsyncApp`'s methods takes keyword args as below. + +```python +app.command("/start-process")( + # ack function is responsible for calling `ack()` + ack=respond_to_slack_within_3_seconds, + # lazy functions are unable to call `ack()` + lazy=[run_long_process] +) +``` +
    + +
    diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md index 76a40a684..d8c3803ed 100644 --- a/docs/_basic/listening_modals.md +++ b/docs/_basic/listening_modals.md @@ -19,11 +19,9 @@ Read more about view submissions in our
    0: ack(response_action="errors", errors=errors) return - # Acknowledge the view_submission event and close the modal ack() - # Do whatever you want with the input data - here we're saving it to a DB # then sending the user a verification of their submission # Message to send user msg = "" - try: - # Save to DB - msg = f"Your submission of {val} was successful" + # Save to DB + msg = f"Your submission of {val} was successful" except Exception as e: - # Handle error - msg = "There was an error with your submission" + # Handle error + msg = "There was an error with your submission" finally: - # Message the user - client.chat_postMessage(channel=user, text=msg) + # Message the user + client.chat_postMessage(channel=user, text=msg) ``` diff --git a/docs/_basic/listening_responding_shortcuts.md b/docs/_basic/listening_responding_shortcuts.md index 7096ad0a8..3d9e2ccca 100644 --- a/docs/_basic/listening_responding_shortcuts.md +++ b/docs/_basic/listening_responding_shortcuts.md @@ -84,14 +84,8 @@ def open_modal(ack, shortcut, client): trigger_id=shortcut["trigger_id"], view={ "type": "modal", - "title": { - "type": "plain_text", - "text": "My App" - }, - "close": { - "type": "plain_text", - "text": "Close" - }, + "title": {"type": "plain_text", "text": "My App"}, + "close": {"type": "plain_text", "text": "Close"}, "blocks": [ { "type": "section", diff --git a/docs/_basic/opening_modals.md b/docs/_basic/opening_modals.md index aef905b4d..29a2fcba5 100644 --- a/docs/_basic/opening_modals.md +++ b/docs/_basic/opening_modals.md @@ -31,44 +31,29 @@ def open_modal(ack, body, client): "type": "modal", # View identifier "callback_id": "view_1", - "title": { - "type": "plain_text", - "text": "Modal title" - }, + "title": {"type": "plain_text", "text": "My App"}, + "submit": {"type": "plain_text", "text": "Submit"}, "blocks": [ { "type": "section", - "text": { - "type": "mrkdwn", - "text": "Welcome to a modal with _blocks_" - }, + "text": {"type": "mrkdwn", "text": "Welcome to a modal with _blocks_"}, "accessory": { "type": "button", - "text": { - "type": "plain_text", - "text": "Click me!" - }, + "text": {"type": "plain_text", "text": "Click me!"}, "action_id": "button_abc" } }, { "type": "input", "block_id": "input_c", - "label": { - "type": "plain_text", - "text": "What are your hopes and dreams?" - }, + "label": {"type": "plain_text", "text": "What are your hopes and dreams?"}, "element": { "type": "plain_text_input", "action_id": "dreamy_input", "multiline": True } } - ], - "submit": { - "type": "plain_text", - "text": "Submit" - } + ] } ) ``` diff --git a/docs/_basic/updating_pushing_modals.md b/docs/_basic/updating_pushing_modals.md index 56ae4748b..766a1f9be 100644 --- a/docs/_basic/updating_pushing_modals.md +++ b/docs/_basic/updating_pushing_modals.md @@ -35,17 +35,11 @@ def update_modal(ack, view, client): "type": "modal", # View identifier "callback_id": "view_1", - "title": { - "type": "plain_text", - "text": "Updated modal" - }, + "title": {"type": "plain_text", "text": "Updated modal"}, "blocks": [ { "type": "section", - "text": { - "type": "plain_text", - "text": "You updated the modal!" - } + "text": {"type": "plain_text", "text": "You updated the modal!"} }, { "type": "image", diff --git a/docs/_tutorials/getting_started.md b/docs/_tutorials/getting_started.md index 5c1d4af0f..4174d78a6 100644 --- a/docs/_tutorials/getting_started.md +++ b/docs/_tutorials/getting_started.md @@ -236,16 +236,10 @@ def message_hello(message, say): blocks=[ { "type": "section", - "text": { - "type": "mrkdwn", - "text": f"Hey there <@{message['user']}>!" - }, + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, "accessory": { "type": "button", - "text": { - "type": "plain_text", - "text": "Click Me" - }, + "text": {"type": "plain_text", "text": "Click Me"}, "action_id": "button_click" } } @@ -286,16 +280,10 @@ def message_hello(message, say): blocks=[ { "type": "section", - "text": { - "type": "mrkdwn", - "text": f"Hey there <@{message['user']}>!" - }, + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, "accessory": { "type": "button", - "text": { - "type": "plain_text", - "text": "Click Me" - }, + "text": {"type": "plain_text", "text": "Click Me"}, "action_id": "button_click" } } diff --git a/docs/assets/style.css b/docs/assets/style.css index f40ec15f6..aebddd563 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -212,7 +212,7 @@ span.beta { margin: 1em 0 0 33%; padding-bottom: 2em; font-size: 1em; - line-height: 1.75em; + line-height: 1.5em; } .tutorial img { @@ -241,7 +241,7 @@ span.beta { pre { background-color: var(--light-grey) !important; background-image: none; - padding: 1.5em; + padding: 1.2em; border: 1px solid #DDDDDD; } @@ -252,7 +252,7 @@ pre code { .content .section-wrapper .section-content { grid-area: body; font-size: 1em; - line-height: 2em; + line-height: 1.5em; } .content .section-wrapper h3 { From ddbf7cce668336e343d9769e209a25065dec2beb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 19 Sep 2020 12:40:34 +0900 Subject: [PATCH 092/865] verson 0.6.0a0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 33c9fe1a2..9b980958a 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.5.3a0" +__version__ = "0.6.0a0" From 2f92ab4ba285b2415199c4c458ac6ab0b2f07221 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 19 Sep 2020 13:37:42 +0900 Subject: [PATCH 093/865] Tweak documents for consistency --- docs/_basic/listening_actions.md | 17 ++++++++++++----- docs/_basic/listening_events.md | 8 ++++++-- docs/_basic/listening_responding_shortcuts.md | 10 ++-------- docs/_basic/sending_messages.md | 10 ++-------- docs/assets/style.css | 4 ++-- 5 files changed, 24 insertions(+), 25 deletions(-) diff --git a/docs/_basic/listening_actions.md b/docs/_basic/listening_actions.md index dcf7bd147..131b48701 100644 --- a/docs/_basic/listening_actions.md +++ b/docs/_basic/listening_actions.md @@ -35,12 +35,19 @@ You can use a constraints object to listen to `callback_id`s, `block_id`s, and ` ```python # Your function will only be called when the action_id matches 'select_user' AND the block_id matches 'assign_ticket' -@app.action({"action_id": "select_user", "block_id": "assign_ticket"}) -def update_message(ack, action, body, client): +@app.action({ + "block_id": "assign_ticket", + "action_id": "select_user" +}) +def update_message(ack, body, client): ack() - client.reactions_add(name='white_check_mark', - timestamp=action['action_ts'], - channel=body['channel']['id']) + + if "container" in body and "message_ts" in body["container"]: + client.reactions_add( + name="white_check_mark", + channel=body["channel"]["id"], + timestamp=body["container"]["message_ts"], + ) ``` diff --git a/docs/_basic/listening_events.md b/docs/_basic/listening_events.md index 5e8059f2f..ddfcd7536 100644 --- a/docs/_basic/listening_events.md +++ b/docs/_basic/listening_events.md @@ -38,9 +38,13 @@ You can filter on subtypes of events by passing in the additional key `subtype`. ```python # Matches all messages from bot users -@app.event({"type": "message", "subtype": "message_changed"}) +@app.event({ + "type": "message", + "subtype": "message_changed" +}) def log_message_change(logger, event): - logger.info(f"The user {event['user']} changed the message to {event['text']}") + user, text = event["user"], event["text"] + logger.info(f"The user {user} changed the message to {text}") ``` diff --git a/docs/_basic/listening_responding_shortcuts.md b/docs/_basic/listening_responding_shortcuts.md index 3d9e2ccca..8e5ad6343 100644 --- a/docs/_basic/listening_responding_shortcuts.md +++ b/docs/_basic/listening_responding_shortcuts.md @@ -33,14 +33,8 @@ def open_modal(ack, shortcut, client): trigger_id=shortcut["trigger_id"], view={ "type": "modal", - "title": { - "type": "plain_text", - "text": "My App" - }, - "close": { - "type": "plain_text", - "text": "Close" - }, + "title": {"type": "plain_text", "text": "My App"}, + "close": {"type": "plain_text", "text": "Close"}, "blocks": [ { "type": "section", diff --git a/docs/_basic/sending_messages.md b/docs/_basic/sending_messages.md index be2291ab4..cb6170f6a 100644 --- a/docs/_basic/sending_messages.md +++ b/docs/_basic/sending_messages.md @@ -40,18 +40,12 @@ def show_datepicker(event, say): if reaction == "calendar": blocks = [{ "type": "section", - "text": { - "type": "mrkdwn", - "text": "Pick a date for me to remind you" - }, + "text": {"type": "mrkdwn", "text": "Pick a date for me to remind you"}, "accessory": { "type": "datepicker", "action_id": "datepicker_remind", "initial_date": "2020-05-04", - "placeholder": { - "type": "plain_text", - "text": "Select a date" - } + "placeholder": {"type": "plain_text", "text": "Select a date"} } }] say( diff --git a/docs/assets/style.css b/docs/assets/style.css index aebddd563..4b62436e8 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -234,7 +234,7 @@ span.beta { padding-bottom: 1em; } -.content .section-wrapper .language-javascript { +.content .section-wrapper .language-python { grid-area: code; } @@ -281,7 +281,7 @@ a:hover { line-height: 1.75em; } -.secondary-wrapper .language-javascript { +.secondary-wrapper .language-python { width: 50%; float: left; margin-top: 1em; From b5b4d213236c0d4f35b9b6fcb02a34855839122a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 19 Sep 2020 14:05:57 +0900 Subject: [PATCH 094/865] Fix document layout --- docs/_advanced/lazy_listener_functions.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/_advanced/lazy_listener_functions.md b/docs/_advanced/lazy_listener_functions.md index cf47fee18..fff26fb9f 100644 --- a/docs/_advanced/lazy_listener_functions.md +++ b/docs/_advanced/lazy_listener_functions.md @@ -63,6 +63,7 @@ def handler(event, context):
    For common Bolt apps, you can call `ack()` at the beginning of a listener function this way: +
    ```python @app.shortcut("callback-id-here") @@ -71,7 +72,9 @@ def open_modal(ack, body, client): run_time_consuming_operation_here() ``` +
    However, if you run your app on FaaS or a similar runtime (that doesn't allow running threads/processes after returning an HTTP response), you will use the `process_before_response=True` option to hold off sending an HTTP response util completing all the tasks in a listener. In this case, all your listener functions must complete within 3 seconds. +
    ```python app = App(process_before_response=True) @@ -82,12 +85,14 @@ def this_always_times_out(ack): time.sleep(5) ``` +
    To deal with this, you can use keyword args `ack: Callable` and `lazy: List[Callable]`: * `ack: Callable` is responsible for calling `ack()` * `lazy: List[Callable]` are unable to call `ack()` but can do any time consuming operations in a separate execution (in a thread, another AWS Lambda invocation, and so on) Instead of acting as a decorator for a method, `App`/`AsyncApp`'s methods takes keyword args as below. +
    ```python app.command("/start-process")( @@ -97,6 +102,5 @@ app.command("/start-process")( lazy=[run_long_process] ) ``` -
    From f96d9b7ca26b48e3870684937bfa6943cc141bd5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 21 Sep 2020 16:03:41 +0900 Subject: [PATCH 095/865] Add more docstring #21 --- slack_bolt/app/app.py | 217 ++++++++++++++---- slack_bolt/app/async_app.py | 207 +++++++++++++---- slack_bolt/app/async_server.py | 6 +- slack_bolt/auth/result.py | 10 + slack_bolt/kwargs_injection/args.py | 14 +- slack_bolt/kwargs_injection/async_args.py | 14 +- slack_bolt/lazy_listener/async_runner.py | 12 + slack_bolt/lazy_listener/runner.py | 12 + slack_bolt/listener/async_listener.py | 14 +- .../listener/async_listener_error_handler.py | 7 + slack_bolt/listener/listener_error_handler.py | 7 + .../async_listener_matcher.py | 6 + .../listener_matcher/listener_matcher.py | 6 + slack_bolt/logger/messages.py | 96 ++++++++ .../async_multi_teams_authorization.py | 6 + .../async_single_team_authorization.py | 5 + .../multi_teams_authorization.py | 6 + .../single_team_authorization.py | 6 + .../ignoring_self_events.py | 1 + .../async_message_listener_matches.py | 1 + .../message_listener_matches.py | 1 + .../request_verification.py | 5 + slack_bolt/middleware/ssl_check/ssl_check.py | 6 + .../url_verification/url_verification.py | 4 + slack_bolt/oauth/async_callback_options.py | 14 ++ slack_bolt/oauth/async_oauth_flow.py | 6 + slack_bolt/oauth/async_oauth_settings.py | 18 ++ slack_bolt/oauth/callback_options.py | 19 ++ slack_bolt/oauth/oauth_flow.py | 6 + slack_bolt/oauth/oauth_settings.py | 18 ++ slack_bolt/request/async_request.py | 7 + slack_bolt/request/request.py | 7 + slack_bolt/response/response.py | 6 + 33 files changed, 655 insertions(+), 115 deletions(-) create mode 100644 slack_bolt/logger/messages.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 665c23b9b..a0599a45a 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -12,6 +12,22 @@ from slack_sdk.web import WebClient from slack_bolt.error import BoltError +from slack_bolt.logger.messages import ( + error_signing_secret_not_found, + warning_client_prioritized_and_token_skipped, + warning_installation_store_prioritized_and_token_skipped, + error_auth_test_failure, + error_token_required, + warning_unhandled_request, + debug_checking_listener, + debug_applying_middleware, + debug_running_listener, + warning_did_not_call_ack, + debug_running_lazy_listener, + debug_responding, + error_unexpected_listener_middleware, + error_client_invalid_type, +) from slack_bolt.lazy_listener.runner import LazyListenerRunner from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner from slack_bolt.listener.custom_listener import CustomListener @@ -65,13 +81,28 @@ def __init__( # No need to set (the value is used only in response to ssl_check requests) verification_token: Optional[str] = None, ): + """Bolt App that provides functionalities to register middleware/listeners + + :param name: The application name that will be used in logging. + If absent, the source file name will be used instead. + :param process_before_response: True if this app runs on Function as a Service. (Default: False) + :param signing_secret: The Signing Secret value used for verifying requests from Slack. + :param token: The bot access token required only for single-workspace app. + :param client: The singleton slack_sdk.WebClient instance for this app. + :param installation_store: The module offering save/find operations of installation data + :param oauth_settings: The settings related to Slack app installation flow (OAuth flow) + :param oauth_flow: Manually instantiated slack_bolt.oauth.OAuthFlow. + This is always prioritized over oauth_settings. + :param authorization_test_enabled: Set False if you want to skip auth.test calls + for every single incoming request from Slack (default: True) + :param verification_token: Deprecated verification mechanism. + This can used only for ssl_check requests. + """ signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", None) token = token or os.environ.get("SLACK_BOT_TOKEN", None) if signing_secret is None or signing_secret == "": - raise BoltError( - "Signing secret not found, so could not initialize the Bolt app." - ) + raise BoltError(error_signing_secret_not_found()) self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] self._signing_secret: str = signing_secret @@ -85,12 +116,12 @@ def __init__( if client is not None: if not isinstance(client, WebClient): - raise BoltError("client must be a WebClient") + raise BoltError(error_client_invalid_type()) self._client = client self._token = client.token if token is not None: self._framework_logger.warning( - "As you gave client as well, the bot token will be unused." + warning_client_prioritized_and_token_skipped() ) else: self._client = create_web_client(token) # NOTE: the token here can be None @@ -117,7 +148,7 @@ def __init__( if self._installation_store is not None and self._token is not None: self._token = None self._framework_logger.warning( - "As you gave installation_store as well, the bot token will be unused." + warning_installation_store_prioritized_and_token_skipped() ) self._middleware_list: List[Union[Callable, Middleware]] = [] @@ -157,13 +188,9 @@ def _init_middleware_list(self): ) ) except SlackApiError as err: - raise BoltError( - f"token is invalid (auth.test result: {err.response})" - ) + raise BoltError(error_auth_test_failure(err.response)) else: - raise BoltError( - "Either an env variable SLACK_BOT_TOKEN or token argument in constructor is required." - ) + raise BoltError(error_token_required()) else: self._middleware_list.append( MultiTeamsAuthorization( @@ -206,6 +233,14 @@ def listener_error_handler(self) -> ListenerErrorHandler: # standalone server def start(self, port: int = 3000, path: str = "/slack/events") -> None: + """Start a web server for local development. + This method internally starts a Web server process built with the http.server module.' + For production, consider using a production-ready WSGI server such as Gunicorn. + + :param port: The port to listen on (Default: 3000) + :param path: The path to handle request from Slack (Default: /slack/events) + :return: None + """ self._development_server = SlackAppDevelopmentServer( port=port, path=path, app=self, oauth_flow=self.oauth_flow, ) @@ -215,6 +250,11 @@ def start(self, port: int = 3000, path: str = "/slack/events") -> None: # main dispatcher def dispatch(self, req: BoltRequest) -> BoltResponse: + """Applies all middleware and dispatches an incoming request from Slack to the right code path. + + :param req: An incoming request from Slack. + :return: The response generated by this Bolt app. + """ self._init_context(req) resp: BoltResponse = BoltResponse(status=200, body="") @@ -226,7 +266,7 @@ def middleware_next(): for middleware in self._middleware_list: middleware_state["next_called"] = False if self._framework_logger.level <= logging.DEBUG: - self._framework_logger.debug(f"Applying {middleware.name}") + self._framework_logger.debug(debug_applying_middleware(middleware.name)) resp = middleware.process(req=req, resp=resp, next=middleware_next) if not middleware_state["next_called"]: if resp is None: @@ -237,7 +277,7 @@ def middleware_next(): for listener in self._listeners: listener_name = listener.ack_function.__name__ - self._framework_logger.debug(f"Checking listener: {listener_name} ...") + self._framework_logger.debug(debug_checking_listener(listener_name)) if listener.matches(req=req, resp=resp): # run all the middleware attached to this listener first resp, next_was_not_called = listener.run_middleware(req=req, resp=resp) @@ -246,8 +286,8 @@ def middleware_next(): # This means the listener is not for this incoming request. continue - self._framework_logger.debug(f"Running listener: {listener_name} ...") - listener_response: Optional[BoltResponse] = self.run_listener( + self._framework_logger.debug(debug_running_listener(listener_name)) + listener_response: Optional[BoltResponse] = self._run_listener( request=req, response=resp, listener_name=listener_name, @@ -256,10 +296,10 @@ def middleware_next(): if listener_response is not None: return listener_response - self._framework_logger.warning(f"Unhandled request ({req.body})") + self._framework_logger.warning(warning_unhandled_request(req)) return BoltResponse(status=404, body={"error": "unhandled request"}) - def run_listener( + def _run_listener( self, request: BoltRequest, response: BoltResponse, @@ -359,7 +399,7 @@ def run_ack_function_asynchronously(): time.sleep(0.01) if response is None and ack.response is None: - self._framework_logger.warning(f"{listener_name} didn't call ack()") + self._framework_logger.warning(warning_did_not_call_ack(listener_name)) return None if response is None and ack.response is not None: @@ -375,10 +415,10 @@ def run_ack_function_asynchronously(): def _start_lazy_function( self, lazy_func: Callable[..., None], request: BoltRequest - ): + ) -> None: # Start a lazy function asynchronously func_name: str = lazy_func.__name__ - self._framework_logger.debug(f"Running lazy listener: {func_name} ...") + self._framework_logger.debug(debug_running_lazy_listener(func_name)) copied_request = self._build_lazy_request(request, func_name) self.lazy_listener_runner.start(function=lazy_func, request=copied_request) @@ -395,16 +435,22 @@ def _debug_log_completion( ) -> None: millis = int((time.time() - starting_time) * 1000) self._framework_logger.debug( - f'Responding with status: {response.status} body: "{response.body}" ({millis} millis)' + debug_responding(response.status, response.body, millis) ) # ------------------------- # middleware - def use(self, *args): + def use(self, *args) -> None: + """Refer to middleware method's docstring for details.""" return self.middleware(*args) - def middleware(self, *args): + def middleware(self, *args) -> None: + """Registers a new middleware to this Bolt app. + + :param args: a list of middleware. Passing a single middleware is supported. + :return: None + """ if len(args) > 0: func = args[0] self._middleware_list.append( @@ -414,7 +460,13 @@ def middleware(self, *args): # ------------------------- # global error handler - def error(self, func: Callable[..., None]): + def error(self, func: Callable[..., None]) -> None: + """Updates the global error handler. + + :param func: The function that is supposed to be executed + when getting an unhandled error in Bolt app. + :return: None + """ self._listener_error_handler = CustomListenerErrorHandler( logger=self._framework_logger, func=func, ) @@ -427,7 +479,15 @@ def event( event: Union[str, Pattern, Dict[str, str]], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new event listener. + + :param event: The conditions to match against a request payload + :param matchers: A list of listener matcher functions. + :param middleware: A list of lister middleware functions. + :return: None + """ + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event) @@ -442,7 +502,10 @@ def message( keyword: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new message event listener. + Check the #event method's docstring for details. + """ matchers = matchers if matchers else [] middleware = middleware if middleware else [] @@ -464,7 +527,15 @@ def command( command: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new slash command listener. + + :param command: The conditions to match against a request payload + :param matchers: A list of listener matcher functions. + :param middleware: A list of lister middleware functions. + :return: None + """ + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.command(command) @@ -482,7 +553,15 @@ def shortcut( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new shortcut listener. + + :param constraints: The conditions to match against a request payload + :param matchers: A list of listener matcher functions. + :param middleware: A list of lister middleware functions. + :return: None + """ + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.shortcut(constraints) @@ -497,7 +576,9 @@ def global_shortcut( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new global shortcut listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.global_shortcut(callback_id) @@ -512,7 +593,9 @@ def message_shortcut( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new message shortcut listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.message_shortcut(callback_id) @@ -530,7 +613,15 @@ def action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new action listener. + + :param constraints: The conditions to match against a request payload + :param matchers: A list of listener matcher functions. + :param middleware: A list of lister middleware functions. + :return: None + """ + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.action(constraints) @@ -545,7 +636,9 @@ def block_action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new block_actions listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.block_action(constraints) @@ -560,7 +653,9 @@ def attachment_action( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new interactive_message listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.attachment_action(callback_id) @@ -575,7 +670,9 @@ def dialog_submission( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new dialog_submission listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.dialog_submission(callback_id) @@ -590,7 +687,9 @@ def dialog_cancellation( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new dialog_cancellation listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.dialog_cancellation(callback_id) @@ -608,7 +707,15 @@ def view( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new view submission/closed event listener. + + :param constraints: The conditions to match against a request payload + :param matchers: A list of listener matcher functions. + :param middleware: A list of lister middleware functions. + :return: None + """ + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.view(constraints) @@ -623,7 +730,9 @@ def view_submission( constraints: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new view_submission listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.view_submission(constraints) @@ -638,7 +747,9 @@ def view_closed( constraints: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new view_closed listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.view_closed(constraints) @@ -656,7 +767,15 @@ def options( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new options listener. + + :param constraints: The conditions to match against a request payload + :param matchers: A list of listener matcher functions. + :param middleware: A list of lister middleware functions. + :return: None + """ + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.options(constraints) @@ -671,7 +790,9 @@ def block_suggestion( action_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new block_suggestion listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.block_suggestion(action_id) @@ -686,7 +807,9 @@ def dialog_suggestion( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new dialog_submission listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.dialog_suggestion(callback_id) @@ -736,9 +859,7 @@ def _register_listener( elif isinstance(m, Callable): listener_middleware.append(CustomMiddleware(app_name=self.name, func=m)) else: - raise ValueError( - f"Unexpected value for a listener middleware: {type(m)}" - ) + raise ValueError(error_unexpected_listener_middleware(type(m))) self._listeners.append( CustomListener( @@ -841,7 +962,11 @@ def _send_response( self._server = HTTPServer(("0.0.0.0", self._port), SlackAppHandler) - def start(self): + def start(self) -> None: + """Starts a new web server process. + + :return: None + """ if self._bolt_app.logger.level > logging.INFO: print("⚡️ Bolt app is running! (development server)") else: diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 89333dc2e..e89d3c10e 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -13,6 +13,21 @@ from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.error import BoltError +from slack_bolt.logger.messages import ( + error_signing_secret_not_found, + warning_client_prioritized_and_token_skipped, + warning_installation_store_prioritized_and_token_skipped, + error_token_required, + warning_unhandled_request, + debug_checking_listener, + debug_running_listener, + warning_did_not_call_ack, + debug_running_lazy_listener, + debug_responding, + error_unexpected_listener_middleware, + error_listener_function_must_be_coro_func, + error_client_invalid_type_async, +) from slack_bolt.lazy_listener.async_runner import AsyncLazyListenerRunner from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -74,13 +89,28 @@ def __init__( # No need to set (the value is used only in response to ssl_check requests) verification_token: Optional[str] = None, ): + """Bolt App that provides functionalities to register middleware/listeners + + :param name: The application name that will be used in logging. + If absent, the source file name will be used instead. + :param process_before_response: True if this app runs on Function as a Service. (Default: False) + :param signing_secret: The Signing Secret value used for verifying requests from Slack. + :param token: The bot access token required only for single-workspace app. + :param client: The singleton slack_sdk.web.async_client.AsyncWebClient instance for this app. + :param installation_store: The module offering save/find operations of installation data + :param oauth_settings: The settings related to Slack app installation flow (OAuth flow) + :param oauth_flow: Manually instantiated slack_bolt.oauth.async_oauth_flow.AsyncOAuthFlow. + This is always prioritized over oauth_settings. + :param authorization_test_enabled: Set False if you want to skip auth.test calls + for every single incoming request from Slack (default: True) + :param verification_token: Deprecated verification mechanism. + This can used only for ssl_check requests. + """ signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", None) token = token or os.environ.get("SLACK_BOT_TOKEN", None) if signing_secret is None or signing_secret == "": - raise BoltError( - "Signing secret not found, so could not initialize the Bolt app." - ) + raise BoltError(error_signing_secret_not_found()) self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] self._signing_secret: str = signing_secret @@ -93,12 +123,12 @@ def __init__( if client is not None: if not isinstance(client, AsyncWebClient): - raise BoltError("client must be an AsyncWebClient") + raise BoltError(error_client_invalid_type_async()) self._async_client = client self._token = client.token if token is not None: self._framework_logger.warning( - "As you gave client as well, the bot token will be unused." + warning_client_prioritized_and_token_skipped() ) else: # NOTE: the token here can be None @@ -131,7 +161,7 @@ def __init__( if self._async_installation_store is not None and self._token is not None: self._token = None self._framework_logger.warning( - "As you gave installation_store as well, the bot token will be unused." + warning_installation_store_prioritized_and_token_skipped() ) self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] @@ -164,9 +194,7 @@ def _init_async_middleware_list(self): ) ) else: - raise BoltError( - "Either an env variable SLACK_BOT_TOKEN or token argument in constructor is required." - ) + raise BoltError(error_token_required()) else: self._async_middleware_list.append( AsyncMultiTeamsAuthorization( @@ -210,6 +238,12 @@ def listener_error_handler(self) -> AsyncListenerErrorHandler: # standalone server def start(self, port: int = 3000, path: str = "/slack/events") -> None: + """Start a web server using AIOHTTP. + + :param port: The port to listen on (Default: 3000) + :param path: The path to handle request from Slack (Default: /slack/events) + :return: None + """ from .async_server import AsyncSlackAppServer self.server = AsyncSlackAppServer(port=port, path=path, app=self,) @@ -219,6 +253,11 @@ def start(self, port: int = 3000, path: str = "/slack/events") -> None: # main dispatcher async def async_dispatch(self, req: AsyncBoltRequest) -> BoltResponse: + """Applies all middleware and dispatches an incoming request from Slack to the right code path. + + :param req: An incoming request from Slack. + :return: The response generated by this Bolt app. + """ self._init_context(req) resp: BoltResponse = BoltResponse(status=200, body="") @@ -243,7 +282,7 @@ async def async_middleware_next(): for listener in self._async_listeners: listener_name = listener.ack_function.__name__ - self._framework_logger.debug(f"Checking listener: {listener_name} ...") + self._framework_logger.debug(debug_checking_listener(listener_name)) if await listener.async_matches(req=req, resp=resp): # run all the middleware attached to this listener first resp, next_was_not_called = await listener.run_async_middleware( @@ -254,10 +293,10 @@ async def async_middleware_next(): # This means the listener is not for this incoming request. continue - self._framework_logger.debug(f"Running listener: {listener_name} ...") + self._framework_logger.debug(debug_running_listener(listener_name)) listener_response: Optional[ BoltResponse - ] = await self.run_async_listener( + ] = await self._run_async_listener( request=req, response=resp, listener_name=listener_name, @@ -266,10 +305,10 @@ async def async_middleware_next(): if listener_response is not None: return listener_response - self._framework_logger.warning(f"Unhandled request ({req.body})") + self._framework_logger.warning(warning_unhandled_request(req)) return BoltResponse(status=404, body={"error": "unhandled request"}) - async def run_async_listener( + async def _run_async_listener( self, request: AsyncBoltRequest, response: BoltResponse, @@ -351,9 +390,6 @@ async def run_ack_function_asynchronously( _f: Future = asyncio.ensure_future( run_ack_function_asynchronously(ack, request, response) ) - self._framework_logger.debug( - f"Async listener: {listener_name} started.." - ) for lazy_func in async_listener.lazy_functions: if request.lazy_function_name: @@ -374,7 +410,7 @@ async def run_ack_function_asynchronously( await asyncio.sleep(0.01) if response is None and ack.response is None: - self._framework_logger.warning(f"{listener_name} didn't call ack()") + self._framework_logger.warning(warning_did_not_call_ack(listener_name)) return None if response is None and ack.response is not None: @@ -390,10 +426,10 @@ async def run_ack_function_asynchronously( def _start_lazy_function( self, lazy_func: Callable[..., Awaitable[None]], request: AsyncBoltRequest - ): + ) -> None: # Start a lazy function asynchronously func_name: str = lazy_func.__name__ - self._framework_logger.debug(f"Running lazy listener: {func_name} ...") + self._framework_logger.debug(debug_running_lazy_listener(func_name)) copied_request = self._build_lazy_request(request, func_name) self.lazy_listener_runner.start(function=lazy_func, request=copied_request) @@ -412,16 +448,22 @@ def _debug_log_completion( ) -> None: millis = int((time.time() - starting_time) * 1000) self._framework_logger.debug( - f'Responding with status: {response.status} body: "{response.body}" ({millis} millis)' + debug_responding(response.status, response.body, millis) ) # ------------------------- # middleware - def use(self, *args): + def use(self, *args) -> None: + """Refer to middleware method's docstring for details.""" return self.middleware(*args) - def middleware(self, *args): + def middleware(self, *args) -> None: + """Registers a new middleware to this Bolt app. + + :param args: a list of middleware. Passing a single middleware is supported. + :return: None + """ if len(args) > 0: func = args[0] self._async_middleware_list.append( @@ -431,7 +473,13 @@ def middleware(self, *args): # ------------------------- # global error handler - def error(self, func: Callable[..., Awaitable[None]]): + def error(self, func: Callable[..., Awaitable[None]]) -> None: + """Updates the global error handler. + + :param func: The function that is supposed to be executed + when getting an unhandled error in Bolt app. + :return: None + """ self._async_listener_error_handler = AsyncCustomListenerErrorHandler( logger=self._framework_logger, func=func, ) @@ -444,7 +492,15 @@ def event( event: Union[str, Pattern, Dict[str, str]], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new event listener. + + :param event: The conditions to match against a request payload + :param matchers: A list of listener matcher functions. + :param middleware: A list of lister middleware functions. + :return: None + """ + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, True) @@ -459,7 +515,8 @@ def message( keyword: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Register a new message event listener.""" matchers = matchers if matchers else [] middleware = middleware if middleware else [] @@ -481,7 +538,15 @@ def command( command: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new slash command listener. + + :param command: The conditions to match against a request payload + :param matchers: A list of listener matcher functions. + :param middleware: A list of lister middleware functions. + :return: None + """ + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.command(command, True) @@ -499,7 +564,15 @@ def shortcut( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new shortcut listener. + + :param constraints: The conditions to match against a request payload + :param matchers: A list of listener matcher functions. + :param middleware: A list of lister middleware functions. + :return: None + """ + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.shortcut(constraints, True) @@ -514,7 +587,9 @@ def global_shortcut( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new global shortcut listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.global_shortcut(callback_id, True) @@ -529,7 +604,9 @@ def message_shortcut( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new message shortcut listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.message_shortcut(callback_id, True) @@ -547,7 +624,15 @@ def action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new action listener. + + :param constraints: The conditions to match against a request payload + :param matchers: A list of listener matcher functions. + :param middleware: A list of lister middleware functions. + :return: None + """ + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.action(constraints, True) @@ -562,7 +647,9 @@ def block_action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new block_actions listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.block_action(constraints, True) @@ -577,7 +664,9 @@ def attachment_action( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new interactive_message listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.attachment_action(callback_id, True) @@ -592,7 +681,9 @@ def dialog_submission( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new dialog_submission listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.dialog_submission(callback_id, True) @@ -607,7 +698,9 @@ def dialog_cancellation( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new dialog_cancellation listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.dialog_cancellation(callback_id, True) @@ -625,7 +718,15 @@ def view( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new view submission/closed listener. + + :param constraints: The conditions to match against a request payload + :param matchers: A list of listener matcher functions. + :param middleware: A list of lister middleware functions. + :return: None + """ + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.view(constraints, True) @@ -640,7 +741,9 @@ def view_submission( constraints: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new view_submission listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.view_submission(constraints, True) @@ -655,7 +758,9 @@ def view_closed( constraints: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new view_closed listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.view_closed(constraints, True) @@ -673,7 +778,15 @@ def options( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new options listener. + + :param constraints: The conditions to match against a request payload + :param matchers: A list of listener matcher functions. + :param middleware: A list of lister middleware functions. + :return: None + """ + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.options(constraints, True) @@ -688,7 +801,9 @@ def block_suggestion( action_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new block_suggestion listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.block_suggestion(action_id, True) @@ -703,7 +818,9 @@ def dialog_suggestion( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): + ) -> Callable[..., None]: + """Registers a new dialog_submission listener.""" + def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.dialog_suggestion(callback_id, True) @@ -743,9 +860,7 @@ def _register_listener( for func in functions: if not inspect.iscoroutinefunction(func): name = func.__name__ - raise BoltError( - f"The listener function ({name}) is not a coroutine function." - ) + raise BoltError(error_listener_function_must_be_coro_func(name)) listener_matchers = [ AsyncCustomListenerMatcher(app_name=self.name, func=f) @@ -761,9 +876,7 @@ def _register_listener( AsyncCustomMiddleware(app_name=self.name, func=m) ) else: - raise ValueError( - f"async function is required for AsyncApp's listener middleware: {type(m)}" - ) + raise ValueError(error_unexpected_listener_middleware(type(m))) self._async_listeners.append( AsyncCustomListener( diff --git a/slack_bolt/app/async_server.py b/slack_bolt/app/async_server.py index 7dc74cdf4..f4272e710 100644 --- a/slack_bolt/app/async_server.py +++ b/slack_bolt/app/async_server.py @@ -63,7 +63,11 @@ async def handle_post_requests(self, request: web.Request) -> web.Response: bolt_resp: BoltResponse = await self._bolt_app.async_dispatch(bolt_req) return await to_aiohttp_response(bolt_resp) - def start(self): + def start(self) -> None: + """ Starts a new web server process. + + :return: None + """ if self._bolt_app.logger.level > logging.INFO: print("⚡️ Bolt app is running!") else: diff --git a/slack_bolt/auth/result.py b/slack_bolt/auth/result.py index 75cb1c7c1..2aca1a0d6 100644 --- a/slack_bolt/auth/result.py +++ b/slack_bolt/auth/result.py @@ -23,6 +23,16 @@ def __init__( user_id: Optional[str] = None, user_token: Optional[str] = None, ): + """The `auth.test` API result for an incoming request. + + :param enterprise_id: Organization ID (Enterprise Grid) + :param team_id: Workspace ID + :param bot_user_id: Bot user's User ID + :param bot_id: Bot ID + :param bot_token: Bot user access token starting with xoxb- + :param user_id: The request user ID + :param user_token: User access token starting with xoxp- + """ self.enterprise_id: Optional[str] = enterprise_id self.team_id: Optional[str] = team_id # bot diff --git a/slack_bolt/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index 458235265..0774b183a 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -23,13 +23,13 @@ class Args: body: Dict[str, Any] # payload payload: Dict[str, Any] - options: Optional[Dict[str, Any]] - shortcut: Optional[Dict[str, Any]] - action: Optional[Dict[str, Any]] - view: Optional[Dict[str, Any]] - command: Optional[Dict[str, Any]] - event: Optional[Dict[str, Any]] - message: Optional[Dict[str, Any]] + options: Optional[Dict[str, Any]] # payload alias + shortcut: Optional[Dict[str, Any]] # payload alias + action: Optional[Dict[str, Any]] # payload alias + view: Optional[Dict[str, Any]] # payload alias + command: Optional[Dict[str, Any]] # payload alias + event: Optional[Dict[str, Any]] # payload alias + message: Optional[Dict[str, Any]] # payload alias # utilities ack: Ack say: Say diff --git a/slack_bolt/kwargs_injection/async_args.py b/slack_bolt/kwargs_injection/async_args.py index dd9d2db4d..47dba2259 100644 --- a/slack_bolt/kwargs_injection/async_args.py +++ b/slack_bolt/kwargs_injection/async_args.py @@ -22,13 +22,13 @@ class AsyncArgs: body: Dict[str, Any] # payload payload: Dict[str, Any] - options: Optional[Dict[str, Any]] - shortcut: Optional[Dict[str, Any]] - action: Optional[Dict[str, Any]] - view: Optional[Dict[str, Any]] - command: Optional[Dict[str, Any]] - event: Optional[Dict[str, Any]] - message: Optional[Dict[str, Any]] + options: Optional[Dict[str, Any]] # payload alias + shortcut: Optional[Dict[str, Any]] # payload alias + action: Optional[Dict[str, Any]] # payload alias + view: Optional[Dict[str, Any]] # payload alias + command: Optional[Dict[str, Any]] # payload alias + event: Optional[Dict[str, Any]] # payload alias + message: Optional[Dict[str, Any]] # payload alias # utilities ack: AsyncAck say: AsyncSay diff --git a/slack_bolt/lazy_listener/async_runner.py b/slack_bolt/lazy_listener/async_runner.py index e5f919869..d640d48ec 100644 --- a/slack_bolt/lazy_listener/async_runner.py +++ b/slack_bolt/lazy_listener/async_runner.py @@ -13,11 +13,23 @@ class AsyncLazyListenerRunner(metaclass=ABCMeta): def start( self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest ) -> None: + """Starts a new lazy listener execution. + + :param function: The function to run. + :param request: The request to pass to the function. The object must be thread-safe. + :return: None + """ raise NotImplementedError() async def run( self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest ) -> None: + """Synchronously run the function with a given request data. + + :param function: The function to run. + :param request: The request to pass to the function. The object must be thread-safe. + :return: None + """ func = to_runnable_function( internal_func=function, logger=self.logger, request=request, ) diff --git a/slack_bolt/lazy_listener/runner.py b/slack_bolt/lazy_listener/runner.py index 565c83080..3442693ea 100644 --- a/slack_bolt/lazy_listener/runner.py +++ b/slack_bolt/lazy_listener/runner.py @@ -11,7 +11,19 @@ class LazyListenerRunner(metaclass=ABCMeta): @abstractmethod def start(self, function: Callable[..., None], request: BoltRequest) -> None: + """Starts a new lazy listener execution. + + :param function: The function to run. + :param request: The request to pass to the function. The object must be thread-safe. + :return: None + """ raise NotImplementedError() def run(self, function: Callable[..., None], request: BoltRequest) -> None: + """Synchronously run the function with a given request data. + + :param function: The function to run. + :param request: The request to pass to the function. The object must be thread-safe. + :return: None + """ build_runnable_function(func=function, logger=self.logger, request=request,)() diff --git a/slack_bolt/listener/async_listener.py b/slack_bolt/listener/async_listener.py index 0535290b4..ab5cdbd79 100644 --- a/slack_bolt/listener/async_listener.py +++ b/slack_bolt/listener/async_listener.py @@ -28,11 +28,11 @@ async def async_matches( async def run_async_middleware( self, *, req: AsyncBoltRequest, resp: BoltResponse, ) -> Tuple[BoltResponse, bool]: - """ + """Runs an async middleware. - :param req: the incoming request - :param resp: the current response - :return: a tuple of the processed response and a flag indicating termination + :param req: The incoming request + :param resp: Thee current response + :return: A tuple of the processed response and a flag indicating termination """ for m in self.middleware: middleware_state = {"next_called": False} @@ -52,9 +52,9 @@ async def run_ack_function( ) -> BoltResponse: """Runs all the registered middleware and then run the listener function. - :param request: the incoming request - :param response: the current response - :return: the processed response + :param request: The incoming request + :param response: The current response + :return: The processed response """ raise NotImplementedError() diff --git a/slack_bolt/listener/async_listener_error_handler.py b/slack_bolt/listener/async_listener_error_handler.py index dea19ca36..15a3efaa5 100644 --- a/slack_bolt/listener/async_listener_error_handler.py +++ b/slack_bolt/listener/async_listener_error_handler.py @@ -25,6 +25,13 @@ async def handle( request: AsyncBoltRequest, response: Optional[BoltResponse], ) -> None: + """Handles an unhandled exception. + + :param error: The raised exception. + :param request: The request. + :param response: The response. + :return: None + """ raise NotImplementedError() diff --git a/slack_bolt/listener/listener_error_handler.py b/slack_bolt/listener/listener_error_handler.py index 5232d6c3f..7c3fb5c7f 100644 --- a/slack_bolt/listener/listener_error_handler.py +++ b/slack_bolt/listener/listener_error_handler.py @@ -22,6 +22,13 @@ class ListenerErrorHandler(metaclass=ABCMeta): def handle( self, error: Exception, request: BoltRequest, response: Optional[BoltResponse], ) -> None: + """Handles an unhandled exception. + + :param error: The raised exception. + :param request: The request. + :param response: The response. + :return: None + """ raise NotImplementedError() diff --git a/slack_bolt/listener_matcher/async_listener_matcher.py b/slack_bolt/listener_matcher/async_listener_matcher.py index ed53d7719..904697026 100644 --- a/slack_bolt/listener_matcher/async_listener_matcher.py +++ b/slack_bolt/listener_matcher/async_listener_matcher.py @@ -7,6 +7,12 @@ class AsyncListenerMatcher(metaclass=ABCMeta): @abstractmethod async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool: + """Matches against the request and returns True if matched. + + :param req: The request + :param resp: The response + :return: True if matched. + """ raise NotImplementedError() diff --git a/slack_bolt/listener_matcher/listener_matcher.py b/slack_bolt/listener_matcher/listener_matcher.py index 57a027d06..30f488efe 100644 --- a/slack_bolt/listener_matcher/listener_matcher.py +++ b/slack_bolt/listener_matcher/listener_matcher.py @@ -7,4 +7,10 @@ class ListenerMatcher(metaclass=ABCMeta): @abstractmethod def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: + """Matches against the request and returns True if matched. + + :param req: The request + :param resp: The response + :return: True if matched. + """ raise NotImplementedError() diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py new file mode 100644 index 000000000..99751a6f0 --- /dev/null +++ b/slack_bolt/logger/messages.py @@ -0,0 +1,96 @@ +from typing import Union + +from slack_sdk.web import SlackResponse + +from slack_bolt.request import BoltRequest + + +# ------------------------------- +# Error +# ------------------------------- + + +def error_signing_secret_not_found() -> str: + return ( + "Signing secret not found, so could not initialize the Bolt app." + "Copy your Signing Secret from the Basic Information page " + "and then store it in a new environment variable" + ) + + +def error_client_invalid_type() -> str: + return "`client` must be a slack_sdk.web.WebClient" + + +def error_client_invalid_type_async() -> str: + return "`client` must be a slack_sdk.web.async_client.AsyncWebClient" + + +def error_auth_test_failure(error_response: SlackResponse) -> str: + return f"`token` is invalid (auth.test result: {error_response})" + + +def error_token_required() -> str: + return ( + "Either an env variable `SLACK_BOT_TOKEN` " + "or `token` argument in the constructor is required." + ) + + +def error_unexpected_listener_middleware(middleware_type) -> str: + return f"Unexpected value for a listener middleware: {middleware_type}" + + +def error_listener_function_must_be_coro_func(func_name: str) -> str: + return f"The listener function ({func_name}) is not a coroutine function." + + +# ------------------------------- +# Warning +# ------------------------------- + + +def warning_client_prioritized_and_token_skipped() -> str: + return "As you gave `client` as well, `token` will be unused." + + +def warning_installation_store_prioritized_and_token_skipped() -> str: + return "As you gave `installation_store` as well, `token` will be unused." + + +def warning_unhandled_request(req: Union[BoltRequest, "AsyncBoltRequest"]) -> str: # type: ignore + return f"Unhandled request ({req.body})" + + +def warning_did_not_call_ack(listener_name: str) -> str: + return f"{listener_name} didn't call ack()" + + +# ------------------------------- +# Info +# ------------------------------- + + +# ------------------------------- +# Debug +# ------------------------------- + + +def debug_applying_middleware(middleware_name: str) -> str: + return f"Applying {middleware_name}" + + +def debug_checking_listener(listener_name: str) -> str: + return f"Checking listener: {listener_name} ..." + + +def debug_running_listener(listener_name: str) -> str: + return f"Running listener: {listener_name} ..." + + +def debug_running_lazy_listener(func_name: str) -> str: + return f"Running lazy listener: {func_name} ..." + + +def debug_responding(status: int, body: str, millis: int) -> str: + return f'Responding with status: {status} body: "{body}" ({millis} millis)' diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index fc2759d17..085162d78 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -24,6 +24,12 @@ def __init__( installation_store: AsyncInstallationStore, verification_enabled: bool = True, ): + """Multi-workspace authorization. + + :param installation_store: The module offering find/save operations of installation data. + :param verification_enabled: + Calls auth.test for every single incoming request from Slack if True (Default: True) + """ self.installation_store = installation_store self.verification_enabled = verification_enabled self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization) diff --git a/slack_bolt/middleware/authorization/async_single_team_authorization.py b/slack_bolt/middleware/authorization/async_single_team_authorization.py index dbe20f546..07af78f60 100644 --- a/slack_bolt/middleware/authorization/async_single_team_authorization.py +++ b/slack_bolt/middleware/authorization/async_single_team_authorization.py @@ -12,6 +12,11 @@ class AsyncSingleTeamAuthorization(AsyncAuthorization): def __init__(self, *, verification_enabled: bool = True): + """Single-workspace authorization. + + :param verification_enabled: + Calls auth.test for every single incoming request from Slack if True (Default: True) + """ self.verification_enabled = verification_enabled self.auth_result: Optional[AsyncSlackResponse] = None self.logger = get_bolt_logger(AsyncSingleTeamAuthorization) diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index a427cf379..cfbdda565 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -18,6 +18,12 @@ class MultiTeamsAuthorization(Authorization): def __init__( self, installation_store: InstallationStore, verification_enabled: bool = True, ): + """Multi-workspace authorization. + + :param installation_store: The module offering find/save operations of installation data. + :param verification_enabled: + Calls auth.test for every single incoming request from Slack if True (Default: True) + """ self.installation_store = installation_store self.verification_enabled = verification_enabled self.logger = get_bolt_logger(MultiTeamsAuthorization) diff --git a/slack_bolt/middleware/authorization/single_team_authorization.py b/slack_bolt/middleware/authorization/single_team_authorization.py index 8cf1b62cd..be60eb123 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -20,6 +20,12 @@ def __init__( auth_test_result: Optional[SlackResponse] = None, verification_enabled: bool = True, ): + """Single-workspace authorization. + + :param auth_test_result: The initial `auth.test` API call result. + :param verification_enabled: + Calls auth.test for every single incoming request from Slack if True (Default: True) + """ self.auth_test_result = auth_test_result self.verification_enabled = verification_enabled self.logger = get_bolt_logger(SingleTeamAuthorization) diff --git a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py index 8f14347c4..cb52a0c48 100644 --- a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -10,6 +10,7 @@ class IgnoringSelfEvents(Middleware): def __init__(self): + """Ignores the events generated by this bot user itself.""" self.logger = get_bolt_logger(IgnoringSelfEvents) def process( diff --git a/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py b/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py index cfb911a92..64eb4dcd3 100644 --- a/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py +++ b/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py @@ -8,6 +8,7 @@ class AsyncMessageListenerMatches(AsyncMiddleware): def __init__(self, keyword: Union[str, Pattern]): + """Captures matched keywords and saves the values in context.""" self.keyword = keyword async def async_process( diff --git a/slack_bolt/middleware/message_listener_matches/message_listener_matches.py b/slack_bolt/middleware/message_listener_matches/message_listener_matches.py index 3ccfe461d..834ab7b4b 100644 --- a/slack_bolt/middleware/message_listener_matches/message_listener_matches.py +++ b/slack_bolt/middleware/message_listener_matches/message_listener_matches.py @@ -8,6 +8,7 @@ class MessageListenerMatches(Middleware): # type: ignore def __init__(self, keyword: Union[str, Pattern]): + """Captures matched keywords and saves the values in context.""" self.keyword = keyword def process( diff --git a/slack_bolt/middleware/request_verification/request_verification.py b/slack_bolt/middleware/request_verification/request_verification.py index 772426f6d..3af1c450e 100644 --- a/slack_bolt/middleware/request_verification/request_verification.py +++ b/slack_bolt/middleware/request_verification/request_verification.py @@ -10,6 +10,11 @@ class RequestVerification(Middleware): # type: ignore def __init__(self, signing_secret: str): + """Verifies an incoming request by checking the validity of + x-slack-signature, x-slack-request-timestamp, and its body data. + + :param signing_secret: The signing secret. + """ self.verifier = SignatureVerifier(signing_secret=signing_secret) self.logger = get_bolt_logger(RequestVerification) diff --git a/slack_bolt/middleware/ssl_check/ssl_check.py b/slack_bolt/middleware/ssl_check/ssl_check.py index f2be29614..43d4eca5d 100644 --- a/slack_bolt/middleware/ssl_check/ssl_check.py +++ b/slack_bolt/middleware/ssl_check/ssl_check.py @@ -8,6 +8,12 @@ class SslCheck(Middleware): # type: ignore def __init__(self, verification_token: str = None): + """Handles ssl_check requests. + + Refer to https://api.slack.com/interactivity/slash-commands for details. + :param verification_token: The verification token to check + (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) + """ self.verification_token = verification_token self.logger = get_bolt_logger(SslCheck) diff --git a/slack_bolt/middleware/url_verification/url_verification.py b/slack_bolt/middleware/url_verification/url_verification.py index a06ac62dd..c4f2c5ea3 100644 --- a/slack_bolt/middleware/url_verification/url_verification.py +++ b/slack_bolt/middleware/url_verification/url_verification.py @@ -8,6 +8,10 @@ class UrlVerification(Middleware): # type: ignore def __init__(self): + """Handles url_verification requests. + + Refer to https://api.slack.com/events/url_verification for details. + """ self.logger = get_bolt_logger(UrlVerification) def process( diff --git a/slack_bolt/oauth/async_callback_options.py b/slack_bolt/oauth/async_callback_options.py index 17e21d16d..6acdbd3aa 100644 --- a/slack_bolt/oauth/async_callback_options.py +++ b/slack_bolt/oauth/async_callback_options.py @@ -18,6 +18,12 @@ def __init__( # type: ignore installation: Installation, settings: "AsyncOAuthSettings", ): + """The arguments for a success function. + + :param request: The request. + :param installation: The installation data. + :param settings: The settings for OAuth flow. + """ self.request = request self.installation = installation self.settings = settings @@ -33,6 +39,14 @@ def __init__( # type: ignore suggested_status_code: int, settings: "AsyncOAuthSettings", ): + """The arguments for a failure function. + + :param request: The request. + :param reason: The response. + :param error: An exception if exists. + :param suggested_status_code: The recommended HTTP status code for the failure. + :param settings: The settings for OAuth flow. + """ self.request = request self.reason = reason self.error = error diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index ff1dc6c22..ddfb72336 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -53,6 +53,12 @@ def __init__( logger: Optional[Logger] = None, settings: AsyncOAuthSettings, ): + """The module to run the Slack app installation flow (OAuth flow). + + :param client: The AsyncWebClient. + :param logger: The logger. + :param settings: OAuth settings to configure this module. + """ self._async_client = client self._logger = logger self.settings = settings diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index f30091ab8..7823b9468 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -65,6 +65,24 @@ def __init__( state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, ): + """The settings for Slack App installation (OAuth flow). + + :param client_id: Check the value in Settings > Basic Information > App Credentials + :param client_secret: Check the value in Settings > Basic Information > App Credentials + :param scopes: Check the value in Settings > Manage Distribution + :param user_scopes: Check the value in Settings > Manage Distribution + :param redirect_uri: Check the value in Features > OAuth & Permissions > Redirect URLs + :param install_path: The endpoint to start an OAuth flow (Default: /slack/install) + :param redirect_uri_path: The path of Redirect URL (Default: /slack/oauth_redirect) + :param callback_options: Give success/failure functions f you want to customize callback functions. + :param success_url: Set a complete URL if you want to redirect end-users when an installation completes. + :param failure_url: Set a complete URL if you want to redirect end-users when an installation fails. + :param authorization_url: Set a URL if you want to customize the URL https://slack.com/oauth/v2/authorize + :param installation_store: Specify the instance of InstallationStore (Default: FileInstallationStore) + :param state_store: Specify the instance of InstallationStore (Default: FileOAuthStateStore) + :param state_cookie_name: The cookie name that is set for installers' browser. (Default: slack-app-oauth-state) + :param state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) + """ # OAuth flow parameters/credentials self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID", None) self.client_secret = client_secret or os.environ.get( diff --git a/slack_bolt/oauth/callback_options.py b/slack_bolt/oauth/callback_options.py index 5056d5ad8..e1368dae1 100644 --- a/slack_bolt/oauth/callback_options.py +++ b/slack_bolt/oauth/callback_options.py @@ -18,6 +18,12 @@ def __init__( # type: ignore installation: Installation, settings: "OAuthSettings", ): + """The arguments for a success function. + + :param request: The request. + :param installation: The installation data. + :param settings: The settings for OAuth flow. + """ self.request = request self.installation = installation self.settings = settings @@ -33,6 +39,14 @@ def __init__( # type: ignore suggested_status_code: int, settings: "OAuthSettings", ): + """The arguments for a failure function. + + :param request: The request. + :param reason: The response. + :param error: An exception if exists. + :param suggested_status_code: The recommended HTTP status code for the failure. + :param settings: The settings for OAuth flow. + """ self.request = request self.reason = reason self.error = error @@ -49,6 +63,11 @@ def __init__( success: Callable[[SuccessArgs], BoltResponse], failure: Callable[[FailureArgs], BoltResponse], ): + """The configurations for OAuth flow. + + :param success: A handler for successful installation. + :param failure: A handler for any types of installation failures. + """ self.success = success self.failure = failure diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index f269c36a0..31d571736 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -53,6 +53,12 @@ def __init__( logger: Optional[Logger] = None, settings: OAuthSettings, ): + """The module to run the Slack app installation flow (OAuth flow). + + :param client: The WebClient. + :param logger: The logger. + :param settings: OAuth settings to configure this module. + """ self._client = client self._logger = logger self.settings = settings diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index 7a138d0c4..0f8a58578 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -63,6 +63,24 @@ def __init__( state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, ): + """The settings for Slack App installation (OAuth flow). + + :param client_id: Check the value in Settings > Basic Information > App Credentials + :param client_secret: Check the value in Settings > Basic Information > App Credentials + :param scopes: Check the value in Settings > Manage Distribution + :param user_scopes: Check the value in Settings > Manage Distribution + :param redirect_uri: Check the value in Features > OAuth & Permissions > Redirect URLs + :param install_path: The endpoint to start an OAuth flow (Default: /slack/install) + :param redirect_uri_path: The path of Redirect URL (Default: /slack/oauth_redirect) + :param callback_options: Give success/failure functions f you want to customize callback functions. + :param success_url: Set a complete URL if you want to redirect end-users when an installation completes. + :param failure_url: Set a complete URL if you want to redirect end-users when an installation fails. + :param authorization_url: Set a URL if you want to customize the URL https://slack.com/oauth/v2/authorize + :param installation_store: Specify the instance of InstallationStore (Default: FileInstallationStore) + :param state_store: Specify the instance of InstallationStore (Default: FileOAuthStateStore) + :param state_cookie_name: The cookie name that is set for installers' browser. (Default: slack-app-oauth-state) + :param state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) + """ self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID", None) self.client_secret = client_secret or os.environ.get( "SLACK_CLIENT_SECRET", None diff --git a/slack_bolt/request/async_request.py b/slack_bolt/request/async_request.py index 7e14d7d38..79a898086 100644 --- a/slack_bolt/request/async_request.py +++ b/slack_bolt/request/async_request.py @@ -29,6 +29,13 @@ def __init__( headers: Optional[Dict[str, Union[str, List[str]]]] = None, context: Optional[Dict[str, str]] = None, ): + """Request to a Bolt app. + + :param body: The raw request body (only plain text is supported) + :param query: The query string data in any data format. + :param headers: The request headers. + :param context: The context in this request. + """ self.raw_body = body self.query = parse_query(query) self.headers = build_normalized_headers(headers) diff --git a/slack_bolt/request/request.py b/slack_bolt/request/request.py index fa960371f..71da15316 100644 --- a/slack_bolt/request/request.py +++ b/slack_bolt/request/request.py @@ -29,6 +29,13 @@ def __init__( headers: Optional[Dict[str, Union[str, List[str]]]] = None, context: Optional[Dict[str, str]] = None, ): + """Request to a Bolt app. + + :param body: The raw request body (only plain text is supported) + :param query: The query string data in any data format. + :param headers: The request headers. + :param context: The context in this request. + """ self.raw_body = body self.query = parse_query(query) self.headers = build_normalized_headers(headers) diff --git a/slack_bolt/response/response.py b/slack_bolt/response/response.py index 6f39d78e2..1c6014516 100644 --- a/slack_bolt/response/response.py +++ b/slack_bolt/response/response.py @@ -15,6 +15,12 @@ def __init__( body: Union[str, dict] = "", headers: Optional[Dict[str, Union[str, List[str]]]] = None, ): + """The response from a Bolt app. + + :param status: HTTP status code + :param body: The response body (plain text response is supported) + :param headers: The response headers. + """ self.status: int = status self.body: str = json.dumps(body) if isinstance(body, dict) else body self.headers: Dict[str, List[str]] = {} From 9968804ad2b891a098f3addca2dc88f1ab91a6d5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 22 Sep 2020 15:55:29 +0900 Subject: [PATCH 096/865] Make single team auth Bolt JS compatible Also, this change removes authorization_test_enabled option from all authorization middleare. --- slack_bolt/app/app.py | 29 ++++--------- slack_bolt/app/async_app.py | 14 +------ .../async_multi_teams_authorization.py | 42 +++++-------------- .../async_single_team_authorization.py | 25 +++-------- .../multi_teams_authorization.py | 40 +++++------------- .../single_team_authorization.py | 27 +++--------- tests/adapter_tests/test_bottle.py | 17 ++++---- tests/adapter_tests/test_tornado.py | 17 ++++---- tests/scenario_tests/test_app.py | 16 ++++++- .../scenario_tests/test_attachment_actions.py | 6 +-- tests/scenario_tests/test_block_actions.py | 4 +- tests/scenario_tests/test_block_suggestion.py | 6 +-- tests/scenario_tests/test_dialogs.py | 38 ++++++++--------- tests/scenario_tests/test_shortcut.py | 14 +++---- tests/scenario_tests/test_slash_command.py | 2 +- tests/scenario_tests/test_view_closed.py | 6 +-- tests/scenario_tests/test_view_submission.py | 4 +- .../test_attachment_actions.py | 6 +-- .../test_block_actions.py | 4 +- .../test_block_suggestion.py | 6 +-- tests/scenario_tests_async/test_dialogs.py | 38 ++++++++--------- tests/scenario_tests_async/test_shortcut.py | 14 +++---- .../test_slash_command.py | 2 +- .../scenario_tests_async/test_view_closed.py | 6 +-- .../test_view_submission.py | 4 +- tests/slack_bolt/app/test_dev_server.py | 20 +++++++-- .../test_single_team_authorization.py | 25 ++--------- .../test_single_team_authorization.py | 20 ++------- 28 files changed, 180 insertions(+), 272 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index a0599a45a..37a66fc1e 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -77,7 +77,6 @@ def __init__( # for the OAuth flow oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, - authorization_test_enabled: bool = True, # No need to set (the value is used only in response to ssl_check requests) verification_token: Optional[str] = None, ): @@ -93,8 +92,6 @@ def __init__( :param oauth_settings: The settings related to Slack app installation flow (OAuth flow) :param oauth_flow: Manually instantiated slack_bolt.oauth.OAuthFlow. This is always prioritized over oauth_settings. - :param authorization_test_enabled: Set False if you want to skip auth.test calls - for every single incoming request from Slack (default: True) :param verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. """ @@ -129,7 +126,6 @@ def __init__( self._installation_store: Optional[InstallationStore] = installation_store self._oauth_flow: Optional[OAuthFlow] = None - self._authorization_test_enabled = authorization_test_enabled if oauth_flow: self._oauth_flow = oauth_flow if self._installation_store is None: @@ -176,27 +172,18 @@ def _init_middleware_list(self): if self._oauth_flow is None: if self._token: - if self._authorization_test_enabled: - self._middleware_list.append(SingleTeamAuthorization()) - else: - try: - auth_test_result = self._client.auth_test(token=self._token) - self._middleware_list.append( - SingleTeamAuthorization( - auth_test_result=auth_test_result, - verification_enabled=self._authorization_test_enabled, - ) - ) - except SlackApiError as err: - raise BoltError(error_auth_test_failure(err.response)) + try: + auth_test_result = self._client.auth_test(token=self._token) + self._middleware_list.append( + SingleTeamAuthorization(auth_test_result=auth_test_result) + ) + except SlackApiError as err: + raise BoltError(error_auth_test_failure(err.response)) else: raise BoltError(error_token_required()) else: self._middleware_list.append( - MultiTeamsAuthorization( - installation_store=self._installation_store, - verification_enabled=self._authorization_test_enabled, - ) + MultiTeamsAuthorization(installation_store=self._installation_store) ) self._middleware_list.append(IgnoringSelfEvents()) self._middleware_list.append(UrlVerification()) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index e89d3c10e..5f382686b 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -85,7 +85,6 @@ def __init__( # for the OAuth flow oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, - authorization_test_enabled: bool = True, # No need to set (the value is used only in response to ssl_check requests) verification_token: Optional[str] = None, ): @@ -101,8 +100,6 @@ def __init__( :param oauth_settings: The settings related to Slack app installation flow (OAuth flow) :param oauth_flow: Manually instantiated slack_bolt.oauth.async_oauth_flow.AsyncOAuthFlow. This is always prioritized over oauth_settings. - :param authorization_test_enabled: Set False if you want to skip auth.test calls - for every single incoming request from Slack (default: True) :param verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. """ @@ -134,8 +131,6 @@ def __init__( # NOTE: the token here can be None self._async_client = create_async_web_client(token) - self._authorization_test_enabled = authorization_test_enabled - self._async_installation_store: Optional[ AsyncInstallationStore ] = installation_store @@ -188,18 +183,13 @@ def _init_async_middleware_list(self): ) if self._async_oauth_flow is None: if self._token: - self._async_middleware_list.append( - AsyncSingleTeamAuthorization( - verification_enabled=self._authorization_test_enabled - ) - ) + self._async_middleware_list.append(AsyncSingleTeamAuthorization()) else: raise BoltError(error_token_required()) else: self._async_middleware_list.append( AsyncMultiTeamsAuthorization( - installation_store=self._async_installation_store, - verification_enabled=self._authorization_test_enabled, + installation_store=self._async_installation_store ) ) diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index 085162d78..2d81dca4c 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -19,19 +19,12 @@ class AsyncMultiTeamsAuthorization(AsyncAuthorization): installation_store: AsyncInstallationStore verification_enabled: bool - def __init__( - self, - installation_store: AsyncInstallationStore, - verification_enabled: bool = True, - ): + def __init__(self, installation_store: AsyncInstallationStore): """Multi-workspace authorization. :param installation_store: The module offering find/save operations of installation data. - :param verification_enabled: - Calls auth.test for every single incoming request from Slack if True (Default: True) """ self.installation_store = installation_store - self.verification_enabled = verification_enabled self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization) async def async_process( @@ -50,36 +43,23 @@ async def async_process( if bot is None: return _build_error_response() - if self.verification_enabled: - auth_result = await req.context.client.auth_test(token=bot.bot_token) - if auth_result: - req.context["authorization_result"] = AuthorizationResult( - enterprise_id=auth_result.get("enterprise_id", None), - team_id=auth_result.get("team_id", None), - bot_user_id=auth_result.get("user_id", None), - bot_id=auth_result.get("bot_id", None), - bot_token=bot.bot_token, - ) - # TODO: bot -> user token - req.context["token"] = bot.bot_token - req.context["client"] = create_async_web_client(bot.bot_token) - return await next() - else: - # Just in case - self.logger.error("auth.test API call result is unexpectedly None") - return _build_error_response() - else: + auth_result = await req.context.client.auth_test(token=bot.bot_token) + if auth_result: req.context["authorization_result"] = AuthorizationResult( - enterprise_id=bot.enterprise_id, - team_id=bot.team_id, - bot_user_id=bot.bot_user_id, - bot_id=bot.bot_id, + enterprise_id=auth_result.get("enterprise_id", None), + team_id=auth_result.get("team_id", None), + bot_user_id=auth_result.get("user_id", None), + bot_id=auth_result.get("bot_id", None), bot_token=bot.bot_token, ) # TODO: bot -> user token req.context["token"] = bot.bot_token req.context["client"] = create_async_web_client(bot.bot_token) return await next() + else: + # Just in case + self.logger.error("auth.test API call result is unexpectedly None") + return _build_error_response() except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") diff --git a/slack_bolt/middleware/authorization/async_single_team_authorization.py b/slack_bolt/middleware/authorization/async_single_team_authorization.py index 07af78f60..ec4491eb6 100644 --- a/slack_bolt/middleware/authorization/async_single_team_authorization.py +++ b/slack_bolt/middleware/authorization/async_single_team_authorization.py @@ -11,13 +11,8 @@ class AsyncSingleTeamAuthorization(AsyncAuthorization): - def __init__(self, *, verification_enabled: bool = True): - """Single-workspace authorization. - - :param verification_enabled: - Calls auth.test for every single incoming request from Slack if True (Default: True) - """ - self.verification_enabled = verification_enabled + def __init__(self): + """Single-workspace authorization.""" self.auth_result: Optional[AsyncSlackResponse] = None self.logger = get_bolt_logger(AsyncSingleTeamAuthorization) @@ -32,20 +27,12 @@ async def async_process( return await next() try: - if not self.verification_enabled: - if self.auth_result is None: - self.auth_result = await req.context.client.auth_test() - req.context["authorization_result"] = _to_authorization_result( - auth_test_result=self.auth_result, - bot_token=req.context.client.token, - request_user_id=req.context.user_id, - ) - return await next() + if self.auth_result is None: + self.auth_result = await req.context.client.auth_test() - auth_result = await req.context.client.auth_test() - if auth_result: + if self.auth_result: req.context["authorization_result"] = _to_authorization_result( - auth_test_result=auth_result, + auth_test_result=self.auth_result, bot_token=req.context.client.token, request_user_id=req.context.user_id, ) diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index cfbdda565..48cb3a692 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -15,17 +15,12 @@ class MultiTeamsAuthorization(Authorization): installation_store: InstallationStore verification_enabled: bool - def __init__( - self, installation_store: InstallationStore, verification_enabled: bool = True, - ): + def __init__(self, installation_store: InstallationStore): """Multi-workspace authorization. :param installation_store: The module offering find/save operations of installation data. - :param verification_enabled: - Calls auth.test for every single incoming request from Slack if True (Default: True) """ self.installation_store = installation_store - self.verification_enabled = verification_enabled self.logger = get_bolt_logger(MultiTeamsAuthorization) def process( @@ -40,36 +35,23 @@ def process( if bot is None: return _build_error_response() - if self.verification_enabled: - auth_result = req.context.client.auth_test(token=bot.bot_token) - if auth_result: - req.context["authorization_result"] = AuthorizationResult( - enterprise_id=auth_result.get("enterprise_id", None), - team_id=auth_result.get("team_id", None), - bot_user_id=auth_result.get("user_id", None), - bot_id=auth_result.get("bot_id", None), - bot_token=bot.bot_token, - ) - # TODO: bot -> user token - req.context["token"] = bot.bot_token - req.context["client"] = create_web_client(bot.bot_token) - return next() - else: - # Just in case - self.logger.error("auth.test API call result is unexpectedly None") - return _build_error_response() - else: + auth_result = req.context.client.auth_test(token=bot.bot_token) + if auth_result: req.context["authorization_result"] = AuthorizationResult( - enterprise_id=bot.enterprise_id, - team_id=bot.team_id, - bot_user_id=bot.bot_user_id, - bot_id=bot.bot_id, + enterprise_id=auth_result.get("enterprise_id", None), + team_id=auth_result.get("team_id", None), + bot_user_id=auth_result.get("user_id", None), + bot_id=auth_result.get("bot_id", None), bot_token=bot.bot_token, ) # TODO: bot -> user token req.context["token"] = bot.bot_token req.context["client"] = create_web_client(bot.bot_token) return next() + else: + # Just in case + self.logger.error("auth.test API call result is unexpectedly None") + return _build_error_response() except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") diff --git a/slack_bolt/middleware/authorization/single_team_authorization.py b/slack_bolt/middleware/authorization/single_team_authorization.py index be60eb123..823068b4d 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -14,20 +14,12 @@ class SingleTeamAuthorization(Authorization): - def __init__( - self, - *, - auth_test_result: Optional[SlackResponse] = None, - verification_enabled: bool = True, - ): + def __init__(self, *, auth_test_result: Optional[SlackResponse] = None): """Single-workspace authorization. :param auth_test_result: The initial `auth.test` API call result. - :param verification_enabled: - Calls auth.test for every single incoming request from Slack if True (Default: True) """ self.auth_test_result = auth_test_result - self.verification_enabled = verification_enabled self.logger = get_bolt_logger(SingleTeamAuthorization) def process( @@ -36,20 +28,13 @@ def process( if _is_no_auth_required(req): return next() - if not self.verification_enabled: - # Skip calling auth.test every time the app receives requests - req.context["authorization_result"] = _to_authorization_result( - auth_test_result=self.auth_test_result, - bot_token=req.context.client.token, - request_user_id=req.context.user_id, - ) - return next() - try: - auth_result = req.context.client.auth_test() - if auth_result: + if not self.auth_test_result: + self.auth_test_result = req.context.client.auth_test() + + if self.auth_test_result: req.context["authorization_result"] = _to_authorization_result( - auth_test_result=auth_result, + auth_test_result=self.auth_test_result, bot_token=req.context.client.token, request_user_id=req.context.user_id, ) diff --git a/tests/adapter_tests/test_bottle.py b/tests/adapter_tests/test_bottle.py index 851146734..7cb8dffa0 100644 --- a/tests/adapter_tests/test_bottle.py +++ b/tests/adapter_tests/test_bottle.py @@ -1,5 +1,6 @@ import json from time import time +from typing import Optional from urllib.parse import quote from slack_sdk.signature import SignatureVerifier @@ -16,22 +17,16 @@ 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,) -app = App(client=web_client, signing_secret=signing_secret,) -handler = SlackRequestHandler(app) -@app.event("app_mention") def event_handler(): pass -@app.shortcut("test-shortcut") def shortcut_handler(ack): ack() -@app.command("/hello-world") def command_handler(ack): ack() @@ -42,16 +37,24 @@ def command_handler(ack): @post("/slack/events") def slack_events(): - return handler.handle(request, response) + return TestBottle.handler.handle(request, response) class TestBottle: signature_verifier = SignatureVerifier(signing_secret) + handler: Optional[SlackRequestHandler] = None def setup_method(self): self.old_os_env = remove_os_env_temporarily() setup_mock_web_api_server(self) + web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + app = App(client=web_client, signing_secret=signing_secret,) + TestBottle.handler = SlackRequestHandler(app) + app.event("app_mention")(event_handler) + app.shortcut("test-shortcut")(shortcut_handler) + app.command("/hello-world")(command_handler) + def teardown_method(self): cleanup_mock_web_api_server(self) restore_os_env(self.old_os_env) diff --git a/tests/adapter_tests/test_tornado.py b/tests/adapter_tests/test_tornado.py index c5331b845..468690fb6 100644 --- a/tests/adapter_tests/test_tornado.py +++ b/tests/adapter_tests/test_tornado.py @@ -20,22 +20,16 @@ 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,) -app = App(client=web_client, signing_secret=signing_secret) - -@app.event("app_mention") def event_handler(): pass -@app.shortcut("test-shortcut") def shortcut_handler(ack): ack() -@app.command("/hello-world") def command_handler(ack): ack() @@ -44,17 +38,24 @@ class TestTornado(AsyncHTTPTestCase): signature_verifier = SignatureVerifier(signing_secret) def setUp(self): - AsyncHTTPTestCase.setUp(self) self.old_os_env = remove_os_env_temporarily() setup_mock_web_api_server(self) + web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + self.app = App(client=web_client, signing_secret=signing_secret,) + self.app.event("app_mention")(event_handler) + self.app.shortcut("test-shortcut")(shortcut_handler) + self.app.command("/hello-world")(command_handler) + + AsyncHTTPTestCase.setUp(self) + def tearDown(self): AsyncHTTPTestCase.tearDown(self) cleanup_mock_web_api_server(self) restore_os_env(self.old_os_env) def get_app(self): - return Application([("/slack/events", SlackEventsHandler, dict(app=app))]) + return Application([("/slack/events", SlackEventsHandler, dict(app=self.app))]) def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index bf08dcb0e..4bfef046f 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -1,4 +1,5 @@ import pytest +from slack_sdk import WebClient from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore @@ -6,14 +7,25 @@ from slack_bolt.error import BoltError from slack_bolt.oauth import OAuthFlow from slack_bolt.oauth.oauth_settings import OAuthSettings +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 TestApp: + 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_signing_secret_absence(self): @@ -26,7 +38,7 @@ def simple_listener(self, ack): ack() def test_listener_registration_error(self): - app = App(signing_secret="valid", token="xoxb-xxx") + app = App(signing_secret="valid", client=self.web_client) with pytest.raises(BoltError): app.action({"type": "invalid_type", "action_id": "a"})(self.simple_listener) @@ -35,7 +47,7 @@ def test_listener_registration_error(self): # -------------------------- def test_valid_single_auth(self): - app = App(signing_secret="valid", token="xoxb-xxx") + app = App(signing_secret="valid", client=self.web_client) assert app != None def test_token_absence(self): diff --git a/tests/scenario_tests/test_attachment_actions.py b/tests/scenario_tests/test_attachment_actions.py index 490c330cc..3596dbcbd 100644 --- a/tests/scenario_tests/test_attachment_actions.py +++ b/tests/scenario_tests/test_attachment_actions.py @@ -105,7 +105,7 @@ def test_failure_without_type(self): app.action("unknown")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_failure(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -119,7 +119,7 @@ def test_failure(self): ) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_failure_2(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -131,7 +131,7 @@ def test_failure_2(self): app.attachment_action("unknown")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 # https://api.slack.com/legacy/interactive-messages diff --git a/tests/scenario_tests/test_block_actions.py b/tests/scenario_tests/test_block_actions.py index f6bd93ee0..9aaefa7fe 100644 --- a/tests/scenario_tests/test_block_actions.py +++ b/tests/scenario_tests/test_block_actions.py @@ -119,7 +119,7 @@ def test_failure(self): app.action("aaa")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_failure_2(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -131,7 +131,7 @@ def test_failure_2(self): app.block_action("aaa")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 body = { diff --git a/tests/scenario_tests/test_block_suggestion.py b/tests/scenario_tests/test_block_suggestion.py index 86c5417f2..341039377 100644 --- a/tests/scenario_tests/test_block_suggestion.py +++ b/tests/scenario_tests/test_block_suggestion.py @@ -131,7 +131,7 @@ def test_failure(self): app.options("mes_a")(show_multi_options) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_failure_2(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -143,7 +143,7 @@ def test_failure_2(self): app.block_suggestion("mes_a")(show_multi_options) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_failure_multi(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -155,7 +155,7 @@ def test_failure_multi(self): app.options("es_a")(show_options) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 body = { diff --git a/tests/scenario_tests/test_dialogs.py b/tests/scenario_tests/test_dialogs.py index df8e31d22..1d6b20c8f 100644 --- a/tests/scenario_tests/test_dialogs.py +++ b/tests/scenario_tests/test_dialogs.py @@ -65,13 +65,13 @@ def test_success_without_type(self): response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert self.mock_received_requests["/auth.test"] == 1 def test_success(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -96,13 +96,13 @@ def test_success(self): response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert self.mock_received_requests["/auth.test"] == 1 def test_success_2(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -121,13 +121,13 @@ def test_success_2(self): response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert self.mock_received_requests["/auth.test"] == 1 def test_process_before_response(self): app = App( @@ -156,13 +156,13 @@ def test_process_before_response(self): response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert self.mock_received_requests["/auth.test"] == 1 def test_process_before_response_2(self): app = App( @@ -185,13 +185,13 @@ def test_process_before_response_2(self): response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert self.mock_received_requests["/auth.test"] == 1 def test_suggestion_failure_without_type(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -203,7 +203,7 @@ def test_suggestion_failure_without_type(self): app.options("dialog-callback-iddddd")(handle_suggestion) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_suggestion_failure(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -215,7 +215,7 @@ def test_suggestion_failure(self): app.dialog_suggestion("dialog-callback-iddddd")(handle_suggestion) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_suggestion_failure_2(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -229,7 +229,7 @@ def test_suggestion_failure_2(self): )(handle_suggestion) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_submission_failure_without_type(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -241,7 +241,7 @@ def test_submission_failure_without_type(self): app.action("dialog-callback-iddddd")(handle_submission) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_submission_failure(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -253,7 +253,7 @@ def test_submission_failure(self): app.dialog_submission("dialog-callback-iddddd")(handle_submission) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_submission_failure_2(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -267,7 +267,7 @@ def test_submission_failure_2(self): )(handle_submission) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_cancellation_failure_without_type(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -279,7 +279,7 @@ def test_cancellation_failure_without_type(self): app.action("dialog-callback-iddddd")(handle_cancellation) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_cancellation_failure(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -291,7 +291,7 @@ def test_cancellation_failure(self): app.dialog_cancellation("dialog-callback-iddddd")(handle_cancellation) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_cancellation_failure_2(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -305,7 +305,7 @@ def test_cancellation_failure_2(self): )(handle_cancellation) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 suggestion_body = { diff --git a/tests/scenario_tests/test_shortcut.py b/tests/scenario_tests/test_shortcut.py index 32939eac3..b646d77a3 100644 --- a/tests/scenario_tests/test_shortcut.py +++ b/tests/scenario_tests/test_shortcut.py @@ -62,7 +62,7 @@ def test_success_both_global_and_message(self): request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_success_global(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -85,7 +85,7 @@ def test_success_global_2(self): request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_success_message(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -101,7 +101,7 @@ def test_success_message(self): request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_success_message_2(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -115,7 +115,7 @@ def test_success_message_2(self): request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_process_before_response_global(self): app = App( @@ -140,7 +140,7 @@ def test_failure(self): app.shortcut("another-one")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_failure_2(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -152,12 +152,12 @@ def test_failure_2(self): app.global_shortcut("another-one")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 3 + assert self.mock_received_requests["/auth.test"] == 1 global_shortcut_body = { diff --git a/tests/scenario_tests/test_slash_command.py b/tests/scenario_tests/test_slash_command.py index d19326772..acfac442b 100644 --- a/tests/scenario_tests/test_slash_command.py +++ b/tests/scenario_tests/test_slash_command.py @@ -80,7 +80,7 @@ def test_failure(self): app.command("/another-one")(commander) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 slash_command_body = ( diff --git a/tests/scenario_tests/test_view_closed.py b/tests/scenario_tests/test_view_closed.py index 6d67cdf57..55c34ab4c 100644 --- a/tests/scenario_tests/test_view_closed.py +++ b/tests/scenario_tests/test_view_closed.py @@ -92,7 +92,7 @@ def test_failure(self): app.view("view-idddd")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_failure(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -104,7 +104,7 @@ def test_failure(self): app.view({"type": "view_closed", "callback_id": "view-idddd"})(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_failure_2(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -116,7 +116,7 @@ def test_failure_2(self): app.view_closed("view-idddd")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 body = { diff --git a/tests/scenario_tests/test_view_submission.py b/tests/scenario_tests/test_view_submission.py index 9c39fe772..c0ecbfa2e 100644 --- a/tests/scenario_tests/test_view_submission.py +++ b/tests/scenario_tests/test_view_submission.py @@ -92,7 +92,7 @@ def test_failure(self): app.view("view-idddd")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 def test_failure_2(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -104,7 +104,7 @@ def test_failure_2(self): app.view_submission("view-idddd")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 body = { diff --git a/tests/scenario_tests_async/test_attachment_actions.py b/tests/scenario_tests_async/test_attachment_actions.py index 564219203..34110bd38 100644 --- a/tests/scenario_tests_async/test_attachment_actions.py +++ b/tests/scenario_tests_async/test_attachment_actions.py @@ -131,7 +131,7 @@ async def test_failure_without_type(self): app.action("unknown")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_failure(self): @@ -146,7 +146,7 @@ async def test_failure(self): ) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_failure_2(self): @@ -159,7 +159,7 @@ async def test_failure_2(self): app.attachment_action("unknown")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 # https://api.slack.com/legacy/interactive-messages diff --git a/tests/scenario_tests_async/test_block_actions.py b/tests/scenario_tests_async/test_block_actions.py index c6568c8ec..34e7da238 100644 --- a/tests/scenario_tests_async/test_block_actions.py +++ b/tests/scenario_tests_async/test_block_actions.py @@ -147,7 +147,7 @@ async def test_failure(self): app.action("aaa")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_failure_2(self): @@ -160,7 +160,7 @@ async def test_failure_2(self): app.block_action("aaa")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 body = { diff --git a/tests/scenario_tests_async/test_block_suggestion.py b/tests/scenario_tests_async/test_block_suggestion.py index ceb546e0f..b6f7aa80c 100644 --- a/tests/scenario_tests_async/test_block_suggestion.py +++ b/tests/scenario_tests_async/test_block_suggestion.py @@ -144,7 +144,7 @@ async def test_failure(self): app.options("mes_a")(show_multi_options) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_failure_2(self): @@ -157,7 +157,7 @@ async def test_failure_2(self): app.block_suggestion("mes_a")(show_multi_options) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_failure_multi(self): @@ -170,7 +170,7 @@ async def test_failure_multi(self): app.options("es_a")(show_options) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 body = { diff --git a/tests/scenario_tests_async/test_dialogs.py b/tests/scenario_tests_async/test_dialogs.py index b5eb31f9b..2f99d468f 100644 --- a/tests/scenario_tests_async/test_dialogs.py +++ b/tests/scenario_tests_async/test_dialogs.py @@ -73,13 +73,13 @@ async def test_success_without_type(self): response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_success(self): @@ -105,13 +105,13 @@ async def test_success(self): response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_success_2(self): @@ -131,13 +131,13 @@ async def test_success_2(self): response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_process_before_response(self): @@ -167,13 +167,13 @@ async def test_process_before_response(self): response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_process_before_response_2(self): @@ -197,13 +197,13 @@ async def test_process_before_response_2(self): response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_suggestion_failure_without_type(self): @@ -216,7 +216,7 @@ async def test_suggestion_failure_without_type(self): app.options("dialog-callback-iddddd")(handle_suggestion) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_suggestion_failure(self): @@ -229,7 +229,7 @@ async def test_suggestion_failure(self): app.dialog_suggestion("dialog-callback-iddddd")(handle_suggestion) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_suggestion_failure_2(self): @@ -244,7 +244,7 @@ async def test_suggestion_failure_2(self): )(handle_suggestion) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_submission_failure_without_type(self): @@ -257,7 +257,7 @@ async def test_submission_failure_without_type(self): app.action("dialog-callback-iddddd")(handle_submission) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_submission_failure(self): @@ -270,7 +270,7 @@ async def test_submission_failure(self): app.dialog_submission("dialog-callback-iddddd")(handle_submission) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_submission_failure_2(self): @@ -285,7 +285,7 @@ async def test_submission_failure_2(self): )(handle_submission) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_cancellation_failure_without_type(self): @@ -298,7 +298,7 @@ async def test_cancellation_failure_without_type(self): app.action("dialog-callback-iddddd")(handle_cancellation) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_cancellation_failure(self): @@ -311,7 +311,7 @@ async def test_cancellation_failure(self): app.dialog_cancellation("dialog-callback-iddddd")(handle_cancellation) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_cancellation_failure_2(self): @@ -326,7 +326,7 @@ async def test_cancellation_failure_2(self): )(handle_cancellation) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 suggestion_body = { diff --git a/tests/scenario_tests_async/test_shortcut.py b/tests/scenario_tests_async/test_shortcut.py index dd4f97784..aac7cde89 100644 --- a/tests/scenario_tests_async/test_shortcut.py +++ b/tests/scenario_tests_async/test_shortcut.py @@ -70,7 +70,7 @@ async def test_success_both_global_and_message(self): request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_success_global(self): @@ -95,7 +95,7 @@ async def test_success_global_2(self): request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_success_message(self): @@ -112,7 +112,7 @@ async def test_success_message(self): request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_success_message_2(self): @@ -127,7 +127,7 @@ async def test_success_message_2(self): request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_process_before_response_global(self): @@ -154,7 +154,7 @@ async def test_failure(self): app.shortcut("another-one")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_failure_2(self): @@ -167,12 +167,12 @@ async def test_failure_2(self): app.global_shortcut("another-one")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 3 + assert self.mock_received_requests["/auth.test"] == 1 global_shortcut_body = { diff --git a/tests/scenario_tests_async/test_slash_command.py b/tests/scenario_tests_async/test_slash_command.py index e161b6ea2..84d8a44eb 100644 --- a/tests/scenario_tests_async/test_slash_command.py +++ b/tests/scenario_tests_async/test_slash_command.py @@ -90,7 +90,7 @@ async def test_failure(self): app.command("/another-one")(commander) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 slash_command_body = ( diff --git a/tests/scenario_tests_async/test_view_closed.py b/tests/scenario_tests_async/test_view_closed.py index 4cc119a33..020301284 100644 --- a/tests/scenario_tests_async/test_view_closed.py +++ b/tests/scenario_tests_async/test_view_closed.py @@ -103,7 +103,7 @@ async def test_failure(self): app.view("view-idddd")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_failure(self): @@ -116,7 +116,7 @@ async def test_failure(self): app.view({"type": "view_closed", "callback_id": "view-idddd"})(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_failure_2(self): @@ -129,7 +129,7 @@ async def test_failure_2(self): app.view_closed("view-idddd")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 body = { diff --git a/tests/scenario_tests_async/test_view_submission.py b/tests/scenario_tests_async/test_view_submission.py index 97653c249..4c5fa672d 100644 --- a/tests/scenario_tests_async/test_view_submission.py +++ b/tests/scenario_tests_async/test_view_submission.py @@ -103,7 +103,7 @@ async def test_failure(self): app.view("view-idddd")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_failure_2(self): @@ -116,7 +116,7 @@ async def test_failure_2(self): app.view_submission("view-idddd")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert self.mock_received_requests["/auth.test"] == 1 body = { diff --git a/tests/slack_bolt/app/test_dev_server.py b/tests/slack_bolt/app/test_dev_server.py index 4553999e0..7e09c6102 100644 --- a/tests/slack_bolt/app/test_dev_server.py +++ b/tests/slack_bolt/app/test_dev_server.py @@ -1,17 +1,31 @@ +from slack_sdk import WebClient + from slack_bolt.app.app import SlackAppDevelopmentServer, App +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 TestDevServer: + 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): - pass + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) def teardown_method(self): - pass + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) def test_instance(self): server = SlackAppDevelopmentServer( port=3001, path="/slack/events", - app=App(signing_secret="valid", token="xoxb-valid",), + app=App(signing_secret=self.signing_secret, client=self.web_client), ) assert server is not None diff --git a/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py index 4b1965c64..d506709a0 100644 --- a/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py +++ b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py @@ -22,10 +22,8 @@ def setup_method(self): def teardown_method(self): cleanup_mock_web_api_server(self) - def test_normal_pattern(self): - authorization = SingleTeamAuthorization( - auth_test_result={}, verification_enabled=True, - ) + def test_success_pattern(self): + authorization = SingleTeamAuthorization(auth_test_result={}) req = BoltRequest(body="payload={}", headers={}) req.context["client"] = WebClient( base_url=self.mock_api_server_base_url, token="xoxb-valid" @@ -38,9 +36,7 @@ def test_normal_pattern(self): assert resp.body == "" def test_failure_pattern(self): - authorization = SingleTeamAuthorization( - auth_test_result={}, verification_enabled=True, - ) + authorization = SingleTeamAuthorization(auth_test_result={}) req = BoltRequest(body="payload={}", headers={}) req.context["client"] = WebClient( base_url=self.mock_api_server_base_url, token="dummy" @@ -51,18 +47,3 @@ def test_failure_pattern(self): assert resp.status == 200 assert resp.body == ":x: Please install this app into the workspace :bow:" - - def test_normal_pattern_disabled(self): - authorization = SingleTeamAuthorization( - auth_test_result={"ok": True}, verification_enabled=False, - ) - req = BoltRequest(body="payload={}", headers={}) - req.context["client"] = WebClient( - base_url=self.mock_api_server_base_url, token="xoxb-valid" - ) - resp = BoltResponse(status=404) - - resp = authorization.process(req=req, resp=resp, next=next) - - assert resp.status == 200 - assert resp.body == "" diff --git a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py index 37e901fb0..228d45291 100644 --- a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py +++ b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py @@ -35,8 +35,8 @@ def event_loop(self): restore_os_env(old_os_env) @pytest.mark.asyncio - async def test_normal_pattern(self): - authorization = AsyncSingleTeamAuthorization(verification_enabled=True,) + async def test_success_pattern(self): + authorization = AsyncSingleTeamAuthorization() req = AsyncBoltRequest(body="payload={}", headers={}) req.context["client"] = AsyncWebClient( base_url=self.mock_api_server_base_url, token="xoxb-valid" @@ -50,7 +50,7 @@ async def test_normal_pattern(self): @pytest.mark.asyncio async def test_failure_pattern(self): - authorization = AsyncSingleTeamAuthorization(verification_enabled=True,) + authorization = AsyncSingleTeamAuthorization() req = AsyncBoltRequest(body="payload={}", headers={}) req.context["client"] = AsyncWebClient( base_url=self.mock_api_server_base_url, token="dummy" @@ -61,17 +61,3 @@ async def test_failure_pattern(self): assert resp.status == 200 assert resp.body == ":x: Please install this app into the workspace :bow:" - - @pytest.mark.asyncio - async def test_normal_pattern_disabled(self): - authorization = AsyncSingleTeamAuthorization(verification_enabled=False,) - req = AsyncBoltRequest(body="payload={}", headers={}) - req.context["client"] = AsyncWebClient( - base_url=self.mock_api_server_base_url, token="xoxb-valid" - ) - resp = BoltResponse(status=404) - - resp = await authorization.async_process(req=req, resp=resp, next=next) - - assert resp.status == 200 - assert resp.body == "" From 8a6cf2ee469ee61c59517420bdd304adf27a7d67 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 22 Sep 2020 18:36:48 +0900 Subject: [PATCH 097/865] Fix #98 by adding authorize function support --- samples/app_authorize.py | 49 +++++++ samples/async_app_authorize.py | 51 +++++++ slack_bolt/app/app.py | 68 +++++++--- slack_bolt/app/async_app.py | 43 ++++-- slack_bolt/auth/__init__.py | 1 - slack_bolt/auth/result.py | 44 ------ slack_bolt/authorization/__init__.py | 1 + slack_bolt/authorization/async_authorize.py | 126 ++++++++++++++++++ .../authorization/async_authorize_args.py | 37 +++++ .../authorization/authorization_result.py | 64 +++++++++ slack_bolt/authorization/authorize.py | 124 +++++++++++++++++ slack_bolt/authorization/authorize_args.py | 37 +++++ slack_bolt/context/base_context.py | 2 +- slack_bolt/logger/messages.py | 6 +- .../async_multi_teams_authorization.py | 42 +++--- .../middleware/authorization/internals.py | 2 +- .../multi_teams_authorization.py | 43 +++--- .../ignoring_self_events.py | 2 +- slack_bolt/oauth/async_oauth_flow.py | 2 + slack_bolt/oauth/async_oauth_settings.py | 15 +++ slack_bolt/oauth/oauth_flow.py | 2 + slack_bolt/oauth/oauth_settings.py | 12 ++ 22 files changed, 644 insertions(+), 129 deletions(-) create mode 100644 samples/app_authorize.py create mode 100644 samples/async_app_authorize.py delete mode 100644 slack_bolt/auth/__init__.py delete mode 100644 slack_bolt/auth/result.py create mode 100644 slack_bolt/authorization/__init__.py create mode 100644 slack_bolt/authorization/async_authorize.py create mode 100644 slack_bolt/authorization/async_authorize_args.py create mode 100644 slack_bolt/authorization/authorization_result.py create mode 100644 slack_bolt/authorization/authorize.py create mode 100644 slack_bolt/authorization/authorize_args.py diff --git a/samples/app_authorize.py b/samples/app_authorize.py new file mode 100644 index 000000000..8048a0ad7 --- /dev/null +++ b/samples/app_authorize.py @@ -0,0 +1,49 @@ +# ------------------------------------------------ +# 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.authorization import AuthorizationResult +from slack_sdk import WebClient + +def authorize(enterprise_id, team_id, user_id, client: WebClient, logger): + logger.info(f"{enterprise_id},{team_id},{user_id}") + # You can implement your own logic here + token = os.environ["MY_TOKEN"] + return AuthorizationResult.from_auth_test_response( + auth_test_response=client.auth_test(token=token), + bot_token=token, + ) + +app = App( + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + authorize=authorize +) + +@app.command("/hello-bolt-python") +def hello_command(ack, body): + user_id = body["user_id"] + ack(f"Hi <@{user_id}>!") + + +@app.event("app_mention") +def event_test(body, say, logger): + logger.info(body) + say("What's up?") + + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export MY_TOKEN=xoxb-*** +# python app.py diff --git a/samples/async_app_authorize.py b/samples/async_app_authorize.py new file mode 100644 index 000000000..515659118 --- /dev/null +++ b/samples/async_app_authorize.py @@ -0,0 +1,51 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging + +logging.basicConfig(level=logging.DEBUG) + +import os +from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt.authorization import AuthorizationResult +from slack_bolt.async_app import AsyncApp + +async def authorize(enterprise_id, team_id, user_id, client: AsyncWebClient, logger): + logger.info(f"{enterprise_id},{team_id},{user_id}") + # You can implement your own logic here + token = os.environ["MY_TOKEN"] + return AuthorizationResult.from_auth_test_response( + auth_test_response=await client.auth_test(token=token), + bot_token=token, + ) + + +app = AsyncApp( + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + authorize=authorize +) + +@app.event("app_mention") +async def event_test(body, say, logger): + logger.info(body) + await say("What's up?") + + +@app.command("/hello-bolt-python") +# or app.command(re.compile(r"/hello-.+"))(test_command) +async def command(ack, body): + user_id = body["user_id"] + await ack(f"Hi <@{user_id}>!") + + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export MY_TOKEN=xoxb-*** +# python app.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 37a66fc1e..2b8d288d3 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -11,11 +11,30 @@ from slack_sdk.oauth.installation_store import InstallationStore from slack_sdk.web import WebClient +from slack_bolt.authorization import AuthorizationResult +from slack_bolt.authorization.authorize import ( + Authorize, + InstallationStoreAuthorize, + CallableAuthorize, +) from slack_bolt.error import BoltError +from slack_bolt.lazy_listener.runner import LazyListenerRunner +from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner +from slack_bolt.listener.custom_listener import CustomListener +from slack_bolt.listener.listener import Listener +from slack_bolt.listener.listener_error_handler import ( + ListenerErrorHandler, + DefaultListenerErrorHandler, + CustomListenerErrorHandler, +) +from slack_bolt.listener_matcher import CustomListenerMatcher +from slack_bolt.listener_matcher import builtins as builtin_matchers +from slack_bolt.listener_matcher.listener_matcher import ListenerMatcher +from slack_bolt.logger import get_bolt_app_logger, get_bolt_logger from slack_bolt.logger.messages import ( error_signing_secret_not_found, warning_client_prioritized_and_token_skipped, - warning_installation_store_prioritized_and_token_skipped, + warning_token_skipped, error_auth_test_failure, error_token_required, warning_unhandled_request, @@ -28,19 +47,6 @@ error_unexpected_listener_middleware, error_client_invalid_type, ) -from slack_bolt.lazy_listener.runner import LazyListenerRunner -from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner -from slack_bolt.listener.custom_listener import CustomListener -from slack_bolt.listener.listener import Listener -from slack_bolt.listener.listener_error_handler import ( - ListenerErrorHandler, - DefaultListenerErrorHandler, - CustomListenerErrorHandler, -) -from slack_bolt.listener_matcher import CustomListenerMatcher -from slack_bolt.listener_matcher import builtins as builtin_matchers -from slack_bolt.listener_matcher.listener_matcher import ListenerMatcher -from slack_bolt.logger import get_bolt_app_logger, get_bolt_logger from slack_bolt.middleware import ( Middleware, SslCheck, @@ -73,6 +79,7 @@ def __init__( token: Optional[str] = None, client: Optional[WebClient] = None, # for multi-workspace apps + authorize: Optional[Callable[..., AuthorizationResult]] = None, installation_store: Optional[InstallationStore] = None, # for the OAuth flow oauth_settings: Optional[OAuthSettings] = None, @@ -88,6 +95,8 @@ def __init__( :param signing_secret: The Signing Secret value used for verifying requests from Slack. :param token: The bot access token required only for single-workspace app. :param client: The singleton slack_sdk.WebClient instance for this app. + :param authorize: The function to authorize an incoming request from Slack + by checking if there is a team/user in the installation data. :param installation_store: The module offering save/find operations of installation data :param oauth_settings: The settings related to Slack app installation flow (OAuth flow) :param oauth_flow: Manually instantiated slack_bolt.oauth.OAuthFlow. @@ -123,7 +132,18 @@ def __init__( else: self._client = create_web_client(token) # NOTE: the token here can be None + self._authorize: Optional[Authorize] = None + if authorize is not None: + self._authorize = CallableAuthorize( + logger=self._framework_logger, func=authorize + ) + self._installation_store: Optional[InstallationStore] = installation_store + if self._installation_store is not None and self._authorize is None: + self._authorize = InstallationStoreAuthorize( + installation_store=self._installation_store, + logger=self._framework_logger, + ) self._oauth_flow: Optional[OAuthFlow] = None if oauth_flow: @@ -132,6 +152,8 @@ def __init__( self._installation_store = self._oauth_flow.settings.installation_store if self._oauth_flow._client is None: self._oauth_flow._client = self._client + if self._authorize is None: + self._authorize = self._oauth_flow.settings.authorize elif oauth_settings is not None: if self._installation_store: # Consistently use a single installation_store @@ -140,12 +162,14 @@ def __init__( self._oauth_flow = OAuthFlow( client=self.client, logger=self.logger, settings=oauth_settings ) + if self._authorize is None: + self._authorize = self._oauth_flow.settings.authorize - if self._installation_store is not None and self._token is not None: + if ( + self._installation_store is not None or self._authorize is not None + ) and self._token is not None: self._token = None - self._framework_logger.warning( - warning_installation_store_prioritized_and_token_skipped() - ) + self._framework_logger.warning(warning_token_skipped()) self._middleware_list: List[Union[Callable, Middleware]] = [] self._listeners: List[Listener] = [] @@ -171,7 +195,7 @@ def _init_middleware_list(self): self._middleware_list.append(RequestVerification(self._signing_secret)) if self._oauth_flow is None: - if self._token: + if self._token is not None: try: auth_test_result = self._client.auth_test(token=self._token) self._middleware_list.append( @@ -179,11 +203,15 @@ def _init_middleware_list(self): ) except SlackApiError as err: raise BoltError(error_auth_test_failure(err.response)) + elif self._authorize is not None: + self._middleware_list.append( + MultiTeamsAuthorization(authorize=self._authorize) + ) else: raise BoltError(error_token_required()) else: self._middleware_list.append( - MultiTeamsAuthorization(installation_store=self._installation_store) + MultiTeamsAuthorization(authorize=self._authorize) ) self._middleware_list.append(IgnoringSelfEvents()) self._middleware_list.append(UrlVerification()) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 5f382686b..5d1fffd8c 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -11,12 +11,18 @@ ) from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt.authorization import AuthorizationResult +from slack_bolt.authorization.async_authorize import ( + AsyncAuthorize, + AsyncCallableAuthorize, + AsyncInstallationStoreAuthorize, +) from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.error import BoltError from slack_bolt.logger.messages import ( error_signing_secret_not_found, warning_client_prioritized_and_token_skipped, - warning_installation_store_prioritized_and_token_skipped, + warning_token_skipped, error_token_required, warning_unhandled_request, debug_checking_listener, @@ -82,6 +88,7 @@ def __init__( client: Optional[AsyncWebClient] = None, # for multi-workspace apps installation_store: Optional[AsyncInstallationStore] = None, + authorize: Optional[Callable[..., Awaitable[AuthorizationResult]]] = None, # for the OAuth flow oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, @@ -97,6 +104,8 @@ def __init__( :param token: The bot access token required only for single-workspace app. :param client: The singleton slack_sdk.web.async_client.AsyncWebClient instance for this app. :param installation_store: The module offering save/find operations of installation data + :param authorize: The function to authorize an incoming request from Slack + by checking if there is a team/user in the installation data. :param oauth_settings: The settings related to Slack app installation flow (OAuth flow) :param oauth_flow: Manually instantiated slack_bolt.oauth.async_oauth_flow.AsyncOAuthFlow. This is always prioritized over oauth_settings. @@ -131,9 +140,20 @@ def __init__( # NOTE: the token here can be None self._async_client = create_async_web_client(token) + self._async_authorize: Optional[AsyncAuthorize] = None + if authorize is not None: + self._async_authorize = AsyncCallableAuthorize( + logger=self._framework_logger, func=authorize + ) + self._async_installation_store: Optional[ AsyncInstallationStore ] = installation_store + if self._async_installation_store is not None and self._async_authorize is None: + self._async_authorize = AsyncInstallationStoreAuthorize( + installation_store=self._async_installation_store, + logger=self._framework_logger, + ) self._async_oauth_flow: Optional[AsyncOAuthFlow] = None if oauth_flow: @@ -144,6 +164,8 @@ def __init__( ) if self._async_oauth_flow._async_client is None: self._async_oauth_flow._async_client = self._async_client + if self._async_authorize is None: + self._async_authorize = self._async_oauth_flow.settings.authorize elif oauth_settings is not None: if self._async_installation_store: # Consistently use a single installation_store @@ -152,12 +174,15 @@ def __init__( self._async_oauth_flow = AsyncOAuthFlow( client=self._async_client, logger=self.logger, settings=oauth_settings ) + if self._async_authorize is None: + self._async_authorize = self._async_oauth_flow.settings.authorize - if self._async_installation_store is not None and self._token is not None: + if ( + self._async_installation_store is not None + or self._async_authorize is not None + ) and self._token is not None: self._token = None - self._framework_logger.warning( - warning_installation_store_prioritized_and_token_skipped() - ) + self._framework_logger.warning(warning_token_skipped()) self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] self._async_listeners: List[AsyncListener] = [] @@ -184,13 +209,15 @@ def _init_async_middleware_list(self): if self._async_oauth_flow is None: if self._token: self._async_middleware_list.append(AsyncSingleTeamAuthorization()) + elif self._async_authorize is not None: + self._async_middleware_list.append( + AsyncMultiTeamsAuthorization(authorize=self._async_authorize) + ) else: raise BoltError(error_token_required()) else: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization( - installation_store=self._async_installation_store - ) + AsyncMultiTeamsAuthorization(authorize=self._async_authorize) ) self._async_middleware_list.append(AsyncIgnoringSelfEvents()) diff --git a/slack_bolt/auth/__init__.py b/slack_bolt/auth/__init__.py deleted file mode 100644 index c08967fc1..000000000 --- a/slack_bolt/auth/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .result import AuthorizationResult diff --git a/slack_bolt/auth/result.py b/slack_bolt/auth/result.py deleted file mode 100644 index 2aca1a0d6..000000000 --- a/slack_bolt/auth/result.py +++ /dev/null @@ -1,44 +0,0 @@ -from typing import Optional - - -class AuthorizationResult(dict): - enterprise_id: Optional[str] - team_id: Optional[str] - bot_id: str - bot_user_id: str - bot_token: str - user_id: Optional[str] - user_token: Optional[str] - - def __init__( - self, - *, - enterprise_id: Optional[str], - team_id: Optional[str], - # bot - bot_user_id: str, - bot_id: str, - bot_token: str, - # user - user_id: Optional[str] = None, - user_token: Optional[str] = None, - ): - """The `auth.test` API result for an incoming request. - - :param enterprise_id: Organization ID (Enterprise Grid) - :param team_id: Workspace ID - :param bot_user_id: Bot user's User ID - :param bot_id: Bot ID - :param bot_token: Bot user access token starting with xoxb- - :param user_id: The request user ID - :param user_token: User access token starting with xoxp- - """ - self.enterprise_id: Optional[str] = enterprise_id - self.team_id: Optional[str] = team_id - # bot - self.bot_user_id: str = bot_user_id - self.bot_id: str = bot_id - self.bot_token: str = bot_token - # user - self.user_id: Optional[str] = user_id - self.user_token: Optional[str] = user_token diff --git a/slack_bolt/authorization/__init__.py b/slack_bolt/authorization/__init__.py new file mode 100644 index 000000000..46cb6b30c --- /dev/null +++ b/slack_bolt/authorization/__init__.py @@ -0,0 +1 @@ +from .authorization_result import AuthorizationResult diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py new file mode 100644 index 000000000..a7717a2ef --- /dev/null +++ b/slack_bolt/authorization/async_authorize.py @@ -0,0 +1,126 @@ +import inspect +from logging import Logger +from typing import Optional, Callable, Awaitable, Dict, Any + +from slack_sdk.errors import SlackApiError +from slack_sdk.oauth.installation_store import Bot +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) + +from slack_bolt.authorization.async_authorize_args import AsyncAuthorizeArgs +from slack_bolt.authorization import AuthorizationResult +from slack_bolt.context.async_context import AsyncBoltContext + + +class AsyncAuthorize: + def __init__(self): + pass + + async def __call__( + self, + *, + context: AsyncBoltContext, + enterprise_id: Optional[str], + team_id: str, + user_id: Optional[str], + ) -> Optional[AuthorizationResult]: + raise NotImplementedError() + + +class AsyncCallableAuthorize(AsyncAuthorize): + def __init__( + self, *, logger: Logger, func: Callable[..., Awaitable[AuthorizationResult]] + ): + self.logger = logger + self.func = func + self.arg_names = inspect.getfullargspec(func).args + + async def __call__( + self, + *, + context: AsyncBoltContext, + enterprise_id: Optional[str], + team_id: str, + user_id: Optional[str], + ) -> Optional[AuthorizationResult]: + try: + all_available_args = { + "args": AsyncAuthorizeArgs( + context=context, + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + ), + "logger": context.logger, + "client": context.client, + "context": context, + "enterprise_id": enterprise_id, + "team_id": team_id, + "user_id": user_id, + } + kwargs: Dict[str, Any] = { # type: ignore + k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore + } + found_arg_names = kwargs.keys() + for name in self.arg_names: + if name not in found_arg_names: + self.logger.warning(f"{name} is not a valid argument") + kwargs[name] = None + + auth_result: Optional[AuthorizationResult] = await self.func(**kwargs) + if auth_result is None: + return auth_result + + if isinstance(auth_result, AuthorizationResult): + return auth_result + else: + raise ValueError( + f"Unexpected returned value from authorize function (type: {type(auth_result)})" + ) + except SlackApiError as err: + self.logger.debug( + f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} " + f"is no longer valid. (response: {err.response})" + ) + return None + + +class AsyncInstallationStoreAuthorize(AsyncAuthorize): + def __init__( + self, *, logger: Logger, installation_store: AsyncInstallationStore, + ): + self.logger = logger + self.installation_store = installation_store + + async def __call__( + self, + *, + context: AsyncBoltContext, + enterprise_id: Optional[str], + team_id: str, + user_id: Optional[str], + ) -> Optional[AuthorizationResult]: + bot: Optional[Bot] = await self.installation_store.async_find_bot( + enterprise_id=enterprise_id, team_id=team_id, + ) + if bot is None: + self.logger.debug( + f"No installation data found " + f"for enterprise_id: {enterprise_id} team_id: {team_id}" + ) + return None + + try: + auth_result = await context.client.auth_test(token=bot.bot_token) + return AuthorizationResult.from_auth_test_response( + auth_test_response=auth_result, + bot_token=bot.bot_token, + user_token=None, # Not yet supported + ) + except SlackApiError as err: + self.logger.debug( + f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} " + f"is no longer valid. (response: {err.response})" + ) + return None diff --git a/slack_bolt/authorization/async_authorize_args.py b/slack_bolt/authorization/async_authorize_args.py new file mode 100644 index 000000000..5100ad19f --- /dev/null +++ b/slack_bolt/authorization/async_authorize_args.py @@ -0,0 +1,37 @@ +from logging import Logger +from typing import Optional + +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.context.async_context import AsyncBoltContext + + +class AsyncAuthorizeArgs: + context: AsyncBoltContext + logger: Logger + client: AsyncWebClient + enterprise_id: Optional[str] + team_id: str + user_id: Optional[str] + + def __init__( + self, + *, + context: AsyncBoltContext, + enterprise_id: Optional[str], + team_id: str, + user_id: Optional[str], + ): + """The whole arguments that are passed to Authorize functions. + + :param context: The request context + :param enterprise_id: The Organization ID (Enterprise Grid) + :param team_id: The workspace ID + :param user_id: The request user ID + """ + self.context = context + self.logger = context.logger + self.client = context.client + self.enterprise_id = enterprise_id + self.team_id = team_id + self.user_id = user_id diff --git a/slack_bolt/authorization/authorization_result.py b/slack_bolt/authorization/authorization_result.py new file mode 100644 index 000000000..62c222873 --- /dev/null +++ b/slack_bolt/authorization/authorization_result.py @@ -0,0 +1,64 @@ +from typing import Optional + +from slack_sdk.web import SlackResponse + + +class AuthorizationResult(dict): + enterprise_id: Optional[str] + team_id: Optional[str] + bot_id: str + bot_user_id: str + bot_token: str + user_id: Optional[str] + user_token: Optional[str] + + def __init__( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + # bot + bot_user_id: str, + bot_id: str, + bot_token: str, + # user + user_id: Optional[str] = None, + user_token: Optional[str] = None, + ): + """The `auth.test` API result for an incoming request. + + :param enterprise_id: Organization ID (Enterprise Grid) + :param team_id: Workspace ID + :param bot_user_id: Bot user's User ID + :param bot_id: Bot ID + :param bot_token: Bot user access token starting with xoxb- + :param user_id: The request user ID + :param user_token: User access token starting with xoxp- + """ + self["enterprise_id"] = self.enterprise_id = enterprise_id + self["team_id"] = self.team_id = team_id + # bot + self["bot_user_id"] = self.bot_user_id = bot_user_id + self["bot_id"] = self.bot_id = bot_id + self["bot_token"] = self.bot_token = bot_token + # user + self["user_id"] = self.user_id = user_id + self["user_token"] = self.user_token = user_token + + @classmethod + def from_auth_test_response( + cls, + *, + bot_token: Optional[str] = None, + user_token: Optional[str] = None, + auth_test_response: SlackResponse, + ) -> "AuthorizationResult": + return AuthorizationResult( + enterprise_id=auth_test_response.get("enterprise_id"), + team_id=auth_test_response.get("team_id"), + bot_user_id=auth_test_response.get("user_id"), + bot_id=auth_test_response.get("bot_id"), + user_id=auth_test_response.get("user_id"), + bot_token=bot_token, + user_token=user_token, + ) diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py new file mode 100644 index 000000000..03f52c5bb --- /dev/null +++ b/slack_bolt/authorization/authorize.py @@ -0,0 +1,124 @@ +import inspect +from logging import Logger +from typing import Optional, Callable, Dict, Any + +from slack_sdk.errors import SlackApiError +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Bot + +from slack_bolt.authorization.authorize_args import AuthorizeArgs +from slack_bolt.authorization.authorization_result import AuthorizationResult +from slack_bolt.context.context import BoltContext + + +class Authorize: + def __init__(self): + pass + + def __call__( + self, + *, + context: BoltContext, + enterprise_id: Optional[str], + team_id: str, + user_id: Optional[str], + ) -> Optional[AuthorizationResult]: + raise NotImplementedError() + + +class CallableAuthorize(Authorize): + def __init__( + self, *, logger: Logger, func: Callable[..., AuthorizationResult], + ): + self.logger = logger + self.func = func + self.arg_names = inspect.getfullargspec(func).args + + def __call__( + self, + *, + context: BoltContext, + enterprise_id: Optional[str], + team_id: str, + user_id: Optional[str], + ) -> Optional[AuthorizationResult]: + try: + all_available_args = { + "args": AuthorizeArgs( + context=context, + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + ), + "logger": context.logger, + "client": context.client, + "context": context, + "enterprise_id": enterprise_id, + "team_id": team_id, + "user_id": user_id, + } + kwargs: Dict[str, Any] = { # type: ignore + k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore + } + found_arg_names = kwargs.keys() + for name in self.arg_names: + if name not in found_arg_names: + self.logger.warning(f"{name} is not a valid argument") + kwargs[name] = None + + auth_result = self.func(**kwargs) + if auth_result is None: + return auth_result + + if isinstance(auth_result, AuthorizationResult): + return auth_result + else: + raise ValueError( + f"Unexpected returned value from authorize function (type: {type(auth_result)})" + ) + except SlackApiError as err: + self.logger.debug( + f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} " + f"is no longer valid. (response: {err.response})" + ) + return None + + +class InstallationStoreAuthorize(Authorize): + def __init__( + self, *, logger: Logger, installation_store: InstallationStore, + ): + self.logger = logger + self.installation_store = installation_store + + def __call__( + self, + *, + context: BoltContext, + enterprise_id: Optional[str], + team_id: str, + user_id: Optional[str], + ) -> Optional[AuthorizationResult]: + bot: Optional[Bot] = self.installation_store.find_bot( + enterprise_id=enterprise_id, team_id=team_id, + ) + if bot is None: + self.logger.debug( + f"No installation data found " + f"for enterprise_id: {enterprise_id} team_id: {team_id}" + ) + return None + + try: + auth_result = context.client.auth_test(token=bot.bot_token) + return AuthorizationResult.from_auth_test_response( + auth_test_response=auth_result, + bot_token=bot.bot_token, + user_token=None, # Not yet supported + ) + except SlackApiError as err: + self.logger.debug( + f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} " + f"is no longer valid. (response: {err.response})" + ) + return None diff --git a/slack_bolt/authorization/authorize_args.py b/slack_bolt/authorization/authorize_args.py new file mode 100644 index 000000000..170cd7843 --- /dev/null +++ b/slack_bolt/authorization/authorize_args.py @@ -0,0 +1,37 @@ +from logging import Logger +from typing import Optional + +from slack_sdk.web import WebClient + +from slack_bolt.context.context import BoltContext + + +class AuthorizeArgs: + context: BoltContext + logger: Logger + client: WebClient + enterprise_id: Optional[str] + team_id: str + user_id: Optional[str] + + def __init__( + self, + *, + context: BoltContext, + enterprise_id: Optional[str], + team_id: str, + user_id: Optional[str], + ): + """The whole arguments that are passed to Authorize functions. + + :param context: The request context + :param enterprise_id: The Organization ID (Enterprise Grid) + :param team_id: The workspace ID + :param user_id: The request user ID + """ + self.context = context + self.logger = context.logger + self.client = context.client + self.enterprise_id = enterprise_id + self.team_id = team_id + self.user_id = user_id diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 839ff0826..c7ae762a3 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -1,7 +1,7 @@ from logging import Logger from typing import Optional, Tuple -from slack_bolt.auth import AuthorizationResult +from slack_bolt.authorization import AuthorizationResult class BaseContext(dict): diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 99751a6f0..0aee513d2 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -54,8 +54,10 @@ def warning_client_prioritized_and_token_skipped() -> str: return "As you gave `client` as well, `token` will be unused." -def warning_installation_store_prioritized_and_token_skipped() -> str: - return "As you gave `installation_store` as well, `token` will be unused." +def warning_token_skipped() -> str: + return ( + "As you gave `installation_store`/`authorize` as well, `token` will be unused." + ) def warning_unhandled_request(req: Union[BoltRequest, "AsyncBoltRequest"]) -> str: # type: ignore diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index 2d81dca4c..e9930396f 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -1,30 +1,25 @@ from typing import Callable, Optional, Awaitable from slack_sdk.errors import SlackApiError -from slack_sdk.oauth.installation_store import Bot -from slack_sdk.oauth.installation_store.async_installation_store import ( - AsyncInstallationStore, -) - -from slack_bolt.auth.result import AuthorizationResult from slack_bolt.logger import get_bolt_logger from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from .async_authorization import AsyncAuthorization from .async_internals import _build_error_response, _is_no_auth_required +from ...authorization import AuthorizationResult +from ...authorization.async_authorize import AsyncAuthorize from ...util.async_utils import create_async_web_client class AsyncMultiTeamsAuthorization(AsyncAuthorization): - installation_store: AsyncInstallationStore - verification_enabled: bool + authorize: AsyncAuthorize - def __init__(self, installation_store: AsyncInstallationStore): + def __init__(self, authorize: AsyncAuthorize): """Multi-workspace authorization. - :param installation_store: The module offering find/save operations of installation data. + :param authorize: The function to authorize incoming requests from Slack. """ - self.installation_store = installation_store + self.authorize = authorize self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization) async def async_process( @@ -37,24 +32,17 @@ async def async_process( if _is_no_auth_required(req): return await next() try: - bot: Optional[Bot] = await self.installation_store.async_find_bot( - enterprise_id=req.context.enterprise_id, team_id=req.context.team_id, + auth_result: Optional[AuthorizationResult] = await self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, ) - if bot is None: - return _build_error_response() - - auth_result = await req.context.client.auth_test(token=bot.bot_token) if auth_result: - req.context["authorization_result"] = AuthorizationResult( - enterprise_id=auth_result.get("enterprise_id", None), - team_id=auth_result.get("team_id", None), - bot_user_id=auth_result.get("user_id", None), - bot_id=auth_result.get("bot_id", None), - bot_token=bot.bot_token, - ) - # TODO: bot -> user token - req.context["token"] = bot.bot_token - req.context["client"] = create_async_web_client(bot.bot_token) + req.context["authorization_result"] = auth_result + token = auth_result.bot_token or auth_result.user_token + req.context["token"] = token + req.context["client"] = create_async_web_client(token) return await next() else: # Just in case diff --git a/slack_bolt/middleware/authorization/internals.py b/slack_bolt/middleware/authorization/internals.py index fd1d29836..80667f92d 100644 --- a/slack_bolt/middleware/authorization/internals.py +++ b/slack_bolt/middleware/authorization/internals.py @@ -2,7 +2,7 @@ from slack_sdk.web import SlackResponse -from slack_bolt.auth import AuthorizationResult +from slack_bolt.authorization import AuthorizationResult from slack_bolt.request.request import BoltRequest from slack_bolt.response import BoltResponse diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index 48cb3a692..224b8214c 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -1,26 +1,27 @@ from typing import Callable, Optional -from slack_bolt.auth.result import AuthorizationResult from slack_bolt.logger import get_bolt_logger from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from slack_sdk.errors import SlackApiError -from slack_sdk.oauth.installation_store import InstallationStore, Bot from .authorization import Authorization from .internals import _build_error_response, _is_no_auth_required +from ...authorization import AuthorizationResult +from ...authorization.authorize import Authorize from ...util.utils import create_web_client class MultiTeamsAuthorization(Authorization): - installation_store: InstallationStore - verification_enabled: bool + authorize: Authorize - def __init__(self, installation_store: InstallationStore): + def __init__( + self, *, authorize: Authorize, + ): """Multi-workspace authorization. - :param installation_store: The module offering find/save operations of installation data. + :param authorize: The function to authorize incoming requests from Slack. """ - self.installation_store = installation_store + self.authorize = authorize self.logger = get_bolt_logger(MultiTeamsAuthorization) def process( @@ -29,24 +30,18 @@ def process( if _is_no_auth_required(req): return next() try: - bot: Optional[Bot] = self.installation_store.find_bot( - enterprise_id=req.context.enterprise_id, team_id=req.context.team_id, + auth_result: Optional[AuthorizationResult] = self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, ) - if bot is None: - return _build_error_response() - - auth_result = req.context.client.auth_test(token=bot.bot_token) - if auth_result: - req.context["authorization_result"] = AuthorizationResult( - enterprise_id=auth_result.get("enterprise_id", None), - team_id=auth_result.get("team_id", None), - bot_user_id=auth_result.get("user_id", None), - bot_id=auth_result.get("bot_id", None), - bot_token=bot.bot_token, - ) - # TODO: bot -> user token - req.context["token"] = bot.bot_token - req.context["client"] = create_web_client(bot.bot_token) + self.logger.info(auth_result) + if auth_result is not None: + req.context["authorization_result"] = auth_result + token = auth_result.bot_token or auth_result.user_token + req.context["token"] = token + req.context["client"] = create_web_client(token) return next() else: # Just in case diff --git a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py index cb52a0c48..4f79aac81 100644 --- a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -1,7 +1,7 @@ import logging from typing import Callable -from slack_bolt.auth import AuthorizationResult +from slack_bolt.authorization import AuthorizationResult from slack_bolt.logger import get_bolt_logger from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index ddfb72336..e762e9b1f 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -62,6 +62,8 @@ def __init__( self._async_client = client self._logger = logger self.settings = settings + self.settings.logger = self._logger + self.client_id = self.settings.client_id self.redirect_uri = self.settings.redirect_uri self.install_path = self.settings.install_path diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index 7823b9468..3b3018575 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -1,4 +1,6 @@ +import logging import os +from logging import Logger from typing import List, Optional from slack_sdk.oauth import ( @@ -13,6 +15,10 @@ from slack_sdk.oauth.state_store import FileOAuthStateStore from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore +from slack_bolt.authorization.async_authorize import ( + AsyncInstallationStoreAuthorize, + AsyncAuthorize, +) from slack_bolt.error import BoltError from slack_bolt.oauth.callback_options import CallbackOptions @@ -33,6 +39,7 @@ class AsyncOAuthSettings: authorization_url: str # default: https://slack.com/oauth/v2/authorize # Installation Management installation_store: AsyncInstallationStore + authorize: AsyncAuthorize # state parameter related configurations state_store: AsyncOAuthStateStore state_cookie_name: str @@ -41,6 +48,8 @@ class AsyncOAuthSettings: state_utils: OAuthStateUtils authorize_url_generator: AuthorizeUrlGenerator redirect_uri_page_renderer: RedirectUriPageRenderer + # Others + logger: Logger def __init__( self, @@ -64,6 +73,8 @@ def __init__( state_store: Optional[AsyncOAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + # Others + logger: Logger = logging.getLogger(__name__), ): """The settings for Slack App installation (OAuth flow). @@ -82,6 +93,7 @@ def __init__( :param state_store: Specify the instance of InstallationStore (Default: FileOAuthStateStore) :param state_cookie_name: The cookie name that is set for installers' browser. (Default: slack-app-oauth-state) :param state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) + :param logger: The logger that will be used internally """ # OAuth flow parameters/credentials self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID", None) @@ -113,6 +125,9 @@ def __init__( self.installation_store = installation_store or FileInstallationStore( client_id=client_id ) + self.authorize = AsyncInstallationStoreAuthorize( + logger=logger, installation_store=self.installation_store, + ) # state parameter related configurations self.state_store = state_store or FileOAuthStateStore( expiration_seconds=state_expiration_seconds, client_id=client_id, diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index 31d571736..96c7ee2b6 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -62,6 +62,8 @@ def __init__( self._client = client self._logger = logger self.settings = settings + self.settings.logger = self._logger + self.client_id = self.settings.client_id self.redirect_uri = self.settings.redirect_uri self.install_path = self.settings.install_path diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index 0f8a58578..e18c7c34e 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -1,4 +1,6 @@ +import logging import os +from logging import Logger from typing import List, Optional from slack_sdk.oauth import ( @@ -11,6 +13,7 @@ from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_bolt.authorization.authorize import Authorize, InstallationStoreAuthorize from slack_bolt.error import BoltError from slack_bolt.oauth.callback_options import CallbackOptions @@ -31,6 +34,7 @@ class OAuthSettings: authorization_url: str # default: https://slack.com/oauth/v2/authorize # Installation Management installation_store: InstallationStore + authorize: Authorize # state parameter related configurations state_store: OAuthStateStore state_cookie_name: str @@ -39,6 +43,8 @@ class OAuthSettings: state_utils: OAuthStateUtils authorize_url_generator: AuthorizeUrlGenerator redirect_uri_page_renderer: RedirectUriPageRenderer + # Others + logger: Logger def __init__( self, @@ -62,6 +68,8 @@ def __init__( state_store: Optional[OAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + # Others + logger: Logger = logging.getLogger(__name__), ): """The settings for Slack App installation (OAuth flow). @@ -80,6 +88,7 @@ def __init__( :param state_store: Specify the instance of InstallationStore (Default: FileOAuthStateStore) :param state_cookie_name: The cookie name that is set for installers' browser. (Default: slack-app-oauth-state) :param state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) + :param logger: The logger that will be used internally """ self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID", None) self.client_secret = client_secret or os.environ.get( @@ -110,6 +119,9 @@ def __init__( self.installation_store = installation_store or FileInstallationStore( client_id=client_id ) + self.authorize = InstallationStoreAuthorize( + logger=logger, installation_store=self.installation_store, + ) # state parameter related configurations self.state_store = state_store or FileOAuthStateStore( expiration_seconds=state_expiration_seconds, client_id=client_id, From a3581c304538a412c183cbb8a50d45f356609567 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 22 Sep 2020 22:55:34 +0900 Subject: [PATCH 098/865] Add unit tests for authorize --- tests/scenario_tests/test_authorize.py | 143 ++++++++++++++++++ tests/scenario_tests_async/test_authorize.py | 151 +++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 tests/scenario_tests/test_authorize.py create mode 100644 tests/scenario_tests_async/test_authorize.py diff --git a/tests/scenario_tests/test_authorize.py b/tests/scenario_tests/test_authorize.py new file mode 100644 index 000000000..fd81213e0 --- /dev/null +++ b/tests/scenario_tests/test_authorize.py @@ -0,0 +1,143 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk import WebClient +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import BoltRequest +from slack_bolt.app import App +from slack_bolt.authorization import AuthorizationResult +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 + +valid_token = "xoxb-valid" + + +def authorize(enterprise_id, team_id, user_id, client: WebClient): + assert enterprise_id == "E111" + assert team_id == "T111" + assert user_id == "W111" + auth_test = client.auth_test(token=valid_token) + return AuthorizationResult.from_auth_test_response( + auth_test_response=auth_test, bot_token=valid_token, + ) + + +def error_authorize(enterprise_id, team_id, user_id): + assert enterprise_id == "E111" + assert team_id == "T111" + assert user_id == "W111" + return None + + +class TestAuthorize: + signing_secret = "secret" + 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_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> BoltRequest: + timestamp = str(int(time())) + return BoltRequest( + body=raw_body, headers=self.build_headers(timestamp, raw_body) + ) + + def test_success(self): + app = App( + client=self.web_client, + authorize=authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(simple_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert self.mock_received_requests["/auth.test"] == 1 + + def test_failure(self): + app = App( + client=self.web_client, + authorize=error_authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(simple_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == ":x: Please install this app into the workspace :bow:" + assert self.mock_received_requests.get("/auth.test") == None + + +body = { + "type": "block_actions", + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "111.222", + "channel_id": "C111", + "is_ephemeral": True, + }, + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button", "emoji": True}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], +} + +raw_body = f"payload={quote(json.dumps(body))}" + + +def simple_listener(ack, body, payload, action): + assert body["trigger_id"] == "111.222.valid" + assert body["actions"][0] == payload + assert payload == action + assert action["action_id"] == "a" + ack() diff --git a/tests/scenario_tests_async/test_authorize.py b/tests/scenario_tests_async/test_authorize.py new file mode 100644 index 000000000..57653b34d --- /dev/null +++ b/tests/scenario_tests_async/test_authorize.py @@ -0,0 +1,151 @@ +import asyncio +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.authorization import AuthorizationResult +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 + +valid_token = "xoxb-valid" + + +async def authorize(enterprise_id, team_id, user_id, client: AsyncWebClient): + assert enterprise_id == "E111" + assert team_id == "T111" + assert user_id == "W111" + auth_test = await client.auth_test(token=valid_token) + return AuthorizationResult.from_auth_test_response( + auth_test_response=auth_test, bot_token=valid_token, + ) + + +async def error_authorize(enterprise_id, team_id, user_id): + assert enterprise_id == "E111" + assert team_id == "T111" + assert user_id == "W111" + return None + + +class TestAsyncAuthorize: + signing_secret = "secret" + 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,) + + @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) + + 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): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + 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_success(self): + app = AsyncApp( + client=self.web_client, + authorize=authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + assert self.mock_received_requests["/auth.test"] == 1 + + @pytest.mark.asyncio + async def test_failure(self): + app = AsyncApp( + client=self.web_client, + authorize=error_authorize, + signing_secret=self.signing_secret, + ) + app.block_action("a")(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == ":x: Please install this app into the workspace :bow:" + assert self.mock_received_requests.get("/auth.test") == None + + +body = { + "type": "block_actions", + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "111.222", + "channel_id": "C111", + "is_ephemeral": True, + }, + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button", "emoji": True}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], +} + +raw_body = f"payload={quote(json.dumps(body))}" + + +async def simple_listener(ack, body, payload, action): + assert body["trigger_id"] == "111.222.valid" + assert body["actions"][0] == payload + assert payload == action + assert action["action_id"] == "a" + await ack() From 3a3f37a9bad4e70e5a111b3e557da3ff7ee8e07d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 23 Sep 2020 20:37:23 +0900 Subject: [PATCH 099/865] version 0.6.1a0 --- setup.py | 2 +- slack_bolt/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index b491f6af0..ffe9d9110 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["samples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.0.0a5",], + install_requires=["slack_sdk>=3.0.0a6",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 9b980958a..09dd12d3b 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.6.0a0" +__version__ = "0.6.1a0" From d5a4f7eb7b19279c9c7e06476fa69bab12235d9a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 24 Sep 2020 20:45:25 +0900 Subject: [PATCH 100/865] Make Bolt Python more customizable from global middleware * Extract the core part of listener execution from App * Enable kwargs_injection to merge all fields in context into args --- slack_bolt/app/app.py | 185 +++-------------- slack_bolt/app/async_app.py | 195 +++--------------- slack_bolt/kwargs_injection/async_utils.py | 3 + slack_bolt/kwargs_injection/utils.py | 3 + slack_bolt/listener/asyncio_runner.py | 178 ++++++++++++++++ slack_bolt/listener/thread_runner.py | 175 ++++++++++++++++ slack_bolt/listener_matcher/builtins.py | 25 +++ .../async_ignoring_self_events.py | 2 +- .../ignoring_self_events.py | 16 +- slack_bolt/util/payload_utils.py | 4 + .../slack_bolt/kwargs_injection/test_args.py | 16 ++ .../kwargs_injection/test_async_args.py | 16 ++ 12 files changed, 483 insertions(+), 335 deletions(-) create mode 100644 slack_bolt/listener/asyncio_runner.py create mode 100644 slack_bolt/listener/thread_runner.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 2b8d288d3..418e548e5 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -2,11 +2,11 @@ import json import logging import os -import time from concurrent.futures.thread import ThreadPoolExecutor from http.server import SimpleHTTPRequestHandler, HTTPServer from typing import List, Union, Pattern, Callable, Dict, Optional +from slack_bolt.listener.thread_runner import ThreadListenerRunner from slack_sdk.errors import SlackApiError from slack_sdk.oauth.installation_store import InstallationStore from slack_sdk.web import WebClient @@ -18,12 +18,10 @@ CallableAuthorize, ) from slack_bolt.error import BoltError -from slack_bolt.lazy_listener.runner import LazyListenerRunner from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner from slack_bolt.listener.custom_listener import CustomListener from slack_bolt.listener.listener import Listener from slack_bolt.listener.listener_error_handler import ( - ListenerErrorHandler, DefaultListenerErrorHandler, CustomListenerErrorHandler, ) @@ -41,9 +39,6 @@ debug_checking_listener, debug_applying_middleware, debug_running_listener, - warning_did_not_call_ack, - debug_running_lazy_listener, - debug_responding, error_unexpected_listener_middleware, error_client_invalid_type, ) @@ -62,7 +57,7 @@ from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import create_web_client, create_copy +from slack_bolt.util.utils import create_web_client class App: @@ -173,14 +168,18 @@ def __init__( self._middleware_list: List[Union[Callable, Middleware]] = [] self._listeners: List[Listener] = [] - self._listener_executor = ThreadPoolExecutor(max_workers=5) # TODO: shutdown - self._listener_error_handler = DefaultListenerErrorHandler( - logger=self._framework_logger - ) - self._process_before_response = process_before_response - self.lazy_listener_runner: LazyListenerRunner = ThreadLazyListenerRunner( - logger=self._framework_logger, executor=self._listener_executor, + listener_executor = ThreadPoolExecutor(max_workers=5) + self._listener_runner = ThreadListenerRunner( + logger=self._framework_logger, + process_before_response=process_before_response, + listener_error_handler=DefaultListenerErrorHandler( + logger=self._framework_logger + ), + listener_executor=listener_executor, + lazy_listener_runner=ThreadLazyListenerRunner( + logger=self._framework_logger, executor=listener_executor, + ), ) self._init_middleware_list_done = False @@ -241,8 +240,8 @@ def installation_store(self) -> Optional[InstallationStore]: return self._installation_store @property - def listener_error_handler(self) -> ListenerErrorHandler: - return self._listener_error_handler + def listener_runner(self) -> ThreadListenerRunner: + return self._listener_runner # ------------------------- # standalone server @@ -302,7 +301,7 @@ def middleware_next(): continue self._framework_logger.debug(debug_running_listener(listener_name)) - listener_response: Optional[BoltResponse] = self._run_listener( + listener_response: Optional[BoltResponse] = self._listener_runner.run( request=req, response=resp, listener_name=listener_name, @@ -314,145 +313,6 @@ def middleware_next(): self._framework_logger.warning(warning_unhandled_request(req)) return BoltResponse(status=404, body={"error": "unhandled request"}) - def _run_listener( - self, - request: BoltRequest, - response: BoltResponse, - listener_name: str, - listener: Listener, - ) -> Optional[BoltResponse]: - ack = request.context.ack - starting_time = time.time() - if self._process_before_response: - if not request.lazy_only: - try: - returned_value = listener.run_ack_function( - request=request, response=response - ) - if isinstance(returned_value, BoltResponse): - response = returned_value - if ack.response is None and listener.auto_acknowledgement: - ack() # automatic ack() call if the call is not yet done - except Exception as e: - # The default response status code is 500 in this case. - # You can customize this by passing your own error handler. - if response is None: - response = BoltResponse(status=500) - response.status = 500 - self._listener_error_handler.handle( - error=e, request=request, response=response, - ) - ack.response = response - - for lazy_func in listener.lazy_functions: - if request.lazy_function_name: - func_name = lazy_func.__name__ - if func_name == request.lazy_function_name: - self.lazy_listener_runner.run( - function=lazy_func, request=request - ) - # This HTTP response won't be sent to Slack API servers. - return BoltResponse(status=200) - else: - continue - else: - self._start_lazy_function(lazy_func, request) - - if response is not None: - self._debug_log_completion(starting_time, response) - return response - elif ack.response is not None: - self._debug_log_completion(starting_time, ack.response) - return ack.response - else: - if listener.auto_acknowledgement: - # acknowledge immediately in case of Events API - ack() - - if not request.lazy_only: - # start the listener function asynchronously - def run_ack_function_asynchronously(): - nonlocal ack, request, response - try: - listener.run_ack_function(request=request, response=response) - except Exception as e: - # The default response status code is 500 in this case. - # You can customize this by passing your own error handler. - if listener.auto_acknowledgement: - self._listener_error_handler.handle( - error=e, request=request, response=response, - ) - else: - if response is None: - response = BoltResponse(status=500) - response.status = 500 - if ack.response is not None: # already acknowledged - response = None - self._listener_error_handler.handle( - error=e, request=request, response=response, - ) - ack.response = response - - self._listener_executor.submit(run_ack_function_asynchronously) - - for lazy_func in listener.lazy_functions: - if request.lazy_function_name: - func_name = lazy_func.__name__ - if func_name == request.lazy_function_name: - self.lazy_listener_runner.run( - function=lazy_func, request=request - ) - # This HTTP response won't be sent to Slack API servers. - return BoltResponse(status=200) - else: - continue - else: - self._start_lazy_function(lazy_func, request) - - # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= 3: - time.sleep(0.01) - - if response is None and ack.response is None: - self._framework_logger.warning(warning_did_not_call_ack(listener_name)) - return None - - if response is None and ack.response is not None: - response = ack.response - self._debug_log_completion(starting_time, response) - return response - - if response is not None: - return response - - # None for both means no ack() in the listener - return None - - def _start_lazy_function( - self, lazy_func: Callable[..., None], request: BoltRequest - ) -> None: - # Start a lazy function asynchronously - func_name: str = lazy_func.__name__ - self._framework_logger.debug(debug_running_lazy_listener(func_name)) - copied_request = self._build_lazy_request(request, func_name) - self.lazy_listener_runner.start(function=lazy_func, request=copied_request) - - @staticmethod - def _build_lazy_request(request: BoltRequest, lazy_func_name: str) -> BoltRequest: - copied_request = create_copy(request) - copied_request.method = "NONE" - copied_request.lazy_only = True - copied_request.lazy_function_name = lazy_func_name - return copied_request - - def _debug_log_completion( - self, starting_time: float, response: BoltResponse - ) -> None: - millis = int((time.time() - starting_time) * 1000) - self._framework_logger.debug( - debug_responding(response.status, response.body, millis) - ) - # ------------------------- # middleware @@ -467,10 +327,13 @@ def middleware(self, *args) -> None: :return: None """ if len(args) > 0: - func = args[0] - self._middleware_list.append( - CustomMiddleware(app_name=self.name, func=func) - ) + middleware_or_callable = args[0] + if isinstance(middleware_or_callable, Middleware): + self._middleware_list.append(middleware_or_callable) + else: + self._middleware_list.append( + CustomMiddleware(app_name=self.name, func=middleware_or_callable) + ) # ------------------------- # global error handler @@ -482,7 +345,7 @@ def error(self, func: Callable[..., None]) -> None: when getting an unhandled error in Bolt app. :return: None """ - self._listener_error_handler = CustomListenerErrorHandler( + self._listener_runner.listener_error_handler = CustomListenerErrorHandler( logger=self._framework_logger, func=func, ) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 5d1fffd8c..160d12041 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -1,11 +1,12 @@ -import asyncio import inspect import logging import os -import time -from asyncio import Future from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable +from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner +from slack_bolt.middleware.message_listener_matches.async_message_listener_matches import ( + AsyncMessageListenerMatches, +) from slack_sdk.oauth.installation_store.async_installation_store import ( AsyncInstallationStore, ) @@ -17,7 +18,6 @@ AsyncCallableAuthorize, AsyncInstallationStoreAuthorize, ) -from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.error import BoltError from slack_bolt.logger.messages import ( error_signing_secret_not_found, @@ -27,19 +27,14 @@ warning_unhandled_request, debug_checking_listener, debug_running_listener, - warning_did_not_call_ack, - debug_running_lazy_listener, - debug_responding, error_unexpected_listener_middleware, error_listener_function_must_be_coro_func, error_client_invalid_type_async, ) -from slack_bolt.lazy_listener.async_runner import AsyncLazyListenerRunner from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener from slack_bolt.listener.async_listener_error_handler import ( AsyncDefaultListenerErrorHandler, - AsyncListenerErrorHandler, AsyncCustomListenerErrorHandler, ) from slack_bolt.listener_matcher import builtins as builtin_matchers @@ -53,7 +48,6 @@ AsyncRequestVerification, AsyncIgnoringSelfEvents, AsyncUrlVerification, - AsyncMessageListenerMatches, ) from slack_bolt.middleware.async_custom_middleware import ( AsyncMiddleware, @@ -70,7 +64,6 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from slack_bolt.util.async_utils import create_async_web_client -from slack_bolt.util.utils import create_copy class AsyncApp: @@ -186,12 +179,16 @@ def __init__( self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] self._async_listeners: List[AsyncListener] = [] - self._async_listener_error_handler = AsyncDefaultListenerErrorHandler( - logger=self._framework_logger - ) - self._process_before_response = process_before_response - self.lazy_listener_runner: AsyncLazyListenerRunner = AsyncioLazyListenerRunner( + + self._async_listener_runner = AsyncioListenerRunner( logger=self._framework_logger, + process_before_response=process_before_response, + listener_error_handler=AsyncDefaultListenerErrorHandler( + logger=self._framework_logger + ), + lazy_listener_runner=AsyncioLazyListenerRunner( + logger=self._framework_logger, + ), ) self._init_middleware_list_done = False @@ -248,8 +245,8 @@ def installation_store(self) -> Optional[AsyncInstallationStore]: return self._async_installation_store @property - def listener_error_handler(self) -> AsyncListenerErrorHandler: - return self._async_listener_error_handler + def listener_runner(self) -> AsyncioListenerRunner: + return self._async_listener_runner # ------------------------- # standalone server @@ -313,11 +310,11 @@ async def async_middleware_next(): self._framework_logger.debug(debug_running_listener(listener_name)) listener_response: Optional[ BoltResponse - ] = await self._run_async_listener( + ] = await self._async_listener_runner.run( request=req, response=resp, listener_name=listener_name, - async_listener=listener, + listener=listener, ) if listener_response is not None: return listener_response @@ -325,149 +322,6 @@ async def async_middleware_next(): self._framework_logger.warning(warning_unhandled_request(req)) return BoltResponse(status=404, body={"error": "unhandled request"}) - async def _run_async_listener( - self, - request: AsyncBoltRequest, - response: BoltResponse, - listener_name: str, - async_listener: AsyncListener, - ) -> Optional[BoltResponse]: - ack = request.context.ack - starting_time = time.time() - if self._process_before_response: - if not request.lazy_only: - try: - returned_value = await async_listener.run_ack_function( - request=request, response=response - ) - if isinstance(returned_value, BoltResponse): - response = returned_value - if ack.response is None and async_listener.auto_acknowledgement: - await ack() # automatic ack() call if the call is not yet done - except Exception as e: - # The default response status code is 500 in this case. - # You can customize this by passing your own error handler. - if response is None: - response = BoltResponse(status=500) - response.status = 500 - await self._async_listener_error_handler.handle( - error=e, request=request, response=response, - ) - ack.response = response - - for lazy_func in async_listener.lazy_functions: - if request.lazy_function_name: - func_name = lazy_func.__name__ - if func_name == request.lazy_function_name: - await self.lazy_listener_runner.run( - function=lazy_func, request=request - ) - # This HTTP response won't be sent to Slack API servers. - return BoltResponse(status=200) - else: - continue - else: - self._start_lazy_function(lazy_func, request) - - if response is not None: - self._debug_log_completion(starting_time, response) - return response - elif ack.response is not None: - self._debug_log_completion(starting_time, ack.response) - return ack.response - else: - if async_listener.auto_acknowledgement: - # acknowledge immediately in case of Events API - await ack() - - if not request.lazy_only: - # start the listener function asynchronously - # NOTE: intentionally - async def run_ack_function_asynchronously( - ack: AsyncAck, request: AsyncBoltRequest, response: BoltResponse, - ): - try: - await async_listener.run_ack_function( - request=request, response=response - ) - except Exception as e: - # The default response status code is 500 in this case. - # You can customize this by passing your own error handler. - if response is None: - response = BoltResponse(status=500) - response.status = 500 - if ack.response is not None: # already acknowledged - response = None - - await self._async_listener_error_handler.handle( - error=e, request=request, response=response, - ) - ack.response = response - - _f: Future = asyncio.ensure_future( - run_ack_function_asynchronously(ack, request, response) - ) - - for lazy_func in async_listener.lazy_functions: - if request.lazy_function_name: - func_name = lazy_func.__name__ - if func_name == request.lazy_function_name: - await self.lazy_listener_runner.run( - function=lazy_func, request=request - ) - # This HTTP response won't be sent to Slack API servers. - return BoltResponse(status=200) - else: - continue - else: - self._start_lazy_function(lazy_func, request) - - # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= 3: - await asyncio.sleep(0.01) - - if response is None and ack.response is None: - self._framework_logger.warning(warning_did_not_call_ack(listener_name)) - return None - - if response is None and ack.response is not None: - response = ack.response - self._debug_log_completion(starting_time, response) - return response - - if response is not None: - return response - - # None for both means no ack() in the listener - return None - - def _start_lazy_function( - self, lazy_func: Callable[..., Awaitable[None]], request: AsyncBoltRequest - ) -> None: - # Start a lazy function asynchronously - func_name: str = lazy_func.__name__ - self._framework_logger.debug(debug_running_lazy_listener(func_name)) - copied_request = self._build_lazy_request(request, func_name) - self.lazy_listener_runner.start(function=lazy_func, request=copied_request) - - @staticmethod - def _build_lazy_request( - request: AsyncBoltRequest, lazy_func_name: str - ) -> AsyncBoltRequest: - copied_request = create_copy(request) - copied_request.method = "NONE" - copied_request.lazy_only = True - copied_request.lazy_function_name = lazy_func_name - return copied_request - - def _debug_log_completion( - self, starting_time: float, response: BoltResponse - ) -> None: - millis = int((time.time() - starting_time) * 1000) - self._framework_logger.debug( - debug_responding(response.status, response.body, millis) - ) - # ------------------------- # middleware @@ -482,10 +336,15 @@ def middleware(self, *args) -> None: :return: None """ if len(args) > 0: - func = args[0] - self._async_middleware_list.append( - AsyncCustomMiddleware(app_name=self.name, func=func) - ) + middleware_or_callable = args[0] + if isinstance(middleware_or_callable, AsyncMiddleware): + self._async_middleware_list.append(middleware_or_callable) + else: + self._async_middleware_list.append( + AsyncCustomMiddleware( + app_name=self.name, func=middleware_or_callable + ) + ) # ------------------------- # global error handler @@ -497,7 +356,7 @@ def error(self, func: Callable[..., Awaitable[None]]) -> None: when getting an unhandled error in Bolt app. :return: None """ - self._async_listener_error_handler = AsyncCustomListenerErrorHandler( + self._async_listener_runner.listener_error_handler = AsyncCustomListenerErrorHandler( logger=self._framework_logger, func=func, ) diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index 08ff1d061..7dd379648 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -58,6 +58,9 @@ def build_async_required_kwargs( or all_available_args["message"] or request.body ) + for k, v in request.context.items(): + if k not in all_available_args: + all_available_args[k] = v 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 ee6661a0c..b28a09a14 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -58,6 +58,9 @@ def build_required_kwargs( or all_available_args["message"] or request.body ) + for k, v in request.context.items(): + if k not in all_available_args: + all_available_args[k] = v 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/listener/asyncio_runner.py b/slack_bolt/listener/asyncio_runner.py new file mode 100644 index 000000000..dca9d5705 --- /dev/null +++ b/slack_bolt/listener/asyncio_runner.py @@ -0,0 +1,178 @@ +import asyncio +import time +from asyncio import Future +from logging import Logger +from typing import Optional, Callable, Awaitable + +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.lazy_listener.async_runner import AsyncLazyListenerRunner +from slack_bolt.listener.async_listener import AsyncListener +from slack_bolt.listener.async_listener_error_handler import AsyncListenerErrorHandler +from slack_bolt.logger.messages import ( + debug_responding, + debug_running_lazy_listener, + warning_did_not_call_ack, +) +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import create_copy + + +class AsyncioListenerRunner: + logger: Logger + process_before_response: bool + listener_error_handler: AsyncListenerErrorHandler + lazy_listener_runner: AsyncLazyListenerRunner + + def __init__( + self, + logger: Logger, + process_before_response: bool, + listener_error_handler: AsyncListenerErrorHandler, + lazy_listener_runner: AsyncLazyListenerRunner, + ): + self.logger = logger + self.process_before_response = process_before_response + self.listener_error_handler = listener_error_handler + self.lazy_listener_runner = lazy_listener_runner + + async def run( + self, + request: AsyncBoltRequest, + response: BoltResponse, + listener_name: str, + listener: AsyncListener, + ) -> Optional[BoltResponse]: + ack = request.context.ack + starting_time = time.time() + if self.process_before_response: + if not request.lazy_only: + try: + returned_value = await listener.run_ack_function( + request=request, response=response + ) + if isinstance(returned_value, BoltResponse): + response = returned_value + if ack.response is None and listener.auto_acknowledgement: + await ack() # automatic ack() call if the call is not yet done + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + await self.listener_error_handler.handle( + error=e, request=request, response=response, + ) + ack.response = response + + for lazy_func in listener.lazy_functions: + if request.lazy_function_name: + func_name = lazy_func.__name__ + if func_name == request.lazy_function_name: + await self.lazy_listener_runner.run( + function=lazy_func, request=request + ) + # This HTTP response won't be sent to Slack API servers. + return BoltResponse(status=200) + else: + continue + else: + self._start_lazy_function(lazy_func, request) + + if response is not None: + self._debug_log_completion(starting_time, response) + return response + elif ack.response is not None: + self._debug_log_completion(starting_time, ack.response) + return ack.response + else: + if listener.auto_acknowledgement: + # acknowledge immediately in case of Events API + await ack() + + if not request.lazy_only: + # start the listener function asynchronously + # NOTE: intentionally + async def run_ack_function_asynchronously( + ack: AsyncAck, request: AsyncBoltRequest, response: BoltResponse, + ): + try: + await listener.run_ack_function( + request=request, response=response + ) + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + if ack.response is not None: # already acknowledged + response = None + + await self.listener_error_handler.handle( + error=e, request=request, response=response, + ) + ack.response = response + + _f: Future = asyncio.ensure_future( + run_ack_function_asynchronously(ack, request, response) + ) + + for lazy_func in listener.lazy_functions: + if request.lazy_function_name: + func_name = lazy_func.__name__ + if func_name == request.lazy_function_name: + await self.lazy_listener_runner.run( + function=lazy_func, request=request + ) + # This HTTP response won't be sent to Slack API servers. + return BoltResponse(status=200) + else: + continue + else: + self._start_lazy_function(lazy_func, request) + + # await for the completion of ack() in the async listener execution + while ack.response is None and time.time() - starting_time <= 3: + await asyncio.sleep(0.01) + + if response is None and ack.response is None: + self.logger.warning(warning_did_not_call_ack(listener_name)) + return None + + if response is None and ack.response is not None: + response = ack.response + self._debug_log_completion(starting_time, response) + return response + + if response is not None: + return response + + # None for both means no ack() in the listener + return None + + def _start_lazy_function( + self, lazy_func: Callable[..., Awaitable[None]], request: AsyncBoltRequest + ) -> None: + # Start a lazy function asynchronously + func_name: str = lazy_func.__name__ + self.logger.debug(debug_running_lazy_listener(func_name)) + copied_request = self._build_lazy_request(request, func_name) + self.lazy_listener_runner.start(function=lazy_func, request=copied_request) + + @staticmethod + def _build_lazy_request( + request: AsyncBoltRequest, lazy_func_name: str + ) -> AsyncBoltRequest: + copied_request = create_copy(request) + copied_request.method = "NONE" + copied_request.lazy_only = True + copied_request.lazy_function_name = lazy_func_name + return copied_request + + def _debug_log_completion( + self, starting_time: float, response: BoltResponse + ) -> None: + millis = int((time.time() - starting_time) * 1000) + self.logger.debug(debug_responding(response.status, response.body, millis)) diff --git a/slack_bolt/listener/thread_runner.py b/slack_bolt/listener/thread_runner.py new file mode 100644 index 000000000..dfe2a51a3 --- /dev/null +++ b/slack_bolt/listener/thread_runner.py @@ -0,0 +1,175 @@ +import time +from concurrent.futures.thread import ThreadPoolExecutor +from logging import Logger +from typing import Optional, Callable + +from slack_bolt.lazy_listener import LazyListenerRunner +from slack_bolt.listener import Listener +from slack_bolt.listener.listener_error_handler import ListenerErrorHandler +from slack_bolt.logger.messages import ( + debug_responding, + debug_running_lazy_listener, + warning_did_not_call_ack, +) +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import create_copy + + +class ThreadListenerRunner: + logger: Logger + process_before_response: bool + listener_error_handler: ListenerErrorHandler + listener_executor: ThreadPoolExecutor + lazy_listener_runner: LazyListenerRunner + + def __init__( + self, + logger: Logger, + process_before_response: bool, + listener_error_handler: ListenerErrorHandler, + listener_executor: ThreadPoolExecutor, + lazy_listener_runner: LazyListenerRunner, + ): + self.logger = logger + self.process_before_response = process_before_response + self.listener_error_handler = listener_error_handler + self.listener_executor = listener_executor + self.lazy_listener_runner = lazy_listener_runner + + def run( # type: ignore + self, + request: BoltRequest, + response: BoltResponse, + listener_name: str, + listener: Listener, + ) -> Optional[BoltResponse]: + ack = request.context.ack + starting_time = time.time() + if self.process_before_response: + if not request.lazy_only: + try: + returned_value = listener.run_ack_function( + request=request, response=response + ) + if isinstance(returned_value, BoltResponse): + response = returned_value + if ack.response is None and listener.auto_acknowledgement: + ack() # automatic ack() call if the call is not yet done + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + self.listener_error_handler.handle( + error=e, request=request, response=response, + ) + ack.response = response + + for lazy_func in listener.lazy_functions: + if request.lazy_function_name: + func_name = lazy_func.__name__ + if func_name == request.lazy_function_name: + self.lazy_listener_runner.run( + function=lazy_func, request=request + ) + # This HTTP response won't be sent to Slack API servers. + return BoltResponse(status=200) + else: + continue + else: + self._start_lazy_function(lazy_func, request) + + if response is not None: + self._debug_log_completion(starting_time, response) + return response + elif ack.response is not None: + self._debug_log_completion(starting_time, ack.response) + return ack.response + else: + if listener.auto_acknowledgement: + # acknowledge immediately in case of Events API + ack() + + if not request.lazy_only: + # start the listener function asynchronously + def run_ack_function_asynchronously(): + nonlocal ack, request, response + try: + listener.run_ack_function(request=request, response=response) + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if listener.auto_acknowledgement: + self.listener_error_handler.handle( + error=e, request=request, response=response, + ) + else: + if response is None: + response = BoltResponse(status=500) + response.status = 500 + if ack.response is not None: # already acknowledged + response = None + self.listener_error_handler.handle( + error=e, request=request, response=response, + ) + ack.response = response + + self.listener_executor.submit(run_ack_function_asynchronously) + + for lazy_func in listener.lazy_functions: + if request.lazy_function_name: + func_name = lazy_func.__name__ + if func_name == request.lazy_function_name: + self.lazy_listener_runner.run( + function=lazy_func, request=request + ) + # This HTTP response won't be sent to Slack API servers. + return BoltResponse(status=200) + else: + continue + else: + self._start_lazy_function(lazy_func, request) + + # await for the completion of ack() in the async listener execution + while ack.response is None and time.time() - starting_time <= 3: + time.sleep(0.01) + + if response is None and ack.response is None: + self.logger.warning(warning_did_not_call_ack(listener_name)) + return None + + if response is None and ack.response is not None: + response = ack.response + self._debug_log_completion(starting_time, response) + return response + + if response is not None: + return response + + # None for both means no ack() in the listener + return None + + def _start_lazy_function( + self, lazy_func: Callable[..., None], request: BoltRequest + ) -> None: + # Start a lazy function asynchronously + func_name: str = lazy_func.__name__ + self.logger.debug(debug_running_lazy_listener(func_name)) + copied_request = self._build_lazy_request(request, func_name) + self.lazy_listener_runner.start(function=lazy_func, request=copied_request) + + @staticmethod + def _build_lazy_request(request: BoltRequest, lazy_func_name: str) -> BoltRequest: + copied_request = create_copy(request) + copied_request.method = "NONE" + copied_request.lazy_only = True + copied_request.lazy_function_name = lazy_func_name + return copied_request + + def _debug_log_completion( + self, starting_time: float, response: BoltResponse + ) -> None: + millis = int((time.time() - starting_time) * 1000) + self.logger.debug(debug_responding(response.status, response.body, millis)) diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 9f7bc58c3..1434a46d0 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -19,6 +19,7 @@ is_dialog_suggestion, is_shortcut, to_action, + is_workflow_step_save, ) if sys.version_info.major == 3 and sys.version_info.minor <= 6: @@ -112,6 +113,19 @@ def func(body: Dict[str, Any]) -> bool: ) +def workflow_step_execute( + asyncio: bool = False, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + def func(body: Dict[str, Any]) -> bool: + return ( + is_event(body) + and _matches("workflow_step_execute", body["event"]["type"]) + and "workflow_step" in body["event"] + ) + + return build_listener_matcher(func, asyncio) + + # ------------- # slash commands @@ -347,6 +361,17 @@ def func(body: Dict[str, Any]) -> bool: return build_listener_matcher(func, asyncio) +def workflow_step_save( + callback_id: Union[str, Pattern], asyncio: bool = False, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + def func(body: Dict[str, Any]) -> bool: + return is_workflow_step_save(body) and _matches( + callback_id, body["view"]["callback_id"] + ) + + return build_listener_matcher(func, asyncio) + + # ------------- # options diff --git a/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py index c3f9ff3bd..63ae78342 100644 --- a/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py @@ -15,7 +15,7 @@ async def async_process( next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: auth_result = req.context.authorization_result - if self._is_self_event(auth_result, req.context.user_id): + if self._is_self_event(auth_result, req.context.user_id, req.body): self._debug_log(req.body) return await req.context.ack() else: diff --git a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py index 4f79aac81..7ba6fbf5f 100644 --- a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -1,5 +1,5 @@ import logging -from typing import Callable +from typing import Callable, Dict, Any from slack_bolt.authorization import AuthorizationResult from slack_bolt.logger import get_bolt_logger @@ -17,7 +17,7 @@ def process( self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], ) -> BoltResponse: auth_result = req.context.authorization_result - if self._is_self_event(auth_result, req.context.user_id): + if self._is_self_event(auth_result, req.context.user_id, req.body): self._debug_log(req.body) return req.context.ack() else: @@ -26,10 +26,16 @@ def process( # ----------------------------------------- @staticmethod - def _is_self_event(auth_result: AuthorizationResult, user_id: str): - return auth_result is not None and user_id == auth_result.bot_user_id + def _is_self_event( + auth_result: AuthorizationResult, user_id: str, body: Dict[str, Any] + ): + return ( + auth_result is not None + and user_id == auth_result.bot_user_id + and body.get("event") is not None + ) def _debug_log(self, body: dict): if self.logger.level <= logging.DEBUG: - event = body["event"] + event = body.get("event") self.logger.debug(f"Skipped self event: {event}") diff --git a/slack_bolt/util/payload_utils.py b/slack_bolt/util/payload_utils.py index a2ca8d204..a1b4da136 100644 --- a/slack_bolt/util/payload_utils.py +++ b/slack_bolt/util/payload_utils.py @@ -201,6 +201,10 @@ def is_view_closed(body: Dict[str, Any]) -> bool: ) +def is_workflow_step_save(body: Dict[str, Any]) -> bool: + return is_view_submission(body) and body["view"]["type"] == "workflow_step" + + # ------------------------------------------ # Internal Utilities # ------------------------------------------ diff --git a/tests/slack_bolt/kwargs_injection/test_args.py b/tests/slack_bolt/kwargs_injection/test_args.py index 93f74071d..dbee91bbe 100644 --- a/tests/slack_bolt/kwargs_injection/test_args.py +++ b/tests/slack_bolt/kwargs_injection/test_args.py @@ -40,3 +40,19 @@ def test_build(self): assert args.request is not None assert args.response is not None assert args.client is not None + + def test_all_values_from_context(self): + req = BoltRequest(body="", headers={}) + req.context["foo"] = "FOO" + req.context["bar"] = 123 + required_args = ["foo", "bar", "ack"] + arg_params: dict = build_required_kwargs( + logger=logging.getLogger(__name__), + required_arg_names=required_args, + request=req, + response=BoltResponse(status=200), + next_func=next, + ) + assert arg_params["foo"] == "FOO" + assert arg_params["bar"] == 123 + assert arg_params["ack"] is not None diff --git a/tests/slack_bolt_async/kwargs_injection/test_async_args.py b/tests/slack_bolt_async/kwargs_injection/test_async_args.py index 45422a5e8..e21e9de6f 100644 --- a/tests/slack_bolt_async/kwargs_injection/test_async_args.py +++ b/tests/slack_bolt_async/kwargs_injection/test_async_args.py @@ -42,3 +42,19 @@ def test_build(self): assert args.request is not None assert args.response is not None assert args.client is not None + + def test_all_values_from_context(self): + req = AsyncBoltRequest(body="", headers={}) + req.context["foo"] = "FOO" + req.context["bar"] = 123 + required_args = ["foo", "bar", "ack"] + arg_params: dict = build_async_required_kwargs( + logger=logging.getLogger(__name__), + required_arg_names=required_args, + request=req, + response=BoltResponse(status=200), + next_func=next, + ) + assert arg_params["foo"] == "FOO" + assert arg_params["bar"] == 123 + assert arg_params["ack"] is not None From 228a4414fedfbcb5e512101ada8953c58d704889 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 24 Sep 2020 16:51:01 +0900 Subject: [PATCH 101/865] Change type hints for bot related fields in AuthorizationResult to optional --- slack_bolt/authorization/authorization_result.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/slack_bolt/authorization/authorization_result.py b/slack_bolt/authorization/authorization_result.py index 62c222873..c0e261e9d 100644 --- a/slack_bolt/authorization/authorization_result.py +++ b/slack_bolt/authorization/authorization_result.py @@ -6,9 +6,9 @@ class AuthorizationResult(dict): enterprise_id: Optional[str] team_id: Optional[str] - bot_id: str - bot_user_id: str - bot_token: str + bot_id: Optional[str] + bot_user_id: Optional[str] + bot_token: Optional[str] user_id: Optional[str] user_token: Optional[str] @@ -18,9 +18,9 @@ def __init__( enterprise_id: Optional[str], team_id: Optional[str], # bot - bot_user_id: str, - bot_id: str, - bot_token: str, + bot_user_id: Optional[str] = None, + bot_id: Optional[str] = None, + bot_token: Optional[str] = None, # user user_id: Optional[str] = None, user_token: Optional[str] = None, From 5a967c085569603f6df418b3539b0b62aa3e4503 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 24 Sep 2020 22:55:50 +0900 Subject: [PATCH 102/865] version 0.6.2a0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 09dd12d3b..035d5aeea 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.6.1a0" +__version__ = "0.6.2a0" From 88f6f1c43ad58a8576b5f597ded0d434708d4fea Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 25 Sep 2020 12:04:52 +0900 Subject: [PATCH 103/865] Rename AuthorizationResult to AuthorizeResult Also, replaced dict.get(key, None) with dict.get(key) --- samples/app_authorize.py | 4 +-- samples/async_app_authorize.py | 4 +-- samples/aws_chalice/app.py | 2 +- samples/aws_chalice/simple_app.py | 2 +- slack_bolt/adapter/aiohttp/__init__.py | 8 ++--- slack_bolt/adapter/aws_lambda/handler.py | 2 +- slack_bolt/adapter/cherrypy/handler.py | 8 ++--- slack_bolt/adapter/django/handler.py | 8 ++--- slack_bolt/adapter/falcon/resource.py | 8 ++--- slack_bolt/adapter/sanic/async_handler.py | 8 ++--- slack_bolt/adapter/starlette/async_handler.py | 8 ++--- slack_bolt/adapter/starlette/handler.py | 8 ++--- slack_bolt/adapter/tornado/handler.py | 8 ++--- slack_bolt/app/app.py | 8 ++--- slack_bolt/app/async_app.py | 8 ++--- slack_bolt/authorization/__init__.py | 2 +- slack_bolt/authorization/async_authorize.py | 20 ++++++----- slack_bolt/authorization/authorize.py | 18 ++++++---- ...rization_result.py => authorize_result.py} | 6 ++-- slack_bolt/context/base_context.py | 18 +++++----- .../authorization/async_internals.py | 6 ++-- .../async_multi_teams_authorization.py | 6 ++-- .../async_single_team_authorization.py | 6 ++-- .../middleware/authorization/internals.py | 34 +++++++++++-------- .../multi_teams_authorization.py | 6 ++-- .../single_team_authorization.py | 6 ++-- .../async_ignoring_self_events.py | 2 +- .../ignoring_self_events.py | 6 ++-- .../request_verification.py | 2 +- .../url_verification/url_verification.py | 2 +- slack_bolt/oauth/async_oauth_flow.py | 24 ++++++------- slack_bolt/oauth/async_oauth_settings.py | 4 +-- slack_bolt/oauth/oauth_flow.py | 24 ++++++------- slack_bolt/oauth/oauth_settings.py | 4 +-- slack_bolt/request/internals.py | 2 +- tests/scenario_tests/test_authorize.py | 4 +-- tests/scenario_tests_async/test_authorize.py | 4 +-- 37 files changed, 155 insertions(+), 145 deletions(-) rename slack_bolt/authorization/{authorization_result.py => authorize_result.py} (95%) diff --git a/samples/app_authorize.py b/samples/app_authorize.py index 8048a0ad7..0b81e9b66 100644 --- a/samples/app_authorize.py +++ b/samples/app_authorize.py @@ -11,14 +11,14 @@ import os from slack_bolt import App -from slack_bolt.authorization import AuthorizationResult +from slack_bolt.authorization import AuthorizeResult from slack_sdk import WebClient def authorize(enterprise_id, team_id, user_id, client: WebClient, logger): logger.info(f"{enterprise_id},{team_id},{user_id}") # You can implement your own logic here token = os.environ["MY_TOKEN"] - return AuthorizationResult.from_auth_test_response( + return AuthorizeResult.from_auth_test_response( auth_test_response=client.auth_test(token=token), bot_token=token, ) diff --git a/samples/async_app_authorize.py b/samples/async_app_authorize.py index 515659118..aee349331 100644 --- a/samples/async_app_authorize.py +++ b/samples/async_app_authorize.py @@ -11,14 +11,14 @@ import os from slack_sdk.web.async_client import AsyncWebClient -from slack_bolt.authorization import AuthorizationResult +from slack_bolt.authorization import AuthorizeResult from slack_bolt.async_app import AsyncApp async def authorize(enterprise_id, team_id, user_id, client: AsyncWebClient, logger): logger.info(f"{enterprise_id},{team_id},{user_id}") # You can implement your own logic here token = os.environ["MY_TOKEN"] - return AuthorizationResult.from_auth_test_response( + return AuthorizeResult.from_auth_test_response( auth_test_response=await client.auth_test(token=token), bot_token=token, ) diff --git a/samples/aws_chalice/app.py b/samples/aws_chalice/app.py index 5341fd54a..59facf400 100644 --- a/samples/aws_chalice/app.py +++ b/samples/aws_chalice/app.py @@ -7,7 +7,7 @@ from slack_bolt.adapter.aws_lambda.chalice_handler import ChaliceSlackRequestHandler # process_before_response must be True when running on FaaS -bolt_app = App(process_before_response=True, authorization_test_enabled=False,) +bolt_app = App(process_before_response=True) @bolt_app.event("app_mention") diff --git a/samples/aws_chalice/simple_app.py b/samples/aws_chalice/simple_app.py index 10b9be077..a4423e5fb 100644 --- a/samples/aws_chalice/simple_app.py +++ b/samples/aws_chalice/simple_app.py @@ -6,7 +6,7 @@ from slack_bolt.adapter.aws_lambda.chalice_handler import ChaliceSlackRequestHandler # process_before_response must be True when running on FaaS -bolt_app = App(process_before_response=True, authorization_test_enabled=False,) +bolt_app = App(process_before_response=True) @bolt_app.event("app_mention") diff --git a/slack_bolt/adapter/aiohttp/__init__.py b/slack_bolt/adapter/aiohttp/__init__.py index f4d347d3f..6d4cb9715 100644 --- a/slack_bolt/adapter/aiohttp/__init__.py +++ b/slack_bolt/adapter/aiohttp/__init__.py @@ -29,10 +29,10 @@ async def to_aiohttp_response(bolt_resp: BoltResponse) -> web.Response: resp.set_cookie( name=name, value=c.value, - max_age=c.get("max-age", None), - expires=c.get("expires", None), - path=c.get("path", None), - domain=c.get("domain", None), + max_age=c.get("max-age"), + expires=c.get("expires"), + path=c.get("path"), + domain=c.get("domain"), secure=True, httponly=True, ) diff --git a/slack_bolt/adapter/aws_lambda/handler.py b/slack_bolt/adapter/aws_lambda/handler.py index 93f8c79ae..0134c6011 100644 --- a/slack_bolt/adapter/aws_lambda/handler.py +++ b/slack_bolt/adapter/aws_lambda/handler.py @@ -30,7 +30,7 @@ def clear_all_log_handlers(cls): def handle(self, event, context): self.logger.debug(f"Incoming event: {event}, context: {context}") - method = event.get("requestContext", {}).get("http", {}).get("method", None) + method = event.get("requestContext", {}).get("http", {}).get("method") if method is None: return not_found() if method == "GET": diff --git a/slack_bolt/adapter/cherrypy/handler.py b/slack_bolt/adapter/cherrypy/handler.py index ae6029179..bd8dd0dc3 100644 --- a/slack_bolt/adapter/cherrypy/handler.py +++ b/slack_bolt/adapter/cherrypy/handler.py @@ -20,14 +20,14 @@ def set_response_status_and_headers(bolt_resp: BoltResponse) -> None: cherrypy.response.headers[k] = v for cookie in bolt_resp.cookies(): for name, c in cookie.items(): - str_max_age: Optional[str] = c.get("max-age", None) + str_max_age: Optional[str] = c.get("max-age") max_age: Optional[int] = int(str_max_age) if str_max_age else None cherrypy_cookie = cherrypy.response.cookie cherrypy_cookie[name] = c.value - cherrypy_cookie[name]["expires"] = c.get("expires", None) + cherrypy_cookie[name]["expires"] = c.get("expires") cherrypy_cookie[name]["max-age"] = max_age - cherrypy_cookie[name]["domain"] = c.get("domain", None) - cherrypy_cookie[name]["path"] = c.get("path", None) + cherrypy_cookie[name]["domain"] = c.get("domain") + cherrypy_cookie[name]["path"] = c.get("path") cherrypy_cookie[name]["secure"] = True cherrypy_cookie[name]["httponly"] = True diff --git a/slack_bolt/adapter/django/handler.py b/slack_bolt/adapter/django/handler.py index 58e42edcd..1749b67de 100644 --- a/slack_bolt/adapter/django/handler.py +++ b/slack_bolt/adapter/django/handler.py @@ -23,15 +23,15 @@ def to_django_response(bolt_resp: BoltResponse) -> HttpResponse: for cookie in bolt_resp.cookies(): for name, c in cookie.items(): - str_max_age: Optional[str] = c.get("max-age", None) + str_max_age: Optional[str] = c.get("max-age") max_age: Optional[int] = int(str_max_age) if str_max_age else None resp.set_cookie( key=name, value=c.value, - expires=c.get("expires", None), + expires=c.get("expires"), max_age=max_age, - domain=c.get("domain", None), - path=c.get("path", None), + domain=c.get("domain"), + path=c.get("path"), secure=True, httponly=True, ) diff --git a/slack_bolt/adapter/falcon/resource.py b/slack_bolt/adapter/falcon/resource.py index 29ec02867..dec1663a3 100644 --- a/slack_bolt/adapter/falcon/resource.py +++ b/slack_bolt/adapter/falcon/resource.py @@ -56,7 +56,7 @@ def _write_response(self, bolt_resp: BoltResponse, resp: Response): resp.set_headers(bolt_resp.first_headers_without_set_cookie()) for cookie in bolt_resp.cookies(): for name, c in cookie.items(): - expire_value = c.get("expires", None) + expire_value = c.get("expires") expire = ( datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value @@ -66,9 +66,9 @@ def _write_response(self, bolt_resp: BoltResponse, resp: Response): name=name, value=c.value, expires=expire, - max_age=c.get("max-age", None), - domain=c.get("domain", None), - path=c.get("path", None), + max_age=c.get("max-age"), + domain=c.get("domain"), + path=c.get("path"), secure=True, http_only=True, ) diff --git a/slack_bolt/adapter/sanic/async_handler.py b/slack_bolt/adapter/sanic/async_handler.py index d123dd9d9..a18af2250 100644 --- a/slack_bolt/adapter/sanic/async_handler.py +++ b/slack_bolt/adapter/sanic/async_handler.py @@ -23,13 +23,13 @@ def to_sanic_response(bolt_resp: BoltResponse) -> HTTPResponse: for cookie in bolt_resp.cookies(): for name, c in cookie.items(): resp.cookies[name] = c.value - expire_value = c.get("expires", None) + expire_value = c.get("expires") if expire_value is not None and expire_value != "": expire = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") resp.cookies[name]["expires"] = expire - resp.cookies[name]["path"] = c.get("path", None) - resp.cookies[name]["domain"] = c.get("domain", None) - resp.cookies[name]["max-age"] = c.get("max-age", None) + resp.cookies[name]["path"] = c.get("path") + resp.cookies[name]["domain"] = c.get("domain") + resp.cookies[name]["max-age"] = c.get("max-age") resp.cookies[name]["secure"] = True resp.cookies[name]["httponly"] = True return resp diff --git a/slack_bolt/adapter/starlette/async_handler.py b/slack_bolt/adapter/starlette/async_handler.py index 4262d51d5..a4fc0464b 100644 --- a/slack_bolt/adapter/starlette/async_handler.py +++ b/slack_bolt/adapter/starlette/async_handler.py @@ -23,10 +23,10 @@ def to_starlette_response(bolt_resp: BoltResponse) -> Response: resp.set_cookie( key=name, value=c.value, - max_age=c.get("max-age", None), - expires=c.get("expires", None), - path=c.get("path", None), - domain=c.get("domain", None), + max_age=c.get("max-age"), + expires=c.get("expires"), + path=c.get("path"), + domain=c.get("domain"), secure=True, httponly=True, ) diff --git a/slack_bolt/adapter/starlette/handler.py b/slack_bolt/adapter/starlette/handler.py index 23cd2887f..e5e4f6057 100644 --- a/slack_bolt/adapter/starlette/handler.py +++ b/slack_bolt/adapter/starlette/handler.py @@ -22,10 +22,10 @@ def to_starlette_response(bolt_resp: BoltResponse) -> Response: resp.set_cookie( key=name, value=c.value, - max_age=c.get("max-age", None), - expires=c.get("expires", None), - path=c.get("path", None), - domain=c.get("domain", None), + max_age=c.get("max-age"), + expires=c.get("expires"), + path=c.get("path"), + domain=c.get("domain"), secure=True, httponly=True, ) diff --git a/slack_bolt/adapter/tornado/handler.py b/slack_bolt/adapter/tornado/handler.py index 272cb1a11..47e06ba9d 100644 --- a/slack_bolt/adapter/tornado/handler.py +++ b/slack_bolt/adapter/tornado/handler.py @@ -54,7 +54,7 @@ def set_response(self, bolt_resp) -> None: self.set_header(name, value) for cookie in bolt_resp.cookies(): for name, c in cookie.items(): - expire_value = c.get("expires", None) + expire_value = c.get("expires") expire = ( datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value @@ -63,10 +63,10 @@ def set_response(self, bolt_resp) -> None: self.set_cookie( name=name, value=c.value, - max_age=c.get("max-age", None), + max_age=c.get("max-age"), expires=expire, - path=c.get("path", None), - domain=c.get("domain", None), + path=c.get("path"), + domain=c.get("domain"), secure=True, httponly=True, ) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 418e548e5..3b25a4836 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -11,7 +11,7 @@ from slack_sdk.oauth.installation_store import InstallationStore from slack_sdk.web import WebClient -from slack_bolt.authorization import AuthorizationResult +from slack_bolt.authorization import AuthorizeResult from slack_bolt.authorization.authorize import ( Authorize, InstallationStoreAuthorize, @@ -74,7 +74,7 @@ def __init__( token: Optional[str] = None, client: Optional[WebClient] = None, # for multi-workspace apps - authorize: Optional[Callable[..., AuthorizationResult]] = None, + authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[InstallationStore] = None, # for the OAuth flow oauth_settings: Optional[OAuthSettings] = None, @@ -99,8 +99,8 @@ def __init__( :param verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. """ - signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", None) - token = token or os.environ.get("SLACK_BOT_TOKEN", None) + signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") + token = token or os.environ.get("SLACK_BOT_TOKEN") if signing_secret is None or signing_secret == "": raise BoltError(error_signing_secret_not_found()) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 160d12041..6652b81ae 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -12,7 +12,7 @@ ) from slack_sdk.web.async_client import AsyncWebClient -from slack_bolt.authorization import AuthorizationResult +from slack_bolt.authorization import AuthorizeResult from slack_bolt.authorization.async_authorize import ( AsyncAuthorize, AsyncCallableAuthorize, @@ -81,7 +81,7 @@ def __init__( client: Optional[AsyncWebClient] = None, # for multi-workspace apps installation_store: Optional[AsyncInstallationStore] = None, - authorize: Optional[Callable[..., Awaitable[AuthorizationResult]]] = None, + authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, # for the OAuth flow oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, @@ -105,8 +105,8 @@ def __init__( :param verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. """ - signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", None) - token = token or os.environ.get("SLACK_BOT_TOKEN", None) + signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") + token = token or os.environ.get("SLACK_BOT_TOKEN") if signing_secret is None or signing_secret == "": raise BoltError(error_signing_secret_not_found()) diff --git a/slack_bolt/authorization/__init__.py b/slack_bolt/authorization/__init__.py index 46cb6b30c..055f3a2aa 100644 --- a/slack_bolt/authorization/__init__.py +++ b/slack_bolt/authorization/__init__.py @@ -1 +1 @@ -from .authorization_result import AuthorizationResult +from .authorize_result import AuthorizeResult diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index a7717a2ef..86eb563cd 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -9,7 +9,7 @@ ) from slack_bolt.authorization.async_authorize_args import AsyncAuthorizeArgs -from slack_bolt.authorization import AuthorizationResult +from slack_bolt.authorization import AuthorizeResult from slack_bolt.context.async_context import AsyncBoltContext @@ -24,13 +24,13 @@ async def __call__( enterprise_id: Optional[str], team_id: str, user_id: Optional[str], - ) -> Optional[AuthorizationResult]: + ) -> Optional[AuthorizeResult]: raise NotImplementedError() class AsyncCallableAuthorize(AsyncAuthorize): def __init__( - self, *, logger: Logger, func: Callable[..., Awaitable[AuthorizationResult]] + self, *, logger: Logger, func: Callable[..., Awaitable[AuthorizeResult]] ): self.logger = logger self.func = func @@ -43,7 +43,7 @@ async def __call__( enterprise_id: Optional[str], team_id: str, user_id: Optional[str], - ) -> Optional[AuthorizationResult]: + ) -> Optional[AuthorizeResult]: try: all_available_args = { "args": AsyncAuthorizeArgs( @@ -59,6 +59,10 @@ async def __call__( "team_id": team_id, "user_id": user_id, } + for k, v in context.items(): + if k not in all_available_args: + all_available_args[k] = v + kwargs: Dict[str, Any] = { # type: ignore k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore } @@ -68,11 +72,11 @@ async def __call__( self.logger.warning(f"{name} is not a valid argument") kwargs[name] = None - auth_result: Optional[AuthorizationResult] = await self.func(**kwargs) + auth_result: Optional[AuthorizeResult] = await self.func(**kwargs) if auth_result is None: return auth_result - if isinstance(auth_result, AuthorizationResult): + if isinstance(auth_result, AuthorizeResult): return auth_result else: raise ValueError( @@ -100,7 +104,7 @@ async def __call__( enterprise_id: Optional[str], team_id: str, user_id: Optional[str], - ) -> Optional[AuthorizationResult]: + ) -> Optional[AuthorizeResult]: bot: Optional[Bot] = await self.installation_store.async_find_bot( enterprise_id=enterprise_id, team_id=team_id, ) @@ -113,7 +117,7 @@ async def __call__( try: auth_result = await context.client.auth_test(token=bot.bot_token) - return AuthorizationResult.from_auth_test_response( + return AuthorizeResult.from_auth_test_response( auth_test_response=auth_result, bot_token=bot.bot_token, user_token=None, # Not yet supported diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 03f52c5bb..47e923af4 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -7,7 +7,7 @@ from slack_sdk.oauth.installation_store import Bot from slack_bolt.authorization.authorize_args import AuthorizeArgs -from slack_bolt.authorization.authorization_result import AuthorizationResult +from slack_bolt.authorization.authorize_result import AuthorizeResult from slack_bolt.context.context import BoltContext @@ -22,13 +22,13 @@ def __call__( enterprise_id: Optional[str], team_id: str, user_id: Optional[str], - ) -> Optional[AuthorizationResult]: + ) -> Optional[AuthorizeResult]: raise NotImplementedError() class CallableAuthorize(Authorize): def __init__( - self, *, logger: Logger, func: Callable[..., AuthorizationResult], + self, *, logger: Logger, func: Callable[..., AuthorizeResult], ): self.logger = logger self.func = func @@ -41,7 +41,7 @@ def __call__( enterprise_id: Optional[str], team_id: str, user_id: Optional[str], - ) -> Optional[AuthorizationResult]: + ) -> Optional[AuthorizeResult]: try: all_available_args = { "args": AuthorizeArgs( @@ -57,6 +57,10 @@ def __call__( "team_id": team_id, "user_id": user_id, } + for k, v in context.items(): + if k not in all_available_args: + all_available_args[k] = v + kwargs: Dict[str, Any] = { # type: ignore k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore } @@ -70,7 +74,7 @@ def __call__( if auth_result is None: return auth_result - if isinstance(auth_result, AuthorizationResult): + if isinstance(auth_result, AuthorizeResult): return auth_result else: raise ValueError( @@ -98,7 +102,7 @@ def __call__( enterprise_id: Optional[str], team_id: str, user_id: Optional[str], - ) -> Optional[AuthorizationResult]: + ) -> Optional[AuthorizeResult]: bot: Optional[Bot] = self.installation_store.find_bot( enterprise_id=enterprise_id, team_id=team_id, ) @@ -111,7 +115,7 @@ def __call__( try: auth_result = context.client.auth_test(token=bot.bot_token) - return AuthorizationResult.from_auth_test_response( + return AuthorizeResult.from_auth_test_response( auth_test_response=auth_result, bot_token=bot.bot_token, user_token=None, # Not yet supported diff --git a/slack_bolt/authorization/authorization_result.py b/slack_bolt/authorization/authorize_result.py similarity index 95% rename from slack_bolt/authorization/authorization_result.py rename to slack_bolt/authorization/authorize_result.py index c0e261e9d..f38fbdf92 100644 --- a/slack_bolt/authorization/authorization_result.py +++ b/slack_bolt/authorization/authorize_result.py @@ -3,7 +3,7 @@ from slack_sdk.web import SlackResponse -class AuthorizationResult(dict): +class AuthorizeResult(dict): enterprise_id: Optional[str] team_id: Optional[str] bot_id: Optional[str] @@ -52,8 +52,8 @@ def from_auth_test_response( bot_token: Optional[str] = None, user_token: Optional[str] = None, auth_test_response: SlackResponse, - ) -> "AuthorizationResult": - return AuthorizationResult( + ) -> "AuthorizeResult": + return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), bot_user_id=auth_test_response.get("user_id"), diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index c7ae762a3..6f21cd7bd 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -1,13 +1,13 @@ from logging import Logger from typing import Optional, Tuple -from slack_bolt.authorization import AuthorizationResult +from slack_bolt.authorization import AuthorizeResult class BaseContext(dict): @property - def authorization_result(self) -> Optional[AuthorizationResult]: - return self.get("authorization_result", None) + def authorize_result(self) -> Optional[AuthorizeResult]: + return self.get("authorize_result") @property def logger(self) -> Logger: @@ -15,11 +15,11 @@ def logger(self) -> Logger: @property def token(self) -> Optional[str]: - return self.get("token", None) + return self.get("token") @property def enterprise_id(self) -> Optional[str]: - return self.get("enterprise_id", None) + return self.get("enterprise_id") @property def team_id(self) -> str: @@ -27,17 +27,17 @@ def team_id(self) -> str: @property def user_id(self) -> Optional[str]: - return self.get("user_id", None) + return self.get("user_id") @property def channel_id(self) -> Optional[str]: - return self.get("channel_id", None) + return self.get("channel_id") @property def response_url(self) -> Optional[str]: - return self.get("response_url", None) + return self.get("response_url") @property def matches(self) -> Optional[Tuple]: """Returns all the matched parts in message listener's regexp""" - return self.get("matches", None) + return self.get("matches") diff --git a/slack_bolt/middleware/authorization/async_internals.py b/slack_bolt/middleware/authorization/async_internals.py index 376bc62b4..6a9d1b696 100644 --- a/slack_bolt/middleware/authorization/async_internals.py +++ b/slack_bolt/middleware/authorization/async_internals.py @@ -6,15 +6,13 @@ def _is_url_verification(req: AsyncBoltRequest) -> bool: return ( req is not None and req.body is not None - and req.body.get("type", None) == "url_verification" + and req.body.get("type") == "url_verification" ) def _is_ssl_check(req: AsyncBoltRequest) -> bool: return ( - req is not None - and req.body is not None - and req.body.get("type", None) == "ssl_check" + req is not None and req.body is not None and req.body.get("type") == "ssl_check" ) diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index e9930396f..2dbac0a90 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -6,7 +6,7 @@ from slack_bolt.response import BoltResponse from .async_authorization import AsyncAuthorization from .async_internals import _build_error_response, _is_no_auth_required -from ...authorization import AuthorizationResult +from ...authorization import AuthorizeResult from ...authorization.async_authorize import AsyncAuthorize from ...util.async_utils import create_async_web_client @@ -32,14 +32,14 @@ async def async_process( if _is_no_auth_required(req): return await next() try: - auth_result: Optional[AuthorizationResult] = await self.authorize( + auth_result: Optional[AuthorizeResult] = await self.authorize( context=req.context, enterprise_id=req.context.enterprise_id, team_id=req.context.team_id, user_id=req.context.user_id, ) if auth_result: - req.context["authorization_result"] = auth_result + req.context["authorize_result"] = auth_result token = auth_result.bot_token or auth_result.user_token req.context["token"] = token req.context["client"] = create_async_web_client(token) diff --git a/slack_bolt/middleware/authorization/async_single_team_authorization.py b/slack_bolt/middleware/authorization/async_single_team_authorization.py index ec4491eb6..5ca888dc1 100644 --- a/slack_bolt/middleware/authorization/async_single_team_authorization.py +++ b/slack_bolt/middleware/authorization/async_single_team_authorization.py @@ -7,7 +7,7 @@ from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.errors import SlackApiError from .async_internals import _build_error_response, _is_no_auth_required -from .internals import _to_authorization_result +from .internals import _to_authorize_result class AsyncSingleTeamAuthorization(AsyncAuthorization): @@ -31,9 +31,9 @@ async def async_process( self.auth_result = await req.context.client.auth_test() if self.auth_result: - req.context["authorization_result"] = _to_authorization_result( + req.context["authorize_result"] = _to_authorize_result( auth_test_result=self.auth_result, - bot_token=req.context.client.token, + token=req.context.client.token, request_user_id=req.context.user_id, ) return await next() diff --git a/slack_bolt/middleware/authorization/internals.py b/slack_bolt/middleware/authorization/internals.py index 80667f92d..3ec2d9d39 100644 --- a/slack_bolt/middleware/authorization/internals.py +++ b/slack_bolt/middleware/authorization/internals.py @@ -2,7 +2,7 @@ from slack_sdk.web import SlackResponse -from slack_bolt.authorization import AuthorizationResult +from slack_bolt.authorization import AuthorizeResult from slack_bolt.request.request import BoltRequest from slack_bolt.response import BoltResponse @@ -11,15 +11,13 @@ def _is_url_verification(req: BoltRequest) -> bool: return ( req is not None and req.body is not None - and req.body.get("type", None) == "url_verification" + and req.body.get("type") == "url_verification" ) def _is_ssl_check(req: BoltRequest) -> bool: return ( - req is not None - and req.body is not None - and req.body.get("type", None) == "ssl_check" + req is not None and req.body is not None and req.body.get("type") == "ssl_check" ) @@ -34,16 +32,22 @@ def _build_error_response() -> BoltResponse: ) -def _to_authorization_result( # type: ignore +def _is_bot_token(token: Optional[str]) -> bool: + return token is not None and token.startswith("xoxb-") + + +def _to_authorize_result( # type: ignore auth_test_result: Union[SlackResponse, "AsyncSlackResponse"], - bot_token: str, + token: Optional[str], request_user_id: Optional[str], -): - return AuthorizationResult( - enterprise_id=auth_test_result.get("enterprise_id", None), - team_id=auth_test_result.get("team_id", None), - bot_user_id=auth_test_result.get("user_id", None), - bot_id=auth_test_result.get("bot_id", None), - bot_token=bot_token, - user_id=request_user_id, +) -> AuthorizeResult: + user_id = auth_test_result.get("user_id") + return AuthorizeResult( + enterprise_id=auth_test_result.get("enterprise_id"), + team_id=auth_test_result.get("team_id"), + bot_id=auth_test_result.get("bot_id"), + bot_user_id=user_id if _is_bot_token(token) else None, + bot_token=token if _is_bot_token(token) else None, + user_id=request_user_id or (user_id if not _is_bot_token(token) else None), + user_token=token if not _is_bot_token(token) else None, ) diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index 224b8214c..6eae78e94 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -6,7 +6,7 @@ from slack_sdk.errors import SlackApiError from .authorization import Authorization from .internals import _build_error_response, _is_no_auth_required -from ...authorization import AuthorizationResult +from ...authorization import AuthorizeResult from ...authorization.authorize import Authorize from ...util.utils import create_web_client @@ -30,7 +30,7 @@ def process( if _is_no_auth_required(req): return next() try: - auth_result: Optional[AuthorizationResult] = self.authorize( + auth_result: Optional[AuthorizeResult] = self.authorize( context=req.context, enterprise_id=req.context.enterprise_id, team_id=req.context.team_id, @@ -38,7 +38,7 @@ def process( ) self.logger.info(auth_result) if auth_result is not None: - req.context["authorization_result"] = auth_result + req.context["authorize_result"] = auth_result token = auth_result.bot_token or auth_result.user_token req.context["token"] = token req.context["client"] = create_web_client(token) diff --git a/slack_bolt/middleware/authorization/single_team_authorization.py b/slack_bolt/middleware/authorization/single_team_authorization.py index 823068b4d..f59e4414e 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -9,7 +9,7 @@ from .internals import ( _build_error_response, _is_no_auth_required, - _to_authorization_result, + _to_authorize_result, ) @@ -33,9 +33,9 @@ def process( self.auth_test_result = req.context.client.auth_test() if self.auth_test_result: - req.context["authorization_result"] = _to_authorization_result( + req.context["authorize_result"] = _to_authorize_result( auth_test_result=self.auth_test_result, - bot_token=req.context.client.token, + token=req.context.client.token, request_user_id=req.context.user_id, ) return next() diff --git a/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py index 63ae78342..2d43ec372 100644 --- a/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py @@ -14,7 +14,7 @@ async def async_process( resp: BoltResponse, next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: - auth_result = req.context.authorization_result + auth_result = req.context.authorize_result if self._is_self_event(auth_result, req.context.user_id, req.body): self._debug_log(req.body) return await req.context.ack() diff --git a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py index 7ba6fbf5f..0c42141a3 100644 --- a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -1,7 +1,7 @@ import logging from typing import Callable, Dict, Any -from slack_bolt.authorization import AuthorizationResult +from slack_bolt.authorization import AuthorizeResult from slack_bolt.logger import get_bolt_logger from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse @@ -16,7 +16,7 @@ def __init__(self): def process( self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], ) -> BoltResponse: - auth_result = req.context.authorization_result + auth_result = req.context.authorize_result if self._is_self_event(auth_result, req.context.user_id, req.body): self._debug_log(req.body) return req.context.ack() @@ -27,7 +27,7 @@ def process( @staticmethod def _is_self_event( - auth_result: AuthorizationResult, user_id: str, body: Dict[str, Any] + auth_result: AuthorizeResult, user_id: str, body: Dict[str, Any] ): return ( auth_result is not None diff --git a/slack_bolt/middleware/request_verification/request_verification.py b/slack_bolt/middleware/request_verification/request_verification.py index 3af1c450e..8c91e65f2 100644 --- a/slack_bolt/middleware/request_verification/request_verification.py +++ b/slack_bolt/middleware/request_verification/request_verification.py @@ -37,7 +37,7 @@ def process( @staticmethod def _can_skip(body: Dict[str, Any]) -> bool: - return body is not None and body.get("ssl_check", None) == "1" + return body is not None and body.get("ssl_check") == "1" @staticmethod def _build_error_response() -> BoltResponse: diff --git a/slack_bolt/middleware/url_verification/url_verification.py b/slack_bolt/middleware/url_verification/url_verification.py index c4f2c5ea3..04e95d2fd 100644 --- a/slack_bolt/middleware/url_verification/url_verification.py +++ b/slack_bolt/middleware/url_verification/url_verification.py @@ -26,7 +26,7 @@ def process( @staticmethod def _is_url_verification_request(body: dict) -> bool: - return body is not None and body.get("type", None) == "url_verification" + return body is not None and body.get("type") == "url_verification" @staticmethod def _build_success_response(body: dict) -> BoltResponse: diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index e762e9b1f..3ac865ca5 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -111,7 +111,7 @@ def sqlite3( client_secret = client_secret or os.environ["SLACK_CLIENT_SECRET"] # required scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",") - redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) + redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") return AsyncOAuthFlow( client=client or AsyncWebClient(), logger=logger, @@ -277,7 +277,7 @@ async def run_installation(self, code: str) -> Optional[Installation]: "incoming_webhook" ) or {} - bot_token: Optional[str] = oauth_response.get("access_token", None) + bot_token: Optional[str] = oauth_response.get("access_token") # NOTE: oauth.v2.access doesn't include bot_id in response bot_id: Optional[str] = None if bot_token is not None: @@ -285,18 +285,18 @@ async def run_installation(self, code: str) -> Optional[Installation]: bot_id = auth_test["bot_id"] return Installation( - app_id=oauth_response.get("app_id", None), - enterprise_id=installed_enterprise.get("id", None), - team_id=installed_team.get("id", None), + app_id=oauth_response.get("app_id"), + enterprise_id=installed_enterprise.get("id"), + team_id=installed_team.get("id"), bot_token=bot_token, bot_id=bot_id, - bot_user_id=oauth_response.get("bot_user_id", None), - bot_scopes=oauth_response.get("scope", None), # comma-separated string - user_id=installer.get("id", None), - user_token=installer.get("access_token", None), - user_scopes=installer.get("scope", None), # comma-separated string - incoming_webhook_url=incoming_webhook.get("url", None), - incoming_webhook_channel_id=incoming_webhook.get("channel_id", None), + bot_user_id=oauth_response.get("bot_user_id"), + bot_scopes=oauth_response.get("scope"), # comma-separated string + user_id=installer.get("id"), + user_token=installer.get("access_token"), + user_scopes=installer.get("scope"), # comma-separated string + incoming_webhook_url=incoming_webhook.get("url"), + incoming_webhook_channel_id=incoming_webhook.get("channel_id"), incoming_webhook_configuration_url=incoming_webhook.get( "configuration_url", None ), diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index 3b3018575..dfff05482 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -96,7 +96,7 @@ def __init__( :param logger: The logger that will be used internally """ # OAuth flow parameters/credentials - self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID", None) + self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID") self.client_secret = client_secret or os.environ.get( "SLACK_CLIENT_SECRET", None ) @@ -107,7 +107,7 @@ def __init__( self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( "," ) - self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) + self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") # Handler configuration self.install_path = install_path or os.environ.get( "SLACK_INSTALL_PATH", "/slack/install" diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index 96c7ee2b6..8d37709d0 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -111,7 +111,7 @@ def sqlite3( client_secret = client_secret or os.environ["SLACK_CLIENT_SECRET"] # required scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",") - redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) + redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") return OAuthFlow( client=client or WebClient(), logger=logger, @@ -277,7 +277,7 @@ def run_installation(self, code: str) -> Optional[Installation]: "incoming_webhook" ) or {} - bot_token: Optional[str] = oauth_response.get("access_token", None) + bot_token: Optional[str] = oauth_response.get("access_token") # NOTE: oauth.v2.access doesn't include bot_id in response bot_id: Optional[str] = None if bot_token is not None: @@ -285,18 +285,18 @@ def run_installation(self, code: str) -> Optional[Installation]: bot_id = auth_test["bot_id"] return Installation( - app_id=oauth_response.get("app_id", None), - enterprise_id=installed_enterprise.get("id", None), - team_id=installed_team.get("id", None), + app_id=oauth_response.get("app_id"), + enterprise_id=installed_enterprise.get("id"), + team_id=installed_team.get("id"), bot_token=bot_token, bot_id=bot_id, - bot_user_id=oauth_response.get("bot_user_id", None), - bot_scopes=oauth_response.get("scope", None), # comma-separated string - user_id=installer.get("id", None), - user_token=installer.get("access_token", None), - user_scopes=installer.get("scope", None), # comma-separated string - incoming_webhook_url=incoming_webhook.get("url", None), - incoming_webhook_channel_id=incoming_webhook.get("channel_id", None), + bot_user_id=oauth_response.get("bot_user_id"), + bot_scopes=oauth_response.get("scope"), # comma-separated string + user_id=installer.get("id"), + user_token=installer.get("access_token"), + user_scopes=installer.get("scope"), # comma-separated string + incoming_webhook_url=incoming_webhook.get("url"), + incoming_webhook_channel_id=incoming_webhook.get("channel_id"), incoming_webhook_configuration_url=incoming_webhook.get( "configuration_url", None ), diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index e18c7c34e..fa319f5b2 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -90,7 +90,7 @@ def __init__( :param state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) :param logger: The logger that will be used internally """ - self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID", None) + self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID") self.client_secret = client_secret or os.environ.get( "SLACK_CLIENT_SECRET", None ) @@ -101,7 +101,7 @@ def __init__( self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( "," ) - self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) + self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") # Handler configuration self.install_path = install_path or os.environ.get( "SLACK_INSTALL_PATH", "/slack/install" diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index e2059dbae..4a14a46d9 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -57,7 +57,7 @@ def extract_enterprise_id(payload: Dict[str, Any]) -> Optional[str]: return payload.get("enterprise_id") if "team" in payload and "enterprise_id" in payload["team"]: # In the case where the type is view_submission - return payload["team"].get("enterprise_id", None) + return payload["team"].get("enterprise_id") if "event" in payload: return extract_enterprise_id(payload["event"]) return None diff --git a/tests/scenario_tests/test_authorize.py b/tests/scenario_tests/test_authorize.py index fd81213e0..4bf924217 100644 --- a/tests/scenario_tests/test_authorize.py +++ b/tests/scenario_tests/test_authorize.py @@ -7,7 +7,7 @@ from slack_bolt import BoltRequest from slack_bolt.app import App -from slack_bolt.authorization import AuthorizationResult +from slack_bolt.authorization import AuthorizeResult from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -22,7 +22,7 @@ def authorize(enterprise_id, team_id, user_id, client: WebClient): assert team_id == "T111" assert user_id == "W111" auth_test = client.auth_test(token=valid_token) - return AuthorizationResult.from_auth_test_response( + return AuthorizeResult.from_auth_test_response( auth_test_response=auth_test, bot_token=valid_token, ) diff --git a/tests/scenario_tests_async/test_authorize.py b/tests/scenario_tests_async/test_authorize.py index 57653b34d..c24e506b4 100644 --- a/tests/scenario_tests_async/test_authorize.py +++ b/tests/scenario_tests_async/test_authorize.py @@ -8,7 +8,7 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.app.async_app import AsyncApp -from slack_bolt.authorization import AuthorizationResult +from slack_bolt.authorization import AuthorizeResult from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( setup_mock_web_api_server, @@ -24,7 +24,7 @@ async def authorize(enterprise_id, team_id, user_id, client: AsyncWebClient): assert team_id == "T111" assert user_id == "W111" auth_test = await client.auth_test(token=valid_token) - return AuthorizationResult.from_auth_test_response( + return AuthorizeResult.from_auth_test_response( auth_test_response=auth_test, bot_token=valid_token, ) From e035154a7ab010ef710cca0909f565a817c24eee Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 25 Sep 2020 14:56:32 +0900 Subject: [PATCH 104/865] slack_sdk v3.0.0a7 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ffe9d9110..4e3fa0a5d 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["samples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.0.0a6",], + install_requires=["slack_sdk>=3.0.0a7",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", From 7b9032712b25b67985b00eb701626fd43a64bfe7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 25 Sep 2020 14:56:59 +0900 Subject: [PATCH 105/865] Make samples up-to-date * Fix some OAuth issues * Change app.event("message")'s behavior to receive all subtype events too --- samples/falcon/oauth_app.py | 10 ++++++++++ samples/fastapi/async_oauth_app.py | 3 +++ samples/fastapi/oauth_app.py | 2 +- slack_bolt/app/app.py | 13 ++++++++++++- slack_bolt/app/async_app.py | 13 ++++++++++++- slack_bolt/listener_matcher/builtins.py | 4 ---- slack_bolt/oauth/async_oauth_settings.py | 4 ++-- slack_bolt/oauth/oauth_settings.py | 4 ++-- 8 files changed, 42 insertions(+), 11 deletions(-) diff --git a/samples/falcon/oauth_app.py b/samples/falcon/oauth_app.py index 14208954d..167003a9c 100644 --- a/samples/falcon/oauth_app.py +++ b/samples/falcon/oauth_app.py @@ -87,6 +87,16 @@ def handle_app_mentions(body, say, logger): say("What's up?") +@app.message("What") +def handle_matched_messages(event, logger): + logger.info(f"message matched: {event['text']}") + + +@app.event("message") +def handle_messages(event, logger): + logger.info(f"subtype: {event.get('subytype')}") + + api = falcon.API() resource = SlackAppResource(app) api.add_route("/slack/events", resource) diff --git a/samples/fastapi/async_oauth_app.py b/samples/fastapi/async_oauth_app.py index c8463aadd..8ba33668a 100644 --- a/samples/fastapi/async_oauth_app.py +++ b/samples/fastapi/async_oauth_app.py @@ -5,6 +5,9 @@ sys.path.insert(1, "../..") # ------------------------------------------------ +import logging +logging.basicConfig(level=logging.DEBUG) + from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler diff --git a/samples/fastapi/oauth_app.py b/samples/fastapi/oauth_app.py index 1b58fc165..dccad57ea 100644 --- a/samples/fastapi/oauth_app.py +++ b/samples/fastapi/oauth_app.py @@ -19,7 +19,7 @@ def handle_app_mentions(body, say, logger): @app.event("message") -async def handle_message(): +def handle_message(): pass diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 3b25a4836..4e252af54 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -141,6 +141,15 @@ def __init__( ) self._oauth_flow: Optional[OAuthFlow] = None + + if ( + oauth_settings is None + and os.environ.get("SLACK_CLIENT_ID") is not None + and os.environ.get("SLACK_CLIENT_SECRET") is not None + ): + # initialize with the default settings + oauth_settings = OAuthSettings() + if oauth_flow: self._oauth_flow = oauth_flow if self._installation_store is None: @@ -389,7 +398,9 @@ def message( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.event("message") + primary_matcher = builtin_matchers.event( + {"type": "message", "subtype": None} + ) middleware.append(MessageListenerMatches(keyword)) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 6652b81ae..fd54f03ce 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -149,6 +149,15 @@ def __init__( ) self._async_oauth_flow: Optional[AsyncOAuthFlow] = None + + if ( + oauth_settings is None + and os.environ.get("SLACK_CLIENT_ID") is not None + and os.environ.get("SLACK_CLIENT_SECRET") is not None + ): + # initialize with the default settings + oauth_settings = AsyncOAuthSettings() + if oauth_flow: self._async_oauth_flow = oauth_flow if self._async_installation_store is None: @@ -398,7 +407,9 @@ def message( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.event("message", True) + primary_matcher = builtin_matchers.event( + {"type": "message", "subtype": None}, True + ) middleware.append(AsyncMessageListenerMatches(keyword)) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 1434a46d0..b8f511a3b 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -75,10 +75,6 @@ async def async_fun(body: Dict[str, Any]) -> bool: def event( constraints: Union[str, Pattern, Dict[str, str]], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - if constraints == "message": - # matches message events that don't have subtype in body - constraints = {"type": "message", "subtype": None} - if isinstance(constraints, (str, Pattern)): event_type: Union[str, Pattern] = constraints diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index dfff05482..01b2e43a5 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -55,8 +55,8 @@ def __init__( self, *, # OAuth flow parameters/credentials - client_id: str, - client_secret: str, + client_id: Optional[str] = None, # required + client_secret: Optional[str] = None, # required scopes: Optional[List[str]] = None, user_scopes: Optional[List[str]] = None, redirect_uri: Optional[str] = None, diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index fa319f5b2..3cc82c834 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -50,8 +50,8 @@ def __init__( self, *, # OAuth flow parameters/credentials - client_id: str, - client_secret: str, + client_id: Optional[str] = None, # required + client_secret: Optional[str] = None, # required scopes: Optional[List[str]] = None, user_scopes: Optional[List[str]] = None, redirect_uri: Optional[str] = None, From 17c8018d4e5f02e320aa30bf6c72f6a8c94e389a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 25 Sep 2020 18:04:34 +0900 Subject: [PATCH 106/865] version 0.6.3a0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 035d5aeea..b47673c57 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.6.2a0" +__version__ = "0.6.3a0" From 26b30fa6b0dfb66b042363669fd727938d284678 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 26 Sep 2020 17:55:58 +0900 Subject: [PATCH 107/865] Update requirements.txt in a sample --- samples/aws_chalice/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/aws_chalice/requirements.txt b/samples/aws_chalice/requirements.txt index 050991ad6..bb964f6ea 100644 --- a/samples/aws_chalice/requirements.txt +++ b/samples/aws_chalice/requirements.txt @@ -1 +1 @@ -slack_sdk==3.0.0a3 \ No newline at end of file +slack_sdk \ No newline at end of file From c6d323687a510bb6fc03ef190aa79e02b11c2ccd Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 26 Sep 2020 17:56:21 +0900 Subject: [PATCH 108/865] slack_sdk 3.0.0a8 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4e3fa0a5d..994d88da2 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["samples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.0.0a7",], + install_requires=["slack_sdk>=3.0.0a8",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", From 43c064ca6a78a665419cb50e090a8c4e7ef096a1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 26 Sep 2020 18:01:07 +0900 Subject: [PATCH 109/865] Add logger to App/AsyncApp constructor args --- slack_bolt/app/app.py | 3 ++- slack_bolt/app/async_app.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 4e252af54..58a70643b 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -64,6 +64,7 @@ class App: def __init__( self, *, + logger: Optional[logging.Logger] = None, # Used in logger name: Optional[str] = None, # Set True when you run this app on a FaaS platform @@ -111,7 +112,7 @@ def __init__( self._verification_token: Optional[str] = verification_token or os.environ.get( "SLACK_VERIFICATION_TOKEN", None ) - self._framework_logger = get_bolt_logger(App) + self._framework_logger = logger or get_bolt_logger(App) self._token: Optional[str] = token diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index fd54f03ce..465ce3dc9 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -70,6 +70,7 @@ class AsyncApp: def __init__( self, *, + logger: Optional[logging.Logger] = None, # Used in logger name: Optional[str] = None, # Set True when you run this app on a FaaS platform @@ -116,7 +117,7 @@ def __init__( self._verification_token: Optional[str] = verification_token or os.environ.get( "SLACK_VERIFICATION_TOKEN", None ) - self._framework_logger = get_bolt_logger(AsyncApp) + self._framework_logger = logger or get_bolt_logger(AsyncApp) self._token: Optional[str] = token From abfa820362e59c12f37e97f95beeef392bc07b57 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 26 Sep 2020 19:44:29 +0900 Subject: [PATCH 110/865] Remove unncessary info logging --- slack_bolt/middleware/authorization/multi_teams_authorization.py | 1 - 1 file changed, 1 deletion(-) diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index 6eae78e94..9d1bc578c 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -36,7 +36,6 @@ def process( team_id=req.context.team_id, user_id=req.context.user_id, ) - self.logger.info(auth_result) if auth_result is not None: req.context["authorize_result"] = auth_result token = auth_result.bot_token or auth_result.user_token From 082c27633da857e9b176b3bc62a0862a33bc0c15 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 26 Sep 2020 21:33:13 +0900 Subject: [PATCH 111/865] slack_sdk 3.0.0a9 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 994d88da2..78244ba6e 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["samples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.0.0a8",], + install_requires=["slack_sdk>=3.0.0a9",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", From 13c7a0308f71410fe517a7ac9bf1d7d7c23ed051 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 26 Sep 2020 21:33:29 +0900 Subject: [PATCH 112/865] Add SQLAlchemy samples --- samples/sqlalchemy/async_oauth_app.py | 230 ++++++++++++++++++++++ samples/sqlalchemy/oauth_app.py | 87 ++++++++ samples/sqlalchemy/requirements.txt | 3 + samples/sqlalchemy/requirements_async.txt | 4 + 4 files changed, 324 insertions(+) create mode 100644 samples/sqlalchemy/async_oauth_app.py create mode 100644 samples/sqlalchemy/oauth_app.py create mode 100644 samples/sqlalchemy/requirements.txt create mode 100644 samples/sqlalchemy/requirements_async.txt diff --git a/samples/sqlalchemy/async_oauth_app.py b/samples/sqlalchemy/async_oauth_app.py new file mode 100644 index 000000000..4aca131de --- /dev/null +++ b/samples/sqlalchemy/async_oauth_app.py @@ -0,0 +1,230 @@ +import logging +import os +import time +from datetime import datetime +from logging import Logger +from typing import Optional +from uuid import uuid4 + +import sqlalchemy +from databases import Database +from slack_sdk.oauth.installation_store import Bot, Installation +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.oauth.installation_store.sqlalchemy import SQLAlchemyInstallationStore +from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore +from slack_sdk.oauth.state_store.sqlalchemy import SQLAlchemyOAuthStateStore +from sqlalchemy import and_, desc, Table, MetaData + +from slack_bolt.adapter.sanic import AsyncSlackRequestHandler +from slack_bolt.async_app import AsyncApp +from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings + + +class AsyncSQLAlchemyInstallationStore(AsyncInstallationStore): + database_url: str + client_id: str + metadata: MetaData + installations: Table + bots: Table + + def __init__( + self, + client_id: str, + database_url: str, + logger: Logger = logging.getLogger(__name__), + ): + self.client_id = client_id + self.database_url = database_url + self._logger = logger + self.metadata = MetaData() + self.installations = SQLAlchemyInstallationStore.build_installations_table( + metadata=self.metadata, + table_name=SQLAlchemyInstallationStore.default_installations_table_name, + ) + self.bots = SQLAlchemyInstallationStore.build_bots_table( + metadata=self.metadata, + table_name=SQLAlchemyInstallationStore.default_bots_table_name, + ) + + @property + def logger(self) -> Logger: + return self._logger + + async def async_save(self, installation: Installation): + async with Database(self.database_url) as database: + async with database.transaction(): + i = installation.to_dict() + i["client_id"] = self.client_id + await database.execute(self.installations.insert(), i) + b = installation.to_bot().to_dict() + b["client_id"] = self.client_id + await database.execute(self.bots.insert(), b) + + async def async_find_bot( + self, *, enterprise_id: Optional[str], team_id: Optional[str] + ) -> Optional[Bot]: + c = self.bots.c + query = ( + self.bots.select() + .where(and_(c.enterprise_id == enterprise_id, c.team_id == team_id)) + .order_by(desc(c.installed_at)) + .limit(1) + ) + async with Database(self.database_url) as database: + result = await database.fetch_one(query) + if result: + return Bot( + app_id=result["app_id"], + enterprise_id=result["enterprise_id"], + team_id=result["team_id"], + bot_token=result["bot_token"], + bot_id=result["bot_id"], + bot_user_id=result["bot_user_id"], + bot_scopes=result["bot_scopes"], + installed_at=result["installed_at"], + ) + else: + return None + + +class AsyncSQLAlchemyOAuthStateStore(AsyncOAuthStateStore): + database_url: str + expiration_seconds: int + metadata: MetaData + oauth_states: Table + + def __init__( + self, + *, + expiration_seconds: int, + database_url: str, + logger: Logger = logging.getLogger(__name__), + ): + self.expiration_seconds = expiration_seconds + self.database_url = database_url + self._logger = logger + self.metadata = MetaData() + self.oauth_states = SQLAlchemyOAuthStateStore.build_oauth_states_table( + metadata=self.metadata, + table_name=SQLAlchemyOAuthStateStore.default_table_name, + ) + + @property + def logger(self) -> Logger: + return self._logger + + async def async_issue(self) -> str: + state: str = str(uuid4()) + now = datetime.utcfromtimestamp(time.time() + self.expiration_seconds) + async with Database(self.database_url) as database: + await database.execute( + self.oauth_states.insert(), {"state": state, "expire_at": now} + ) + return state + + async def async_consume(self, state: str) -> bool: + try: + async with Database(self.database_url) as database: + async with database.transaction(): + c = self.oauth_states.c + query = self.oauth_states.select().where( + and_(c.state == state, c.expire_at > datetime.utcnow()) + ) + row = await database.fetch_one(query) + self.logger.debug(f"consume's query result: {row}") + await database.execute( + self.oauth_states.delete().where(c.id == row["id"]) + ) + return True + return False + except Exception as e: + message = f"Failed to find any persistent data for state: {state} - {e}" + self.logger.warning(message) + return False + + +database_url = "sqlite:///slackapp.db" +# database_url = "postgresql://localhost/slackapp" # pip install psycopg2 databases[postgresql] + +logger = logging.getLogger(__name__) +client_id, client_secret, signing_secret = ( + os.environ["SLACK_CLIENT_ID"], + os.environ["SLACK_CLIENT_SECRET"], + os.environ["SLACK_SIGNING_SECRET"], +) + +installation_store = AsyncSQLAlchemyInstallationStore( + client_id=client_id, database_url=database_url, logger=logger, +) +oauth_state_store = AsyncSQLAlchemyOAuthStateStore( + expiration_seconds=120, database_url=database_url, logger=logger, +) + +app = AsyncApp( + logger=logger, + signing_secret=signing_secret, + installation_store=installation_store, + oauth_settings=AsyncOAuthSettings( + client_id=client_id, client_secret=client_secret, state_store=oauth_state_store, + ), +) +app_handler = AsyncSlackRequestHandler(app) + + +@app.event("app_mention") +async def handle_command(say: AsyncSay): + await say("Hi!") + + +from sanic import Sanic +from sanic.request import Request + +api = Sanic(name="awesome-slack-app") + + +@api.post("/slack/events") +async def endpoint(req: Request): + return await app_handler.handle(req) + + +@api.get("/slack/install") +async def install(req: Request): + return await app_handler.handle(req) + + +@api.get("/slack/oauth_redirect") +async def oauth_redirect(req: Request): + return await app_handler.handle(req) + + +async def init(): + try: + async with Database(database_url) as database: + await database.fetch_one("select count(*) from slack_bots") + except Exception: + engine = sqlalchemy.create_engine(database_url) + installation_store.metadata.create_all(engine) + oauth_state_store.metadata.create_all(engine) + + +if __name__ == "__main__": + import asyncio + + logging.basicConfig(level=logging.DEBUG) + asyncio.run(init()) + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) + +# pip install -r requirements_async.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 + +# python async_oauth_app.py +# or +# uvicorn async_oauth_app:api --reload --port 3000 --log-level warning diff --git a/samples/sqlalchemy/oauth_app.py b/samples/sqlalchemy/oauth_app.py new file mode 100644 index 000000000..3c01b78bf --- /dev/null +++ b/samples/sqlalchemy/oauth_app.py @@ -0,0 +1,87 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) +logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) + +import os +from slack_bolt import App +from slack_bolt.adapter.flask import SlackRequestHandler +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store.sqlalchemy import SQLAlchemyInstallationStore +from slack_sdk.oauth.state_store.sqlalchemy import SQLAlchemyOAuthStateStore + +import sqlalchemy +from sqlalchemy.engine import Engine + +database_url = "sqlite:///slackapp.db" +# database_url = "postgresql://localhost/slackapp" # pip install psycopg2 + +logger = logging.getLogger(__name__) +client_id, client_secret, signing_secret = ( + os.environ["SLACK_CLIENT_ID"], + os.environ["SLACK_CLIENT_SECRET"], + os.environ["SLACK_SIGNING_SECRET"], +) + +engine: Engine = sqlalchemy.create_engine(database_url) +installation_store = SQLAlchemyInstallationStore( + client_id=client_id, engine=engine, logger=logger, +) +oauth_state_store = SQLAlchemyOAuthStateStore( + expiration_seconds=120, engine=engine, logger=logger, +) + +try: + engine.execute("select count(*) from bots") +except Exception as e: + installation_store.metadata.create_all(engine) + oauth_state_store.metadata.create_all(engine) + +app = App( + logger=logger, + signing_secret=signing_secret, + installation_store=installation_store, + oauth_settings=OAuthSettings( + client_id=client_id, + client_secret=client_secret, + state_store=oauth_state_store, + ), +) + + +@app.event("app_mention") +def handle_command(say): + say("Hi!") + + +from flask import Flask, request + +flask_app = Flask(__name__) +handler = SlackRequestHandler(app) + + +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + return handler.handle(request) + + +@flask_app.route("/slack/install", methods=["GET"]) +def install(): + return handler.handle(request) + + +@flask_app.route("/slack/oauth_redirect", methods=["GET"]) +def oauth_redirect(): + return handler.handle(request) + + +# pip install -r requirements.txt + +# # -- OAuth flow -- # +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,chat:write + +# FLASK_APP=oauth_app.py FLASK_ENV=development flask run -p 3000 diff --git a/samples/sqlalchemy/requirements.txt b/samples/sqlalchemy/requirements.txt new file mode 100644 index 000000000..d4cd207d2 --- /dev/null +++ b/samples/sqlalchemy/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt>=0.6 +Flask>1 +SQLAlchemy diff --git a/samples/sqlalchemy/requirements_async.txt b/samples/sqlalchemy/requirements_async.txt new file mode 100644 index 000000000..ef386d6f4 --- /dev/null +++ b/samples/sqlalchemy/requirements_async.txt @@ -0,0 +1,4 @@ +slack_bolt>=0.6 +sanic>=20,<21 +uvicorn<1 +databases[sqlite] \ No newline at end of file From 2f496f712e939fbd3fb0da0eabcaea5f53df170c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 26 Sep 2020 22:54:34 +0900 Subject: [PATCH 113/865] Add Django OAuth sample --- samples/django/README.md | 2 +- samples/django/{slackapp => }/manage.py | 0 .../slackapp/{slackapp => }/__init__.py | 0 samples/django/slackapp/apps.py | 5 + .../slackapp/migrations/0001_initial.py | 101 ++++++++++++++ .../django/slackapp/migrations/__init__.py | 0 samples/django/slackapp/models.py | 56 ++++++++ .../slackapp/{slackapp => }/settings.py | 2 + samples/django/slackapp/slackapp/urls.py | 9 -- samples/django/slackapp/slackapp/views.py | 21 --- samples/django/slackapp/urls.py | 9 ++ samples/django/slackapp/views.py | 129 ++++++++++++++++++ .../django/slackapp/{slackapp => }/wsgi.py | 0 13 files changed, 303 insertions(+), 31 deletions(-) rename samples/django/{slackapp => }/manage.py (100%) rename samples/django/slackapp/{slackapp => }/__init__.py (100%) create mode 100644 samples/django/slackapp/apps.py create mode 100644 samples/django/slackapp/migrations/0001_initial.py create mode 100644 samples/django/slackapp/migrations/__init__.py create mode 100644 samples/django/slackapp/models.py rename samples/django/slackapp/{slackapp => }/settings.py (97%) delete mode 100644 samples/django/slackapp/slackapp/urls.py delete mode 100644 samples/django/slackapp/slackapp/views.py create mode 100644 samples/django/slackapp/urls.py create mode 100644 samples/django/slackapp/views.py rename samples/django/slackapp/{slackapp => }/wsgi.py (100%) diff --git a/samples/django/README.md b/samples/django/README.md index e414848ac..538bec95a 100644 --- a/samples/django/README.md +++ b/samples/django/README.md @@ -2,7 +2,7 @@ pip install -r requirements.txt export SLACK_SIGNING_SECRET=*** export SLACK_BOT_TOKEN=xoxb-*** -cd ./slackapp + python manage.py migrate python manage.py runserver 0.0.0.0:3000 ``` \ No newline at end of file diff --git a/samples/django/slackapp/manage.py b/samples/django/manage.py similarity index 100% rename from samples/django/slackapp/manage.py rename to samples/django/manage.py diff --git a/samples/django/slackapp/slackapp/__init__.py b/samples/django/slackapp/__init__.py similarity index 100% rename from samples/django/slackapp/slackapp/__init__.py rename to samples/django/slackapp/__init__.py diff --git a/samples/django/slackapp/apps.py b/samples/django/slackapp/apps.py new file mode 100644 index 000000000..aa8cafd43 --- /dev/null +++ b/samples/django/slackapp/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SlackAppConfig(AppConfig): + name = "slackapp" diff --git a/samples/django/slackapp/migrations/0001_initial.py b/samples/django/slackapp/migrations/0001_initial.py new file mode 100644 index 000000000..84665c287 --- /dev/null +++ b/samples/django/slackapp/migrations/0001_initial.py @@ -0,0 +1,101 @@ +# Generated by Django 3.1.1 on 2020-09-26 13:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="SlackBot", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("client_id", models.TextField()), + ("app_id", models.TextField()), + ("enterprise_id", models.TextField(null=True)), + ("team_id", models.TextField(null=True)), + ("bot_token", models.TextField(null=True)), + ("bot_id", models.TextField(null=True)), + ("bot_user_id", models.TextField(null=True)), + ("bot_scopes", models.TextField(null=True)), + ("installed_at", models.DateTimeField()), + ], + ), + migrations.CreateModel( + name="SlackInstallation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("client_id", models.TextField()), + ("app_id", models.TextField()), + ("enterprise_id", models.TextField(null=True)), + ("team_id", models.TextField(null=True)), + ("bot_token", models.TextField(null=True)), + ("bot_id", models.TextField(null=True)), + ("bot_user_id", models.TextField(null=True)), + ("bot_scopes", models.TextField(null=True)), + ("user_id", models.TextField()), + ("user_token", models.TextField(null=True)), + ("user_scopes", models.TextField(null=True)), + ("incoming_webhook_url", models.TextField(null=True)), + ("incoming_webhook_channel_id", models.TextField(null=True)), + ("incoming_webhook_configuration_url", models.TextField(null=True)), + ("installed_at", models.DateTimeField()), + ], + ), + migrations.CreateModel( + name="SlackOAuthState", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("state", models.TextField()), + ("expire_at", models.DateTimeField()), + ], + ), + migrations.AddIndex( + model_name="slackinstallation", + index=models.Index( + fields=[ + "client_id", + "enterprise_id", + "team_id", + "user_id", + "installed_at", + ], + name="slackapp_sl_client__9b0d3f_idx", + ), + ), + migrations.AddIndex( + model_name="slackbot", + index=models.Index( + fields=["client_id", "enterprise_id", "team_id", "installed_at"], + name="slackapp_sl_client__d220d6_idx", + ), + ), + ] diff --git a/samples/django/slackapp/migrations/__init__.py b/samples/django/slackapp/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/samples/django/slackapp/models.py b/samples/django/slackapp/models.py new file mode 100644 index 000000000..d0a3ff3da --- /dev/null +++ b/samples/django/slackapp/models.py @@ -0,0 +1,56 @@ +from django.db import models + + +class SlackBot(models.Model): + client_id = models.TextField(null=False) + app_id = models.TextField(null=False) + enterprise_id = models.TextField(null=True) + team_id = models.TextField(null=True) + bot_token = models.TextField(null=True) + bot_id = models.TextField(null=True) + bot_user_id = models.TextField(null=True) + bot_scopes = models.TextField(null=True) + installed_at = models.DateTimeField(null=False) + + class Meta: + indexes = [ + models.Index( + fields=["client_id", "enterprise_id", "team_id", "installed_at"] + ), + ] + + +class SlackInstallation(models.Model): + client_id = models.TextField(null=False) + app_id = models.TextField(null=False) + enterprise_id = models.TextField(null=True) + team_id = models.TextField(null=True) + bot_token = models.TextField(null=True) + bot_id = models.TextField(null=True) + bot_user_id = models.TextField(null=True) + bot_scopes = models.TextField(null=True) + user_id = models.TextField(null=False) + user_token = models.TextField(null=True) + user_scopes = models.TextField(null=True) + incoming_webhook_url = models.TextField(null=True) + incoming_webhook_channel_id = models.TextField(null=True) + incoming_webhook_configuration_url = models.TextField(null=True) + installed_at = models.DateTimeField(null=False) + + class Meta: + indexes = [ + models.Index( + fields=[ + "client_id", + "enterprise_id", + "team_id", + "user_id", + "installed_at", + ] + ), + ] + + +class SlackOAuthState(models.Model): + state = models.TextField(null=False) + expire_at = models.DateTimeField(null=False) diff --git a/samples/django/slackapp/slackapp/settings.py b/samples/django/slackapp/settings.py similarity index 97% rename from samples/django/slackapp/slackapp/settings.py rename to samples/django/slackapp/settings.py index 563da74fb..13252519b 100644 --- a/samples/django/slackapp/slackapp/settings.py +++ b/samples/django/slackapp/settings.py @@ -23,6 +23,7 @@ "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), "propagate": False, }, + "django.db.backends": {"level": "DEBUG",}, "slack_bolt": {"handlers": ["console"], "level": "DEBUG", "propagate": False,}, }, } @@ -54,6 +55,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "slackapp.apps.SlackAppConfig", ] MIDDLEWARE = [ diff --git a/samples/django/slackapp/slackapp/urls.py b/samples/django/slackapp/slackapp/urls.py deleted file mode 100644 index e76b9a114..000000000 --- a/samples/django/slackapp/slackapp/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path - -from . import views - -urlpatterns = [ - path("slack/events", views.handle, name="handle"), - path("slack/install", views.handle, name="install"), - path("slack/oauth_redirect", views.handle, name="oauth_redirect"), -] diff --git a/samples/django/slackapp/slackapp/views.py b/samples/django/slackapp/slackapp/views.py deleted file mode 100644 index 4c0d74417..000000000 --- a/samples/django/slackapp/slackapp/views.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.http import HttpRequest -from django.views.decorators.csrf import csrf_exempt - -from slack_bolt import App -from slack_bolt.adapter.django import SlackRequestHandler - -app = App() - - -@app.event("app_mention") -def event_test(ack, body, say, logger): - logger.info(body) - say("What's up?") - - -handler = SlackRequestHandler(app=app) - - -@csrf_exempt -def handle(request: HttpRequest): - return handler.handle(request) diff --git a/samples/django/slackapp/urls.py b/samples/django/slackapp/urls.py new file mode 100644 index 000000000..f875bc822 --- /dev/null +++ b/samples/django/slackapp/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("slack/events", views.events, name="handle"), + path("slack/install", views.oauth, name="install"), + path("slack/oauth_redirect", views.oauth, name="oauth_redirect"), +] diff --git a/samples/django/slackapp/views.py b/samples/django/slackapp/views.py new file mode 100644 index 000000000..9b1721130 --- /dev/null +++ b/samples/django/slackapp/views.py @@ -0,0 +1,129 @@ +import logging +import os +from logging import Logger +from typing import Optional +from uuid import uuid4 + +from django.db.models import F +from django.db.models.functions import Coalesce +from django.http import HttpRequest +from django.utils import timezone +from django.views.decorators.csrf import csrf_exempt +from slack_sdk.oauth import InstallationStore, OAuthStateStore +from slack_sdk.oauth.installation_store import Bot, Installation + +from slack_bolt import App +from slack_bolt.adapter.django import SlackRequestHandler +from slack_bolt.oauth.oauth_settings import OAuthSettings +from .models import SlackOAuthState, SlackBot, SlackInstallation + + +class DjangoInstallationStore(InstallationStore): + client_id: str + + def __init__( + self, client_id: str, logger: Logger, + ): + self.client_id = client_id + self._logger = logger + + @property + def logger(self) -> Logger: + return self._logger + + def save(self, installation: Installation): + i = installation.to_dict() + i["client_id"] = self.client_id + SlackInstallation(**i).save() + b = installation.to_bot().to_dict() + b["client_id"] = self.client_id + SlackBot(**b).save() + + def find_bot( + self, *, enterprise_id: Optional[str], team_id: Optional[str] + ) -> Optional[Bot]: + rows = ( + SlackBot.objects.filter(enterprise_id=enterprise_id) + .filter(team_id=team_id) + .order_by(F("installed_at").desc())[:1] + ) + if len(rows) > 0: + b = rows[0] + return Bot( + app_id=b.app_id, + enterprise_id=b.enterprise_id, + team_id=b.team_id, + bot_token=b.bot_token, + bot_id=b.bot_id, + bot_user_id=b.bot_user_id, + bot_scopes=b.bot_scopes, + installed_at=b.installed_at.timestamp(), + ) + return None + + +class DjangoOAuthStateStore(OAuthStateStore): + expiration_seconds: int + + def __init__( + self, expiration_seconds: int, logger: Logger, + ): + self.expiration_seconds = expiration_seconds + self._logger = logger + + @property + def logger(self) -> Logger: + return self._logger + + def issue(self) -> str: + state: str = str(uuid4()) + expire_at = timezone.now() + timezone.timedelta(seconds=self.expiration_seconds) + row = SlackOAuthState(state=state, expire_at=expire_at) + row.save() + return state + + def consume(self, state: str) -> bool: + rows = SlackOAuthState.objects.filter(state=state).filter( + expire_at__gte=timezone.now() + ) + if len(rows) > 0: + for row in rows: + row.delete() + return True + return False + + +logger = logging.getLogger(__name__) +client_id, client_secret, signing_secret = ( + os.environ["SLACK_CLIENT_ID"], + os.environ["SLACK_CLIENT_SECRET"], + os.environ["SLACK_SIGNING_SECRET"], +) + +app = App( + signing_secret=signing_secret, + installation_store=DjangoInstallationStore(client_id=client_id, logger=logger,), + oauth_settings=OAuthSettings( + client_id=client_id, + client_secret=client_secret, + state_store=DjangoOAuthStateStore(expiration_seconds=120, logger=logger,), + ), +) + + +@app.event("app_mention") +def event_test(body, say, logger): + logger.info(body) + say("What's up?") + + +handler = SlackRequestHandler(app=app) + + +@csrf_exempt +def events(request: HttpRequest): + return handler.handle(request) + + +def oauth(request: HttpRequest): + return handler.handle(request) diff --git a/samples/django/slackapp/slackapp/wsgi.py b/samples/django/slackapp/wsgi.py similarity index 100% rename from samples/django/slackapp/slackapp/wsgi.py rename to samples/django/slackapp/wsgi.py From de93088a238649de9204c035c01ba3f1c5339e1c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 26 Sep 2020 23:04:15 +0900 Subject: [PATCH 114/865] version 0.6.4a0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index b47673c57..b57a6795b 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.6.3a0" +__version__ = "0.6.4a0" From 42fa6a916850251e7fe90d681902d9d111b0a397 Mon Sep 17 00:00:00 2001 From: Shay DeWael Date: Mon, 28 Sep 2020 15:08:07 -0700 Subject: [PATCH 115/865] Add Advanced Sections to documentation (#104) Add advanced sections documentation --- docs/_advanced/adapters.md | 36 ++---- docs/_advanced/async.md | 34 +++--- docs/_advanced/authorization.md | 72 ++++++++++++ docs/_advanced/context.md | 74 ++++++++++++ docs/_advanced/custom_adapters.md | 72 ++++++++++++ docs/_advanced/errors.md | 19 ++++ docs/_advanced/global_middleware.md | 34 ++++++ docs/_advanced/lazy_listener.md | 79 +++++++++++++ docs/_advanced/lazy_listener_functions.md | 106 ------------------ docs/_advanced/listener_middleware.md | 23 ++++ docs/_advanced/logging.md | 28 +++++ docs/_basic/authenticating_oauth.md | 4 +- docs/_basic/listening_responding_options.md | 2 +- docs/_basic/listening_responding_shortcuts.md | 3 +- docs/_basic/publishing_views.md | 46 ++++++++ docs/_config.yml | 4 + docs/assets/style.css | 75 ++++++++----- 17 files changed, 526 insertions(+), 185 deletions(-) create mode 100644 docs/_advanced/authorization.md create mode 100644 docs/_advanced/context.md create mode 100644 docs/_advanced/custom_adapters.md create mode 100644 docs/_advanced/errors.md create mode 100644 docs/_advanced/global_middleware.md create mode 100644 docs/_advanced/lazy_listener.md delete mode 100644 docs/_advanced/lazy_listener_functions.md create mode 100644 docs/_advanced/listener_middleware.md create mode 100644 docs/_advanced/logging.md create mode 100644 docs/_basic/publishing_views.md diff --git a/docs/_advanced/adapters.md b/docs/_advanced/adapters.md index 4612322f3..0887b335c 100644 --- a/docs/_advanced/adapters.md +++ b/docs/_advanced/adapters.md @@ -1,44 +1,26 @@ --- -title: Using in Web frameworks +title: Adapters lang: en slug: adapters order: 0 ---
    -`App#start()` starts a Web server process using the [`http.server` standard library](https://docs.python.org/3/library/http.server.html). As mentioned in its document, it is not recommended to use the module for production. +Adapters are responsible for handling and parsing incoming events from Slack to conform to `BoltRequest`, then dispatching those events to your Bolt app. -Once you're done with local development, it's about time to choose the right Web framework and production-ready server. +By default, Bolt will use the built-in `HTTPSever` adapter. While this is okay for local development, it is not recommended for production. Bolt for Python includes a collection of built-in adapters that can be imported and used with your app. The built-in adapters support a variety of popular Python frameworks including Flask, Django, and Starlette among others. Adapters support the use of any production-ready web server of your choice. -Let's try using [Flask](https://flask.palletsprojects.com/) framework along with the [Gunicorn](https://gunicorn.org/) WSGI HTTP server here. - -```bash -pip install slack_bolt flask gunicorn -export SLACK_SIGNING_SECRET=*** -export SLACK_BOT_TOKEN=xoxb-*** -# Save the source code as main.py -gunicorn --bind :3000 --workers 1 --threads 2 --timeout 0 main:flask_app -``` - -We currently support the following frameworks. As long as a Web framework works, you can run Bolt app in any web servers. - -* [Django](https://www.djangoproject.com/) -* [Flask](https://flask.palletsprojects.com/) -* [Starlette](https://www.starlette.io/) & [FastAPI](https://fastapi.tiangolo.com/) -* [Sanic](https://sanicframework.org/) -* [Tornado](https://www.tornadoweb.org/) -* [Falcon](https://falcon.readthedocs.io/) -* [Bottle](https://bottlepy.org/) -* [CherryPy](https://cherrypy.org/) -* [Pyramid](https://trypyramid.com/) - -Check [samples](https://github.com/slackapi/bolt-python/tree/main/samples) in the GitHub repository to learn how to configure your app with frameworks. +To use an adapter, you'll create an app with the framework of your choosing and import its corresponding adapter. Then you'll initalize the adapter instance and call its function that handles and parses incoming events. +The full list adapters, as well as configuration and sample usage, can be found within the repository's `samples` folder.
    ```python from slack_bolt import App -app = App() +app = App( + signing_secret=os.environ.get("SIGNING_SECRET"), + token=os.environ.get("SLACK_BOT_TOKEN") +) # There is nothing specific to Flask here! # App is completely framework/runtime agnostic diff --git a/docs/_advanced/async.md b/docs/_advanced/async.md index 104db22a9..6850764e1 100644 --- a/docs/_advanced/async.md +++ b/docs/_advanced/async.md @@ -1,23 +1,18 @@ --- -title: Async Bolt +title: Using async lang: en -slug: advanced-async -order: 1 +slug: async +order: 2 ---
    -In the Basic concepts section, all the code snippets are not in the asynchronous programming style. You may be wondering if Bolt for Python is not available for asynchronous frameworks and their runtime such as the standard `asyncio` library. - -No worries! You can use Bolt with [Starlette](https://www.starlette.io/), [FastAPI](https://fastapi.tiangolo.com/), [Sanic](https://sanicframework.org/), [AIOHTTP](https://docs.aiohttp.org/), and whatever you want to use. - -`AsyncApp` internally relies on AIOHTTP for making HTTP requests to Slack API servers. So, to use the async version of Bolt, add `aiohttp` to `requirements.txt` or run `pip install aiohttp`. - -You can find sample projects in [samples](https://github.com/slackapi/bolt-python/tree/main/samples) directory in the GitHub repository. +To use the async version of Bolt, you can import and initialize an `AsyncApp` instance (rather than `App`). `AsyncApp` relies on AIOHTTP to make API requests, which means you'll need to install `aiohttp` (by adding to `requirements.txt` or running `pip install aiohttp`). +Sample async projects can be found within the repository's `samples` folder.
    ```python -# required: pip install aiohttp +# Requirement: install aiohttp from slack_bolt.async_app import AsyncApp app = AsyncApp() @@ -36,25 +31,23 @@ if __name__ == "__main__":
    -

    Using other async frameworks

    +

    Using other frameworks

    -`AsyncApp#start()` internally uses [AIOHTTP](https://docs.aiohttp.org/)'s web server feature. However, this doesn't mean you have to use AIOHTTP. `AsyncApp` can handle incoming requests from Slack using any other frameworks. +Internally `AsyncApp#start()` implements a [`AIOHTTP`](https://docs.aiohttp.org/) web server. If you prefer, you can use a framework other than `AIOHTTP` to handle incoming requests. + +This example uses [Sanic](https://sanicframework.org/), but the full list of adapters are in the [`adapter` folder](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter). -The code snippet in this section is an example using [Sanic](https://sanicframework.org/). You can start your Slack app server-side built with Sanic only by running the following commands. +The following commands install the necessary requirements and starts the Sanic server on port 3000. ```bash +# Install requirements pip install slack_bolt sanic uvicorn -export SLACK_SIGNING_SECRET=*** -export SLACK_BOT_TOKEN=xoxb-*** -# save the source as async_app.py +# Save the source as async_app.py uvicorn async_app:api --reload --port 3000 --log-level debug ``` - -If you would like to use other frameworks, check the list of the built-in adapters [here](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter). If your favorite framework is available there, you can use Bolt with it in a similar way. -
    ```python @@ -85,5 +78,4 @@ async def endpoint(req: Request): if __name__ == "__main__": api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) ``` -
    diff --git a/docs/_advanced/authorization.md b/docs/_advanced/authorization.md new file mode 100644 index 000000000..1f69cfc77 --- /dev/null +++ b/docs/_advanced/authorization.md @@ -0,0 +1,72 @@ +--- +title: Authorization +lang: en +slug: authorization +order: 5 +--- + +
    +Authorization is the process of determining which Slack credentials should be available while processing an incoming Slack event. + +Apps installed on a single workspace can simply pass their bot token into the `App` constructor using the `token` parameter. However, if your app will be installed on multiple workspaces, you have two options. The easier option is to use the built-in OAuth support. This will handle setting up OAuth routes and verifying state. Read the section on [authenticating with OAuth](#authenticating-oauth) for details. + +For a more custom solution, you can set the `authorize` parameter to a function upon `App` instantiation. The `authorize` function should return [an instance of `AuthorizeResult`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/authorization/authorize_result.py), which contains information about who and where the event is coming from. + +`AuthorizeResult` should have a few specific properties, all of type `str`: +- Either **`bot_token`** (xoxb) *or* **`user_token`** (xoxp) are **required**. Most apps will use `bot_token` by default. Passing a token allows built-in functions (like `say()`) to work. +- **`bot_user_id`** and **`bot_id`**, if using a `bot_token`. +- **`enterprise_id`** and **`team_id`**, which can be found in events sent to your app. +- **`user_id`** only when using `user_token`. +
    + +```python +import os +from slack_bolt import App +# Import the AuthorizationResult class +from slack_bolt.authorization import AuthorizationResult + +# This is just an example (assumes there are no user tokens) +# You should store authorizations in a secure DB +installations = [ + { + "enterprise_id": "E1234A12AB", + "team_id": "T12345", + "bot_token": "xoxb-123abc", + "bot_id": "B1251", + "bot_user_id": "U12385" + }, + { + "team_id": "T77712", + "bot_token": "xoxb-102anc", + "bot_id": "B5910", + "bot_user_id": "U1239", + "enterprise_id": "E1234A12AB" + } +] + +def authorize(enterprise_id, team_id, logger): + + # You can implement your own logic to fetch token here + + for (team in installations): + # enterprise_id doesn't exist for some teams + is_valid_enterprise = True if (("enterprise_id" not in team) or (enterprise_id == team["enterprise_id"])) else False + + if ((is_valid_enterprise == True) and (team["team_id"] == team_id)): + # Return an instance of AuthorizeResult + # If you don't store bot_id and bot_user_id, could also call `from_auth_test_response` with your bot_token to automatically fetch them + return AuthorizeResult( + enterprise_id=enterprise_id, + team_id=team_id, + bot_token=team["bot_token"], + bot_id=team["bot_id"], + bot_user_id=team["bot_user_id"] + ) + + logger.error("No authorization information was found") + +app = App( + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + authorize=authorize +) +``` diff --git a/docs/_advanced/context.md b/docs/_advanced/context.md new file mode 100644 index 000000000..4e01a95f7 --- /dev/null +++ b/docs/_advanced/context.md @@ -0,0 +1,74 @@ +--- +title: Adding context +lang: en +slug: context +order: 7 +--- + +
    +All listeners have access to a `context` dictionary, which can be used to enrich events with additional information. Bolt automatically attaches information that is included in the incoming event, like `user_id`, `team_id`, `channel_id`, and `enterprise_id`. + +`context` is just a dictionary, so you can directly modify it. +
    + +```python +# Listener middleware to fetch tasks from external system using userId +def fetch_tasks(context, event, next): + user = event["user"] + + try: + # Assume get_tasks fetchs list of tasks from DB corresponding to user ID + user_tasks = db.get_tasks(user) + tasks = user_tasks + except Exception: + # get_tasks() raises exception because no tasks are found + tasks = [] + finally: + # Put user's tasks in context + context["tasks"] = tasks + next() + +# Listener middleware to create a list of section blocks +def create_sections(context, next): + task_blocks = [] + + # Loops through tasks added to context in previous middleware + for task in context["tasks"]: + task_blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{task['title']}*\n{task['body']}" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "See task" + }, + "url": task["url"], + } + } + ) + + # Put list of blocks in context + context["blocks"] = task_blocks + next() + +# Listen for user opening app home +# Include fetch_tasks middleware +@app.event( + event = "app_home_opened", + middleware = [fetch_tasks, create_sections] +) +def show_tasks(event, client, context): + # Publish view to user's home tab + client.views_publish( + user_id=event["user"], + view={ + "type": "home", + "blocks": context["blocks"] + } + ) +``` \ No newline at end of file diff --git a/docs/_advanced/custom_adapters.md b/docs/_advanced/custom_adapters.md new file mode 100644 index 000000000..b15a36dc8 --- /dev/null +++ b/docs/_advanced/custom_adapters.md @@ -0,0 +1,72 @@ +--- +title: Custom adapters +lang: en +slug: custom-adapters +order: 1 +--- + +
    +[Adapters](#adapters) are flexible and can be adjusted based on the framework you prefer. There are two necessary components of adapters: + +- `__init__(app: App)`: Constructor that accepts and stores an instance of the Bolt `App`. +- `handle(req: Request)`: Function (typically named `handle()`) that receives incoming Slack requests, parses them to conform to an instance of [`BoltRequest`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/request/request.py), then dispatches them to the stored Bolt app. + +`BoltRequest` instantiation accepts four parameters: + +| Parameter | Description | Required? | +|-----------|-------------|-----------| +| `body: str` | The raw request body | **Yes** | +| `query: any` | The query string data | No | +| `headers: Dict[str, Union[str, List[str]]]` | Request headers | No | +| `context: Dict[str, str]` | Any context for the request | No | + +`BoltRequest` will return [an instance of `BoltResponse`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/response/response.py) from the Bolt app. + +For more in-depth examples of custom adapters, look at the implementations of the [built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter). +
    + +```python +# Necessary imports for Flask +from flask import Request, Response, make_response + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + +# This example is a simplified version of the Flask adapter +# For a more detailed, complete example, look in the adapter folder +# github.com/slackapi/bolt-python/blob/main/slack_bolt/adapter/flask/handler.py + +# Takes in an HTTP request and converts it to a standard BoltRequest +def to_bolt_request(req: Request) -> BoltRequest: + return BoltRequest( + body=req.get_data(as_text=True), + query=req.query_string.decode("utf-8"), + headers=req.headers, + ) + +# Takes in a BoltResponse and converts it to a standard Flask response +def to_flask_response(bolt_resp: BoltResponse) -> Response: + resp: Response = make_response(bolt_resp.body, bolt_resp.status) + for k, values in bolt_resp.headers.items(): + for v in values: + resp.headers.add_header(k, v) + return resp + +# Instantiated from your app +# Accepts a Flask app +class SlackRequestHandler: + def __init__(self, app: App): + self.app = app + + # handle() will be called from your Flask app + # when you receive a request from Slack + def handle(self, req: Request) -> Response: + # This example does not cover OAuth + if req.method == "POST": + # Dispatch the request for Bolt to handle and route + bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req)) + return to_flask_response(bolt_resp) + + return make_response("Not Found", 404) +``` diff --git a/docs/_advanced/errors.md b/docs/_advanced/errors.md new file mode 100644 index 000000000..7899485ce --- /dev/null +++ b/docs/_advanced/errors.md @@ -0,0 +1,19 @@ +--- +title: Handling errors +lang: en +slug: errors +order: 3 +--- + +
    +If an error occurs in a listener, you can handle it directly using a `try`/`except` block. Errors associated with your app will be of type `BoltError`. Errors associated with calling Slack APIs will be of type `SlackApiError`. + +By default, the global error handler will log all non-handled exceptions to the console. To handle global errors yourself, you can attach a global error handler to your app using the `app.error(fn)` function. +
    + +```python +@app.error +def custom_error_handler(error, body, logger): + logger.exception(f"Error: {error}") + logger.info(f"Request body: {body}") +``` \ No newline at end of file diff --git a/docs/_advanced/global_middleware.md b/docs/_advanced/global_middleware.md new file mode 100644 index 000000000..efebea050 --- /dev/null +++ b/docs/_advanced/global_middleware.md @@ -0,0 +1,34 @@ +--- +title: Global middleware +lang: en +slug: global-middleware +order: 6 +--- + +
    +Global middleware is run for all incoming events, before any listener middleware. You can add any number of global middleware to your app by passing middleware functions to `app.use()`. Middleware functions are called with the same arguments as listeners, with an additional `next()` function. + +Both global and listener middleware must call `next()` to pass control of the execution chain to the next middleware. +
    + +```python +@app.use +def auth_acme(client, context, logger, payload, next): + slack_user_id = payload["user"] + help_channel_id = "C12345" + + try: + # Look up user in external system using their Slack user ID + user = acme.lookup_by_id(slack_user_id) + # Add that to context + context["user"] = user + except Exception: + client.chat_postEphemeral( + channel=payload["channel"], + user=slack_user_id, + text=f"Sorry <@{slack_user_id}>, you aren't registered in Acme or there was an error with authentication. Please post in <#{help_channel_id}> for assistance" + ) + + # Pass control to the next middleware + next() +``` diff --git a/docs/_advanced/lazy_listener.md b/docs/_advanced/lazy_listener.md new file mode 100644 index 000000000..198edafc6 --- /dev/null +++ b/docs/_advanced/lazy_listener.md @@ -0,0 +1,79 @@ +--- +title: Lazy listeners (FaaS) +lang: en +slug: lazy-listeners +order: 9 +--- + +
    +⚠️ Lazy listener functions are a beta feature to make it easier to deploy Bolt for Python apps to FaaS environments. As the feature is developed, Bolt for Python's API is subject to change. + +Typically you'd call `ack()` as the first step of your listener functions. Calling `ack()` tells Slack that you've received the event and are handling it in within reasonable amount of time (3 seconds). + +However, apps running on FaaS or similar runtimes that don't allow you to run threads or processes after returning an HTTP response cannot follow this pattern. Instead, you should set the `process_before_response` flag to `True`. This allows you to create a listener that calls `ack()` and handles the event safely, though you still need to complete everything within 3 seconds. For events, while a listener doesn't need `ack()` method call as you normally would, the listener needs to complete within 3 seconds, too. + +Rather than acting as a decorator, lazy listeners take two keyword args: +* `ack: Callable`: Responsible for calling `ack()` +* `lazy: List[Callable]`: Responsible for handling any time-consuming processes related to the event. The lazy function does not have access to `ack()`. +
    + +```python +app.command("/start-process")( + # ack() is still called within 3 seconds + ack=respond_to_slack_within_3_seconds, + # Lazy function is responsible for processing the event + lazy=[run_long_process] +) +``` + +
    + +

    Example with AWS Lambda

    +
    + +
    +This example deploys the code to [AWS Lambda](https://aws.amazon.com/lambda/). There are more examples within the [`sample` folder](https://github.com/slackapi/bolt-python/tree/main/adapter). + +```bash +pip install slack_bolt +# Save the source code as main.py +# and refer handler as `handler: main.handler` in config.yaml + +# https://pypi.org/project/python-lambda/ +pip install python-lambda + +# Configure config.yml properly (AWSLambdaFullAccess required) +export SLACK_SIGNING_SECRET=*** +export SLACK_BOT_TOKEN=xoxb-*** +echo 'slack_bolt' > requirements.txt +lambda deploy --config-file config.yaml --requirements requirements.txt +``` +
    + +```python +from slack_bolt import App +# process_before_response must be True when running on FaaS +app = App(process_before_response=True) + +def respond_to_slack_within_3_seconds(body, ack): + if "text" in body: + ack(":x: Usage: /start-process (description here)") + else: + ack(f"Accepted! (task: {body['text']})") + +import time +def run_long_process(respond, body): + time.sleep(5) # longer than 3 seconds + respond(f"Completed! (task: {body['text']})") + +app.command("/start-process")( + ack=respond_to_slack_within_3_seconds, # responsible for calling `ack()` + lazy=[run_long_process] # unable to call `ack()` / can have multiple functions +) + +from slack_bolt.adapter.aws_lambda import SlackRequestHandler +def handler(event, context): + slack_handler = SlackRequestHandler(app=app) + return slack_handler.handle(event, context) +``` +
    diff --git a/docs/_advanced/lazy_listener_functions.md b/docs/_advanced/lazy_listener_functions.md deleted file mode 100644 index fff26fb9f..000000000 --- a/docs/_advanced/lazy_listener_functions.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: Lazy listener functions (beta) -lang: en -slug: lazy-listener-functions -order: 1 ---- - -
    -Lazy listener function is a **beta** feature that is available only in Bolt for Python. Your application can easily start asynchronous executions with Slack payloads. This feature is particularly useful for FaaS (Function as a Service) users. - -To learn the reason why this feature matters for FaaS users, click **"Why is this feature useful for FaaS users?"**. - -To deploy the code on the right side to [AWS Lambda](https://aws.amazon.com/lambda/) environment, follow the instructions below. - -```bash -pip install slack_bolt -# Save the source code as main.py -# and refer handler as `handler: main.handler` in config.yaml - -pip install python-lambda -# https://pypi.org/project/python-lambda/ -# Configure config.yml properly (AWSLambdaFullAccess required) - -export SLACK_SIGNING_SECRET=*** -export SLACK_BOT_TOKEN=xoxb-*** -echo 'slack_bolt' > requirements.txt -lambda deploy --config-file config.yaml --requirements requirements.txt -``` -
    - -```python -from slack_bolt import App -# process_before_response must be True when running on FaaS -app = App(process_before_response=True) - -def respond_to_slack_within_3_seconds(body, ack): - if "text" in body: - ack(":x: Usage: /start-process (description here)") - else: - ack(f"Accepted! (task: {body['text']})") - -import time -def run_long_process(respond, body): - time.sleep(5) # longer than 3 seconds - respond(f"Completed! (task: {body['text']})") - -app.command("/start-process")( - ack=respond_to_slack_within_3_seconds, # responsible for calling `ack()` - lazy=[run_long_process] # unable to call `ack()` / can have multiple functions -) - -from slack_bolt.adapter.aws_lambda import SlackRequestHandler -def handler(event, context): - slack_handler = SlackRequestHandler(app=app) - return slack_handler.handle(event, context) -``` - -
    - -

    Why is this feature useful for FaaS users?

    -
    - -
    - -For common Bolt apps, you can call `ack()` at the beginning of a listener function this way: -
    - -```python -@app.shortcut("callback-id-here") -def open_modal(ack, body, client): - ack() # acknowledge within 3 seconds - run_time_consuming_operation_here() -``` - -
    -However, if you run your app on FaaS or a similar runtime (that doesn't allow running threads/processes after returning an HTTP response), you will use the `process_before_response=True` option to hold off sending an HTTP response util completing all the tasks in a listener. In this case, all your listener functions must complete within 3 seconds. -
    - -```python -app = App(process_before_response=True) - -@app.command("/hello") -def this_always_times_out(ack): - ack() # will be held off for 5 seconds - time.sleep(5) -``` - -
    -To deal with this, you can use keyword args `ack: Callable` and `lazy: List[Callable]`: - -* `ack: Callable` is responsible for calling `ack()` -* `lazy: List[Callable]` are unable to call `ack()` but can do any time consuming operations in a separate execution (in a thread, another AWS Lambda invocation, and so on) - -Instead of acting as a decorator for a method, `App`/`AsyncApp`'s methods takes keyword args as below. -
    - -```python -app.command("/start-process")( - # ack function is responsible for calling `ack()` - ack=respond_to_slack_within_3_seconds, - # lazy functions are unable to call `ack()` - lazy=[run_long_process] -) -``` - -
    diff --git a/docs/_advanced/listener_middleware.md b/docs/_advanced/listener_middleware.md new file mode 100644 index 000000000..73c3d3590 --- /dev/null +++ b/docs/_advanced/listener_middleware.md @@ -0,0 +1,23 @@ +--- +title: Listener middleware +lang: en +slug: listener-middleware +order: 5 +--- + +
    +Listener middleware is only run for the listener in which it's passed. You can pass any number of middleware functions to the listener using the `middleware` parameter, which must be a list that contains one to many middleware functions. +
    + +```python +# Listener middleware which filters out messages with "bot_message" subtype +def no_bot_messages(message, next): + subtype = message.get("subtype") + if subtype != "bot_message": + next() + +# This listener only receives messages from humans +@app.event(event="message", middleware=[no_bot_messages]) +def log_message(logger, event): + logger.info(f"(MSG) User: {event['user']}\nMessage: {event['text']}") +``` diff --git a/docs/_advanced/logging.md b/docs/_advanced/logging.md new file mode 100644 index 000000000..f36161153 --- /dev/null +++ b/docs/_advanced/logging.md @@ -0,0 +1,28 @@ +--- +title: Logging +lang: en +slug: logging +order: 4 +--- + +
    +By default, Bolt will log information from your app to the output destination. After you've imported the logging module, you can customize the root log level by passing the `level` parameter to `basicConfig()`. The available log levels in order of least to most severe are `debug`, `info`, `warning`, `error`, and `critical`. + +Outside of a global context, you can also log a single message corresponding to a specific level. Because Bolt uses Python’s [standard logging module](https://docs.python.org/3/library/logging.html), you can use any its features. +
    + +```python +import logging + +# logger in a global context +# requires importing logging +logging.basicConfig(level=logging.DEBUG) + +@app.event("app_mention") +def handle_mention(body, say, logger): + user = body["event"]["user"] + # single logger call + # global logger is passed to listener + logger.debug(body) + say(f"{user} mentioned your app") +``` diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md index 36d4baf5a..82630e551 100644 --- a/docs/_basic/authenticating_oauth.md +++ b/docs/_basic/authenticating_oauth.md @@ -2,12 +2,12 @@ title: Authenticating with OAuth lang: en slug: authenticating-oauth -order: 14 +order: 15 ---
    -Slack apps installed on multiple workspaces will need to implement OAuth, then store installation information (like access tokens) securely. By providing `client_id`, `client_secret`, `scopes`, `installation_store`, and `state_store` when initializing App, Bolt for Python will handle the work of setting up OAuth routes and verifying state. If you’re implementing a custom receiver, you can make use of our [OAuth library](https://slack.dev/python-slack-sdk/oauth/), which is what Bolt for Python uses under the hood. +Slack apps installed on multiple workspaces will need to implement OAuth, then store installation information (like access tokens) securely. By providing `client_id`, `client_secret`, `scopes`, `installation_store`, and `state_store` when initializing App, Bolt for Python will handle the work of setting up OAuth routes and verifying state. If you’re implementing a custom adapter, you can make use of our [OAuth library](https://slack.dev/python-slack-sdk/oauth/), which is what Bolt for Python uses under the hood. Bolt for Python will create a **Redirect URL** `slack/oauth_redirect`, which Slack uses to redirect users after they complete your app’s installation flow. You will need to add this **Redirect URL** in your app configuration settings under **OAuth and Permissions**. This path can be configured in the `OAuthSettings` argument described below. diff --git a/docs/_basic/listening_responding_options.md b/docs/_basic/listening_responding_options.md index 28d8ee3ca..50997ccce 100644 --- a/docs/_basic/listening_responding_options.md +++ b/docs/_basic/listening_responding_options.md @@ -2,7 +2,7 @@ title: Listening and responding to options lang: en slug: options -order: 13 +order: 14 ---
    diff --git a/docs/_basic/listening_responding_shortcuts.md b/docs/_basic/listening_responding_shortcuts.md index 8e5ad6343..0a73d6de7 100644 --- a/docs/_basic/listening_responding_shortcuts.md +++ b/docs/_basic/listening_responding_shortcuts.md @@ -23,7 +23,7 @@ When setting up shortcuts within your app configuration, as with other URLs, you ```python -# The open_modal shortcut opens a plain old modal +# The open_modal shortcut listens to a shortcut with the callback_id "open_modal" @app.shortcut("open_modal") def open_modal(ack, shortcut, client): # Acknowledge the shortcut request @@ -31,6 +31,7 @@ def open_modal(ack, shortcut, client): # Call the views_open method using one of the built-in WebClients client.views_open( trigger_id=shortcut["trigger_id"], + # A simple view payload for a modal view={ "type": "modal", "title": {"type": "plain_text", "text": "My App"}, diff --git a/docs/_basic/publishing_views.md b/docs/_basic/publishing_views.md new file mode 100644 index 000000000..842ba74b9 --- /dev/null +++ b/docs/_basic/publishing_views.md @@ -0,0 +1,46 @@ +--- +title: Publishing views to App Home +lang: en +slug: app-home +order: 13 +--- + +
    +Home tabs are customizable surfaces accessible via the sidebar and search that allow apps to display views on a per-user basis. After enabling App Home within your app configuration, home tabs can be published and updated by passing a `user_id` and view payload to the `views.publish` method. + +You can subscribe to the `app_home_opened` event to listen for when users open your App Home. +
    + +```python +@app.event("app_home_opened") +def open_modal(client, event, logger): + try: + # Call views.publish with the built-in client + client.views_publish( + # Use the user ID associated with the event + user_id=event["user"], + # Home tabs must be enabled in your app configuration + view={ + "type": "home", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Welcome home, <@" + event["user"] + "> :house:*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Learn how home tabs can be more useful and interactive ." + } + } + ] + } + ) + + except Exception as e: + logger.error(f"Error opening modal: {e}") +``` diff --git a/docs/_config.yml b/docs/_config.yml index f576f4cdd..5854e882e 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -52,6 +52,10 @@ google_tag_manager: GTM-KFZ5MK7 markdown: kramdown kramdown: parse_block_html: true + parse_span_html: true + syntax_highlighter_opts: + block: + line_numbers: true plugins: - jemoji - jekyll-redirect-from diff --git a/docs/assets/style.css b/docs/assets/style.css index 4b62436e8..99c1e26c9 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -11,9 +11,10 @@ --black: #1D1C1D; } -body { +html, body { background-color: var(--white); font-family: 'Noto Sans JP', 'Slack-Lato', sans-serif; + font-size: 100%; } .content { @@ -49,18 +50,18 @@ span.beta { } .panel .sidebar-content .logo { - padding-top: 1em; + padding-top: 1.15em; position: relative; } .panel .sidebar-content .logo .icon img { - width: 30px; + width: 2.15em; margin-right: 6px; } .panel .sidebar-content .logo .name { font-weight: 800; - font-size: 1.7em; + font-size: 2em; vertical-align: bottom; } @@ -84,14 +85,14 @@ span.beta { list-style: none; list-style-position: inside; padding-top: 0.9em; - margin: 0 0 0 -8px; - font-size: 0.80em; + margin: 0 0 0 -0.5em; + font-size: 0.95em; } .panel .sidebar-content ul.sidebar-section li { border-radius: 8px; padding: 2px 0 2px 8px; - margin: 3px 0; + margin: 0.1em 0; color: var(--black); } @@ -119,8 +120,8 @@ span.beta { /* Main page */ .header { width: 95%; - margin: 0 auto 1em auto; - height: 5rem; + margin: 0 auto 0.8em auto; + height: 5em; padding-top: 1.5em; } @@ -132,7 +133,7 @@ span.beta { color: var(--grey); font-weight: 700; padding: 6px 14px 9px; - font-size: 15px; + font-size 0.9em; } .header a.language-switcher:hover { @@ -149,7 +150,7 @@ span.beta { width: 90%; margin: 0 auto 30px auto; display: grid; - grid-gap: 25px; + grid-gap: 20px; grid-template-areas: "head" "body" @@ -189,7 +190,7 @@ span.beta { } .completed { - background: #58AF7F; + background: #53b3e1; } .tutorial-nav ul li { @@ -198,7 +199,7 @@ span.beta { .tutorial-nav a { font-weight: 700; - font-size: 0.8em; + font-size: 0.9em; color: #757575; } @@ -211,8 +212,6 @@ span.beta { width: 55%; margin: 1em 0 0 33%; padding-bottom: 2em; - font-size: 1em; - line-height: 1.5em; } .tutorial img { @@ -224,17 +223,17 @@ span.beta { .tutorial blockquote { margin: 0 0 0 1em; - padding: 0 6em 0 1.5em; + padding: 0 6em 0 2em; border-radius: 6px; border-left: 6px solid #DDD; - font-size: 0.95em; + font-size: 1em; } .tutorial h3 { padding-bottom: 1em; } -.content .section-wrapper .language-python { +.content .section-wrapper .highlighter-rouge { grid-area: code; } @@ -245,19 +244,43 @@ pre { border: 1px solid #DDDDDD; } -pre code { - font-size: 0.85em !important; +pre code pre { + padding: 0; + font-size: 0.9em; + line-height: 2.2em; +} + +pre code span { + padding: 0; + margin: 0; + height: 0; +} + +table, pre tbody, pre tbody td, pre tbody td pre { + padding: 0; + border: 0; + margin: 0; + text-align: left; +} + +pre tbody td.gl pre { + color: #999988; + padding-right: 1.6em; + user-select: none; } .content .section-wrapper .section-content { grid-area: body; - font-size: 1em; - line-height: 1.5em; +} + +.content .section-wrapper, .tutorial { + font-size: 1.15em; + line-height: 1.9em; } .content .section-wrapper h3 { grid-area: head; - font-size: 1.4em; + font-size: 1.45em; font-weight: 600; } @@ -276,12 +299,10 @@ a:hover { .secondary-wrapper { width: 100%; grid-area: secondary; - margin: 1em auto 0 auto; - font-size: 1em; - line-height: 1.75em; + margin: 0.6em auto 0 auto; } -.secondary-wrapper .language-python { +.secondary-wrapper div.highlighter-rouge { width: 50%; float: left; margin-top: 1em; From 66cc78f9218b1ed4a625efad4723e1a228072446 Mon Sep 17 00:00:00 2001 From: Shane DeWael Date: Tue, 29 Sep 2020 08:27:28 -0700 Subject: [PATCH 116/865] fix secondary code snippet width --- docs/assets/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/assets/style.css b/docs/assets/style.css index 99c1e26c9..3af53a8c6 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -315,6 +315,10 @@ a:hover { margin-top: 1em; } +.content .section-wrapper .secondary-content div.highlighter-rouge { + width: 100%; +} + summary h4 { display: inline; } From 51b04ea8c9e41f7809b99280d105c5cddb84c99b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 30 Sep 2020 06:26:27 +0900 Subject: [PATCH 117/865] Adjust the documents --- docs/_advanced/authorization.md | 3 --- docs/_advanced/context.md | 3 --- docs/_advanced/lazy_listener.md | 11 +++++++++++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/_advanced/authorization.md b/docs/_advanced/authorization.md index 1f69cfc77..66cd48d5b 100644 --- a/docs/_advanced/authorization.md +++ b/docs/_advanced/authorization.md @@ -45,13 +45,10 @@ installations = [ ] def authorize(enterprise_id, team_id, logger): - # You can implement your own logic to fetch token here - for (team in installations): # enterprise_id doesn't exist for some teams is_valid_enterprise = True if (("enterprise_id" not in team) or (enterprise_id == team["enterprise_id"])) else False - if ((is_valid_enterprise == True) and (team["team_id"] == team_id)): # Return an instance of AuthorizeResult # If you don't store bot_id and bot_user_id, could also call `from_auth_test_response` with your bot_token to automatically fetch them diff --git a/docs/_advanced/context.md b/docs/_advanced/context.md index 4e01a95f7..cc29d239c 100644 --- a/docs/_advanced/context.md +++ b/docs/_advanced/context.md @@ -15,7 +15,6 @@ All listeners have access to a `context` dictionary, which can be used to enrich # Listener middleware to fetch tasks from external system using userId def fetch_tasks(context, event, next): user = event["user"] - try: # Assume get_tasks fetchs list of tasks from DB corresponding to user ID user_tasks = db.get_tasks(user) @@ -31,7 +30,6 @@ def fetch_tasks(context, event, next): # Listener middleware to create a list of section blocks def create_sections(context, next): task_blocks = [] - # Loops through tasks added to context in previous middleware for task in context["tasks"]: task_blocks.append( @@ -51,7 +49,6 @@ def create_sections(context, next): } } ) - # Put list of blocks in context context["blocks"] = task_blocks next() diff --git a/docs/_advanced/lazy_listener.md b/docs/_advanced/lazy_listener.md index 198edafc6..f8e9702f4 100644 --- a/docs/_advanced/lazy_listener.md +++ b/docs/_advanced/lazy_listener.md @@ -18,6 +18,17 @@ Rather than acting as a decorator, lazy listeners take two keyword args:
    ```python +def respond_to_slack_within_3_seconds(body, ack): + if "text" in body: + ack(":x: Usage: /start-process (description here)") + else: + ack(f"Accepted! (task: {body['text']})") + +import time +def run_long_process(respond, body): + time.sleep(5) # longer than 3 seconds + respond(f"Completed! (task: {body['text']})") + app.command("/start-process")( # ack() is still called within 3 seconds ack=respond_to_slack_within_3_seconds, From 6d2ad37fcb07192a607c75f055df456ddfbef7e7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 1 Oct 2020 06:29:50 +0900 Subject: [PATCH 118/865] Remove duplicated imports --- slack_bolt/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/slack_bolt/__init__.py b/slack_bolt/__init__.py index 3f7e5f9b0..44ea7d641 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -5,9 +5,7 @@ from .context.respond import Respond # noqa from .context.say import Say # noqa from .kwargs_injection import Args # noqa -from .kwargs_injection import Args # noqa from .listener import Listener # noqa from .listener_matcher import CustomListenerMatcher # noqa from .request import BoltRequest # noqa from .response import BoltResponse # noqa -from .response import BoltResponse # noqa From 1f3f4acb47f453ecc2015439f2ea0383fc19cf85 Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Wed, 23 Sep 2020 15:47:06 -0700 Subject: [PATCH 119/865] docs > workflow steps section --- docs/_config.yml | 5 ++ docs/_includes/sidebar.html | 54 ++++++++++------ docs/_layouts/default.html | 50 +++++++++------ docs/_steps/adding_editing_workflow_step.md | 68 +++++++++++++++++++++ docs/_steps/creating_workflow_step.md | 40 ++++++++++++ docs/_steps/executing_workflow_steps.md | 44 +++++++++++++ docs/_steps/saving_workflow_step.md | 62 +++++++++++++++++++ docs/_steps/workflow_steps_overview.md | 21 +++++++ 8 files changed, 308 insertions(+), 36 deletions(-) create mode 100644 docs/_steps/adding_editing_workflow_step.md create mode 100644 docs/_steps/creating_workflow_step.md create mode 100644 docs/_steps/executing_workflow_steps.md create mode 100644 docs/_steps/saving_workflow_step.md create mode 100644 docs/_steps/workflow_steps_overview.md diff --git a/docs/_config.yml b/docs/_config.yml index 5854e882e..716bea204 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -11,6 +11,8 @@ url: https://slack.dev collections: basic: output: false + steps: + output: false advanced: output: false tutorials: @@ -29,11 +31,14 @@ defaults: t: en: basic: Basic concepts + steps: Workflow steps advanced: Advanced concepts start: Getting started contribute: Contributing ja-jp: basic: 基本的な概念 + # TODO: translate this title + steps: Workflow steps advanced: 応用コンセプト start: Bolt 入門ガイド contribute: 貢献 diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html index 0b871cfaa..e51aedcff 100644 --- a/docs/_includes/sidebar.html +++ b/docs/_includes/sidebar.html @@ -1,7 +1,7 @@ +
    \ No newline at end of file diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 948a77756..f929bd989 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -15,38 +15,50 @@
    -
    - {% include header.html %} -
    +
    + {% include header.html %} +
    -
    +
    {% assign basic_sections = site.basic | sort: "order" | where: "lang", page.lang %} {% for section in basic_sections %} -
    -

    {{ section.title }}

    +
    +

    {{ section.title }}

    - {{ section.content | markdownify }} + {{ section.content | markdownify }} -
    -
    +
    +
    {% endfor %} +
    + +
    + {% assign workflow_steps = site.steps | sort: "order" | where: "lang", page.lang %} {% for section in + workflow_steps %} +
    +

    {{ section.title }}

    + {{ section.content | markdownify }} +
    + {% endfor %} +
    -
    - {% assign advanced_sections = site.advanced | sort: "order" | where: "lang", page.lang %} - {% for section in advanced_sections %} -
    -

    {{ section.title }}

    +
    + {% assign advanced_sections = site.advanced | sort: "order" | where: "lang", page.lang %} + {% for section in advanced_sections %} +
    +

    {{ section.title }}

    - {{ section.content | markdownify }} + {{ section.content | markdownify }} -
    -
    - {% endfor %} +
    + {% endfor %} +
    {% include analytics.html %} - + + \ No newline at end of file diff --git a/docs/_steps/adding_editing_workflow_step.md b/docs/_steps/adding_editing_workflow_step.md new file mode 100644 index 000000000..b11b8d384 --- /dev/null +++ b/docs/_steps/adding_editing_workflow_step.md @@ -0,0 +1,68 @@ +--- +title: Adding or editing workflow steps +lang: en +slug: adding-editing-steps +order: 3 +--- + +
    + +When a builder adds (or later edits) your step in their workflow, your app will receive a [`workflow_step_edit` event](https://api.slack.com/reference/workflows/workflow_step_edit). The `edit` callback in your `WorkflowStep` configuration will be run when this event is received. + +Whether a builder is adding or editing a step, you need to send them a [workflow step configuration modal](https://api.slack.com/reference/workflows/configuration-view). This modal is where step-specific settings are chosen, and it has more restrictions than typical modals—most notably, it cannot include `title​`, `submit​`, or `close`​ properties. By default, the configuration modal's `callback_id` will be the same as the workflow step. + +Within the `edit` callback, the `configure()` utility can be used to easily open your step's configuration modal by passing in the view's blocks with the corresponding `blocks` argument. To disable saving the configuration before certain conditions are met, you can also pass in `submit_disabled` with a value of `True`. + +To learn more about opening configuration modals, [read the documentation](https://api.slack.com/workflows/steps#handle_config_view). + +
    + +```python +def edit_handler(ack, step, configure): + ack() + + blocks = [ + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "name", + "placeholder": { + "type": "plain_text", + "text": "Add a task name", + }, + }, + "label": { + "type": "plain_text", + "text": "Task name", + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "description", + "placeholder": { + "type": "plain_text", + "text": "Add a task description", + }, + }, + "label": { + "type": "plain_text", + "text": "Task description", + }, + }, + ] + + configure(blocks=blocks) + +ws = WorkflowStep(callback_id="add_task", config={ + "edit": edit_handler, + "save": save_handler, + "execute": execute_handler, +}) + +app.step(ws) +``` diff --git a/docs/_steps/creating_workflow_step.md b/docs/_steps/creating_workflow_step.md new file mode 100644 index 000000000..2e4c49e7e --- /dev/null +++ b/docs/_steps/creating_workflow_step.md @@ -0,0 +1,40 @@ +--- +title: Creating workflow steps +lang: en +slug: creating-steps +order: 2 +--- + +
    + +To create a workflow step, Bolt provides the `WorkflowStep` class. + +When instantiating a new `WorkflowStep`, pass in the step's `callback_id` and a configuration object. + +The configuration object contains three keys: `edit`, `save`, and `execute`. Each of these keys must be a single callback or a list of callbacks. All callbacks have access to a `step` object that contains information about the workflow step event. + +After instantiating a `WorkflowStep`, you can pass it into `app.step()`. Behind the scenes, your app will listen and respond to the workflow step’s events using the callbacks provided in the configuration object. + +
    + +```python +from slack_bolt import App, WorkflowStep + +# Initiate the Bolt app as you normally would +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# Define the step's configuration +ws_config = { + "edit": edit_handler, + "save": save_handler, + "execute": execute_handler, +} + +# Create a new WorkflowStep instance +ws = WorkflowStep(callback_id="add_task", config=ws_config) + +app.step(ws) +``` diff --git a/docs/_steps/executing_workflow_steps.md b/docs/_steps/executing_workflow_steps.md new file mode 100644 index 000000000..9f8725afa --- /dev/null +++ b/docs/_steps/executing_workflow_steps.md @@ -0,0 +1,44 @@ +--- +title: Executing workflow steps +lang: en +slug: executing-steps +order: 5 +--- + +
    + +When your workflow step is executed by an end user, your app will receive a [`workflow_step_execute` event](https://api.slack.com/events/workflow_step_execute). The `execute` callback in your `WorkflowStep` configuration will be run when this event is received. + +Using the `inputs` from the `save` callback, this is where you can make third-party API calls, save information to a database, update the user's Home tab, or decide the outputs that will be available to subsequent workflow steps by mapping values to the `outputs` object. + +Within the `execute` callback, your app must either call `complete()` to indicate that the step's execution was successful, or `fail()` to indicate that the step's execution failed. + +
    + +```python +def execute_handler(step, complete, fail): + inputs = step["inputs"] + + outputs = { + "task_name": inputs["task_name"]["value"], + "task_description": inputs["task_description"]["value"], + } + + error = { + "message": "Just testing step failure!" + } + + # if everything was successful + complete(outputs=outputs) + + # if something went wrong + fail(error=error) + +ws = WorkflowStep(callback_id="add_task", config={ + "edit": edit_handler, + "save": save_handler, + "execute": execute_handler, +}) + +app.step(ws) +``` \ No newline at end of file diff --git a/docs/_steps/saving_workflow_step.md b/docs/_steps/saving_workflow_step.md new file mode 100644 index 000000000..8a0d95b35 --- /dev/null +++ b/docs/_steps/saving_workflow_step.md @@ -0,0 +1,62 @@ +--- +title: Saving step configurations +lang: en +slug: saving-steps +order: 4 +--- + +
    + +After the configuration modal is opened, your app will listen for the `view_submission` event. The `save` callback in your `WorkflowStep` configuration will be run when this event is received. + +Within the `save` callback, the `update()` method can be used to save the builder's step configuration by passing in the following arguments: + +- `inputs` is an dictionary representing the data your app expects to receive from the user upon workflow step execution. +- `outputs` is a list of objects containing data that your app will provide upon the workflow step's completion. Outputs can then be used in subsequent steps of the workflow. +- `step_name` overrides the default Step name +- `step_image_url` overrides the default Step image + +To learn more about how to structure these parameters, [read the documentation](https://api.slack.com/reference/workflows/workflow_step). + +
    + +```python +def save_handler(ack, view, update): + ack() + + values = view.state + task_name = values["task_name_input"]["name"] + task_description = values["task_description_input"]["description"] + + inputs = { + "task_name": { + "value": task_name.value + }, + "task_description": { + "value": task_description.value + }, + } + + outputs = [ + { + "type": "text", + "name": "task_name", + "label": "Task name", + }, + { + "type": "text", + "name": "task_description", + "label": "Task description", + } + ] + + update(inputs=inputs, outputs=outputs); + +ws = WorkflowStep(callback_id="add_task", config={ + "edit": edit_handler, + "save": save_handler, + "execute": execute_handler, +}) + +app.step(ws) +``` \ No newline at end of file diff --git a/docs/_steps/workflow_steps_overview.md b/docs/_steps/workflow_steps_overview.md new file mode 100644 index 000000000..eff6d4c5e --- /dev/null +++ b/docs/_steps/workflow_steps_overview.md @@ -0,0 +1,21 @@ +--- +title: Overview of Workflow Steps for apps +lang: en +slug: steps-overview +order: 1 +--- + +
    +Workflow Steps from apps allow your app to create and process custom workflow steps that users can add using [Workflow Builder](https://api.slack.com/workflows). + +A workflow step is made up of three distinct user events: + +- Adding or editing the step in a Workflow +- Saving or updating the step's configuration +- The end user's execution of the step + +All three events must be handled for a workflow step to function. + +Read more about workflow steps from apps in the [API documentation](https://api.slack.com/workflows/steps). + +
    From 35d8ee3eba9bb6cbd2219c83d759dcef643d8e8f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 24 Sep 2020 20:35:55 +0900 Subject: [PATCH 120/865] Add Steps from Apps (no tests, WIP) --- samples/async_steps_from_apps.py | 239 +++++++++-------- samples/async_steps_from_apps_primitive.py | 172 ++++++++++++ samples/steps_from_apps.py | 250 +++++++++--------- samples/steps_from_apps_primitive.py | 174 ++++++++++++ slack_bolt/app/app.py | 29 ++ slack_bolt/app/async_app.py | 34 +++ slack_bolt/workflows/__init__.py | 0 slack_bolt/workflows/step/__init__.py | 6 + slack_bolt/workflows/step/async_step.py | 138 ++++++++++ .../workflows/step/async_step_middleware.py | 53 ++++ slack_bolt/workflows/step/step.py | 136 ++++++++++ slack_bolt/workflows/step/step_middleware.py | 47 ++++ .../workflows/step/utilities/__init__.py | 0 .../step/utilities/async_complete.py | 15 ++ .../step/utilities/async_configure.py | 23 ++ .../workflows/step/utilities/async_fail.py | 15 ++ .../workflows/step/utilities/async_update.py | 14 + .../workflows/step/utilities/complete.py | 15 ++ .../workflows/step/utilities/configure.py | 21 ++ slack_bolt/workflows/step/utilities/fail.py | 15 ++ slack_bolt/workflows/step/utilities/update.py | 14 + 21 files changed, 1166 insertions(+), 244 deletions(-) create mode 100644 samples/async_steps_from_apps_primitive.py create mode 100644 samples/steps_from_apps_primitive.py create mode 100644 slack_bolt/workflows/__init__.py create mode 100644 slack_bolt/workflows/step/__init__.py create mode 100644 slack_bolt/workflows/step/async_step.py create mode 100644 slack_bolt/workflows/step/async_step_middleware.py create mode 100644 slack_bolt/workflows/step/step.py create mode 100644 slack_bolt/workflows/step/step_middleware.py create mode 100644 slack_bolt/workflows/step/utilities/__init__.py create mode 100644 slack_bolt/workflows/step/utilities/async_complete.py create mode 100644 slack_bolt/workflows/step/utilities/async_configure.py create mode 100644 slack_bolt/workflows/step/utilities/async_fail.py create mode 100644 slack_bolt/workflows/step/utilities/async_update.py create mode 100644 slack_bolt/workflows/step/utilities/complete.py create mode 100644 slack_bolt/workflows/step/utilities/configure.py create mode 100644 slack_bolt/workflows/step/utilities/fail.py create mode 100644 slack_bolt/workflows/step/utilities/update.py diff --git a/samples/async_steps_from_apps.py b/samples/async_steps_from_apps.py index 3c65addf0..90f1558bf 100644 --- a/samples/async_steps_from_apps.py +++ b/samples/async_steps_from_apps.py @@ -9,6 +9,7 @@ from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient from slack_bolt.async_app import AsyncApp, AsyncAck +from slack_bolt.workflows.step.async_step import AsyncConfigure, AsyncUpdate, AsyncComplete, AsyncFail logging.basicConfig(level=logging.DEBUG) @@ -20,101 +21,90 @@ # https://api.slack.com/tutorials/workflow-builder-steps -@app.action({"type": "workflow_step_edit", "callback_id": "copy_review"}) -async def edit(body: dict, ack: AsyncAck, client: AsyncWebClient): +async def edit(ack: AsyncAck, configure: AsyncConfigure): await ack() - new_modal: AsyncSlackResponse = await client.views_open( - trigger_id=body["trigger_id"], - view={ - "type": "workflow_step", - "callback_id": "copy_review_view", - "blocks": [ - { - "type": "section", - "block_id": "intro-section", - "text": { - "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", - }, + await configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", }, - { - "type": "input", - "block_id": "task_name_input", - "element": { - "type": "plain_text_input", - "action_id": "task_name", - "placeholder": { - "type": "plain_text", - "text": "Write a task name", - }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", }, - "label": {"type": "plain_text", "text": "Task name"}, }, - { - "type": "input", - "block_id": "task_description_input", - "element": { - "type": "plain_text_input", - "action_id": "task_description", - "placeholder": { - "type": "plain_text", - "text": "Write a description for your task", - }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", }, - "label": {"type": "plain_text", "text": "Task description"}, }, - { - "type": "input", - "block_id": "task_author_input", - "element": { - "type": "plain_text_input", - "action_id": "task_author", - "placeholder": { - "type": "plain_text", - "text": "Write a task name", - }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", }, - "label": {"type": "plain_text", "text": "Task author"}, }, - ], - }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] ) -@app.view("copy_review_view") -async def save(ack: AsyncAck, client: AsyncWebClient, body: dict): +async def save(ack: AsyncAck, body: dict, update: AsyncUpdate): state_values = body["view"]["state"]["values"] - response: AsyncSlackResponse = await client.api_call( - api_method="workflows.updateStep", - json={ - "workflow_step_edit_id": body["workflow_step"]["workflow_step_edit_id"], - "inputs": { - "taskName": { - "value": state_values["task_name_input"]["task_name"]["value"], - }, - "taskDescription": { - "value": state_values["task_description_input"]["task_description"][ - "value" - ], - }, - "taskAuthorEmail": { - "value": state_values["task_author_input"]["task_author"]["value"], - }, + await update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], }, - "outputs": [ - {"name": "taskName", "type": "text", "label": "Task Name",}, - { - "name": "taskDescription", - "type": "text", - "label": "Task Description", - }, - { - "name": "taskAuthorEmail", - "type": "text", - "label": "Task Author Email", - }, - ], }, + outputs=[ + {"name": "taskName", "type": "text", "label": "Task Name", }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ] ) await ack() @@ -122,51 +112,58 @@ async def save(ack: AsyncAck, client: AsyncWebClient, body: dict): pseudo_database = {} -@app.event("workflow_step_execute") -async def execute(body: dict, client: AsyncWebClient): - step = body["event"]["workflow_step"] - completion: AsyncSlackResponse = await client.api_call( - api_method="workflows.stepCompleted", - json={ - "workflow_step_execute_id": step["workflow_step_execute_id"], - "outputs": { +async def execute(body: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail): + try: + step = body["event"]["workflow_step"] + await complete( + outputs={ "taskName": step["inputs"]["taskName"]["value"], "taskDescription": step["inputs"]["taskDescription"]["value"], "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], - }, - }, - ) - user: AsyncSlackResponse = await client.users_lookupByEmail( - email=step["inputs"]["taskAuthorEmail"]["value"] - ) - user_id = user["user"]["id"] - new_task = { - "task_name": step["inputs"]["taskName"]["value"], - "task_description": step["inputs"]["taskDescription"]["value"], - } - tasks = pseudo_database.get(user_id, []) - tasks.append(new_task) - pseudo_database[user_id] = tasks - - blocks = [] - for task in tasks: - blocks.append( - { - "type": "section", - "text": {"type": "plain_text", "text": task["task_name"]}, } ) - blocks.append({"type": "divider"}) - - home_tab_update: AsyncSlackResponse = await client.views_publish( - user_id=user_id, - view={ - "type": "home", - "title": {"type": "plain_text", "text": "Your tasks!"}, - "blocks": blocks, - }, - ) - + user: AsyncSlackResponse = await client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + home_tab_update: AsyncSlackResponse = await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except: + await fail(error={ + "message": "Something wrong!" + }) + + +app.step( + callback_id="copy_review", + edit=edit, + save=save, + execute=execute, +) if __name__ == "__main__": app.start(3000) # POST http://localhost:3000/slack/events diff --git a/samples/async_steps_from_apps_primitive.py b/samples/async_steps_from_apps_primitive.py new file mode 100644 index 000000000..3c65addf0 --- /dev/null +++ b/samples/async_steps_from_apps_primitive.py @@ -0,0 +1,172 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging + +from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient +from slack_bolt.async_app import AsyncApp, AsyncAck + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = AsyncApp() + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +@app.action({"type": "workflow_step_edit", "callback_id": "copy_review"}) +async def edit(body: dict, ack: AsyncAck, client: AsyncWebClient): + await ack() + new_modal: AsyncSlackResponse = await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "workflow_step", + "callback_id": "copy_review_view", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ], + }, + ) + + +@app.view("copy_review_view") +async def save(ack: AsyncAck, client: AsyncWebClient, body: dict): + state_values = body["view"]["state"]["values"] + response: AsyncSlackResponse = await client.api_call( + api_method="workflows.updateStep", + json={ + "workflow_step_edit_id": body["workflow_step"]["workflow_step_edit_id"], + "inputs": { + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name",}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + ) + await ack() + + +pseudo_database = {} + + +@app.event("workflow_step_execute") +async def execute(body: dict, client: AsyncWebClient): + step = body["event"]["workflow_step"] + completion: AsyncSlackResponse = await client.api_call( + api_method="workflows.stepCompleted", + json={ + "workflow_step_execute_id": step["workflow_step_execute_id"], + "outputs": { + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + }, + }, + ) + user: AsyncSlackResponse = await client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + home_tab_update: AsyncSlackResponse = await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/samples/steps_from_apps.py b/samples/steps_from_apps.py index 9f8889cbc..9419c4831 100644 --- a/samples/steps_from_apps.py +++ b/samples/steps_from_apps.py @@ -2,6 +2,7 @@ # instead of slack_bolt in requirements.txt import sys + sys.path.insert(1, "..") # ------------------------------------------------ @@ -11,6 +12,7 @@ from slack_sdk.web import SlackResponse from slack_bolt import App, Ack +from slack_bolt.workflows.step import Configure, Update, Complete, Fail logging.basicConfig(level=logging.DEBUG) @@ -19,104 +21,99 @@ app = App() +@app.middleware # or app.use(log_request) +def log_request(logger, body, next): + logger.debug(body) + return next() + + # https://api.slack.com/tutorials/workflow-builder-steps -@app.action({"type": "workflow_step_edit", "callback_id": "copy_review"}) -def edit(body: dict, ack: Ack, client: WebClient): +def edit(ack: Ack, configure: Configure): ack() - new_modal: SlackResponse = client.views_open( - trigger_id=body["trigger_id"], - view={ - "type": "workflow_step", - "callback_id": "copy_review_view", - "blocks": [ - { - "type": "section", - "block_id": "intro-section", - "text": { - "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", - }, + configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", }, - { - "type": "input", - "block_id": "task_name_input", - "element": { - "type": "plain_text_input", - "action_id": "task_name", - "placeholder": { - "type": "plain_text", - "text": "Write a task name", - }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", }, - "label": {"type": "plain_text", "text": "Task name"}, }, - { - "type": "input", - "block_id": "task_description_input", - "element": { - "type": "plain_text_input", - "action_id": "task_description", - "placeholder": { - "type": "plain_text", - "text": "Write a description for your task", - }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", }, - "label": {"type": "plain_text", "text": "Task description"}, }, - { - "type": "input", - "block_id": "task_author_input", - "element": { - "type": "plain_text_input", - "action_id": "task_author", - "placeholder": { - "type": "plain_text", - "text": "Write a task name", - }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", }, - "label": {"type": "plain_text", "text": "Task author"}, }, - ], - }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] ) -@app.view("copy_review_view") -def save(ack: Ack, client: WebClient, body: dict): +def save(ack: Ack, body: dict, update: Update): state_values = body["view"]["state"]["values"] - response: SlackResponse = client.api_call( - api_method="workflows.updateStep", - json={ - "workflow_step_edit_id": body["workflow_step"]["workflow_step_edit_id"], - "inputs": { - "taskName": { - "value": state_values["task_name_input"]["task_name"]["value"], - }, - "taskDescription": { - "value": state_values["task_description_input"]["task_description"][ - "value" - ], - }, - "taskAuthorEmail": { - "value": state_values["task_author_input"]["task_author"]["value"], - }, + update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], }, - "outputs": [ - {"name": "taskName", "type": "text", "label": "Task Name",}, - { - "name": "taskDescription", - "type": "text", - "label": "Task Description", - }, - { - "name": "taskAuthorEmail", - "type": "text", - "label": "Task Author Email", - }, - ], }, + outputs=[ + {"name": "taskName", "type": "text", "label": "Task Name", }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ] ) ack() @@ -124,51 +121,58 @@ def save(ack: Ack, client: WebClient, body: dict): pseudo_database = {} -@app.event("workflow_step_execute") -def execute(body: dict, client: WebClient): +def execute(body: dict, client: WebClient, complete: Complete, fail: Fail): step = body["event"]["workflow_step"] - completion: SlackResponse = client.api_call( - api_method="workflows.stepCompleted", - json={ - "workflow_step_execute_id": step["workflow_step_execute_id"], - "outputs": { - "taskName": step["inputs"]["taskName"]["value"], - "taskDescription": step["inputs"]["taskDescription"]["value"], - "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], - }, - }, - ) - user: SlackResponse = client.users_lookupByEmail( - email=step["inputs"]["taskAuthorEmail"]["value"] - ) - user_id = user["user"]["id"] - new_task = { - "task_name": step["inputs"]["taskName"]["value"], - "task_description": step["inputs"]["taskDescription"]["value"], - } - tasks = pseudo_database.get(user_id, []) - tasks.append(new_task) - pseudo_database[user_id] = tasks - - blocks = [] - for task in tasks: - blocks.append( - { - "type": "section", - "text": {"type": "plain_text", "text": task["task_name"]}, - } + try: + complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } ) - blocks.append({"type": "divider"}) - - home_tab_update: SlackResponse = client.views_publish( - user_id=user_id, - view={ - "type": "home", - "title": {"type": "plain_text", "text": "Your tasks!"}, - "blocks": blocks, - }, - ) + user: SlackResponse = client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + home_tab_update: SlackResponse = client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + fail(error={ + "message": "Something wrong!" + }) + +app.step( + callback_id="copy_review", + edit=edit, + save=save, + execute=execute, +) if __name__ == "__main__": app.start(3000) # POST http://localhost:3000/slack/events diff --git a/samples/steps_from_apps_primitive.py b/samples/steps_from_apps_primitive.py new file mode 100644 index 000000000..9f8889cbc --- /dev/null +++ b/samples/steps_from_apps_primitive.py @@ -0,0 +1,174 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt import App, Ack + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = App() + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +@app.action({"type": "workflow_step_edit", "callback_id": "copy_review"}) +def edit(body: dict, ack: Ack, client: WebClient): + ack() + new_modal: SlackResponse = client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "workflow_step", + "callback_id": "copy_review_view", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ], + }, + ) + + +@app.view("copy_review_view") +def save(ack: Ack, client: WebClient, body: dict): + state_values = body["view"]["state"]["values"] + response: SlackResponse = client.api_call( + api_method="workflows.updateStep", + json={ + "workflow_step_edit_id": body["workflow_step"]["workflow_step_edit_id"], + "inputs": { + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name",}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + ) + ack() + + +pseudo_database = {} + + +@app.event("workflow_step_execute") +def execute(body: dict, client: WebClient): + step = body["event"]["workflow_step"] + completion: SlackResponse = client.api_call( + api_method="workflows.stepCompleted", + json={ + "workflow_step_execute_id": step["workflow_step_execute_id"], + "outputs": { + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + }, + }, + ) + user: SlackResponse = client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + home_tab_update: SlackResponse = client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 58a70643b..e9b129e1a 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -7,6 +7,7 @@ from typing import List, Union, Pattern, Callable, Dict, Optional from slack_bolt.listener.thread_runner import ThreadListenerRunner +from slack_bolt.workflows.step import WorkflowStep, WorkflowStepMiddleware from slack_sdk.errors import SlackApiError from slack_sdk.oauth.installation_store import InstallationStore from slack_sdk.web import WebClient @@ -345,6 +346,34 @@ def middleware(self, *args) -> None: CustomMiddleware(app_name=self.name, func=middleware_or_callable) ) + # ------------------------- + # Workflows: Steps from Apps + + def step( + self, + callback_id: Union[str, Pattern, WorkflowStep], + save_callback_id: Optional[Union[str, Pattern]] = None, + edit: Optional[Union[Callable[..., Optional[BoltResponse]], Listener]] = None, + save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener]] = None, + execute: Optional[ + Union[Callable[..., Optional[BoltResponse]], Listener] + ] = None, + ): + """Registers a new Workflow Steps from Apps listeners.""" + step = callback_id + if isinstance(callback_id, (str, Pattern)): + step = WorkflowStep( + callback_id=callback_id, + save_callback_id=save_callback_id, + edit=edit, + save=save, + execute=execute, + ) + elif not isinstance(step, WorkflowStep): + raise BoltError("Invalid step object") + + self.use(WorkflowStepMiddleware(step, self.listener_runner)) + # ------------------------- # global error handler diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 465ce3dc9..4f72a5977 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -7,6 +7,8 @@ from slack_bolt.middleware.message_listener_matches.async_message_listener_matches import ( AsyncMessageListenerMatches, ) +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep +from slack_bolt.workflows.step.async_step_middleware import AsyncWorkflowStepMiddleware from slack_sdk.oauth.installation_store.async_installation_store import ( AsyncInstallationStore, ) @@ -356,6 +358,38 @@ def middleware(self, *args) -> None: ) ) + # ------------------------- + # Workflows: Steps from Apps + + def step( + self, + callback_id: Union[str, Pattern, AsyncWorkflowStep], + save_callback_id: Optional[Union[str, Pattern]] = None, + edit: Optional[ + Union[Callable[..., Optional[BoltResponse]], AsyncListener] + ] = None, + save: Optional[ + Union[Callable[..., Optional[BoltResponse]], AsyncListener] + ] = None, + execute: Optional[ + Union[Callable[..., Optional[BoltResponse]], AsyncListener] + ] = None, + ): + """Registers a new Workflow Steps from Apps listeners.""" + step = callback_id + if isinstance(callback_id, (str, Pattern)): + step = AsyncWorkflowStep( + callback_id=callback_id, + save_callback_id=save_callback_id, + edit=edit, + save=save, + execute=execute, + ) + elif not isinstance(step, AsyncWorkflowStep): + raise BoltError("Invalid step object") + + self.use(AsyncWorkflowStepMiddleware(step, self._async_listener_runner)) + # ------------------------- # global error handler diff --git a/slack_bolt/workflows/__init__.py b/slack_bolt/workflows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/slack_bolt/workflows/step/__init__.py b/slack_bolt/workflows/step/__init__.py new file mode 100644 index 000000000..8bd0a067a --- /dev/null +++ b/slack_bolt/workflows/step/__init__.py @@ -0,0 +1,6 @@ +from .step import WorkflowStep +from .step_middleware import WorkflowStepMiddleware +from .utilities.complete import Complete +from .utilities.configure import Configure +from .utilities.update import Update +from .utilities.fail import Fail diff --git a/slack_bolt/workflows/step/async_step.py b/slack_bolt/workflows/step/async_step.py new file mode 100644 index 000000000..049cc257d --- /dev/null +++ b/slack_bolt/workflows/step/async_step.py @@ -0,0 +1,138 @@ +from typing import Callable, Union, Optional, Awaitable + +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener +from slack_bolt.listener_matcher.builtins import ( + workflow_step_edit, + workflow_step_save, + workflow_step_execute, +) +from slack_bolt.middleware.async_custom_middleware import AsyncCustomMiddleware +from slack_bolt.response import BoltResponse +from slack_sdk.web.async_client import AsyncWebClient +from .utilities.async_configure import AsyncConfigure +from .utilities.async_fail import AsyncFail +from .utilities.async_complete import AsyncComplete +from .utilities.async_update import AsyncUpdate + + +class AsyncWorkflowStep: + callback_id: str + edit: AsyncListener + save: AsyncListener + execute: AsyncListener + + def __init__( + self, + *, + callback_id: str, + edit: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], + save: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], + execute: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], + save_callback_id: Optional[str] = None, # TODO: better approach? + app_name: Optional[str] = None, + ): + self.callback_id = callback_id + app_name = app_name or __name__ + self.edit = self._build_listener(callback_id, app_name, edit, "edit") + self.save = self._build_listener( + save_callback_id or callback_id, app_name, save, "save" + ) + self.execute = self._build_listener(callback_id, app_name, execute, "execute") + + @classmethod + def _build_listener( + cls, callback_id: str, app_name: str, listener: AsyncListener, name: str, + ): + if isinstance(listener, AsyncListener): + return listener + elif isinstance(listener, Callable): + return AsyncCustomListener( + app_name=app_name, + matchers=cls._build_matchers(name, callback_id), + middleware=cls._build_middleware(name, callback_id), + ack_function=listener, + lazy_functions=[], + auto_acknowledgement=name == "execute", + ) + else: + raise ValueError(f"Invalid `{name}` listener") + + @classmethod + def _build_matchers(cls, name: str, callback_id: str): + if name == "edit": + return [workflow_step_edit(callback_id, asyncio=True)] + elif name == "save": + return [workflow_step_save(callback_id, asyncio=True)] + elif name == "execute": + return [workflow_step_execute(asyncio=True)] + else: + raise ValueError(f"Invalid name {name}") + + @classmethod + def _build_middleware(cls, name: str, callback_id: str): + if name == "edit": + return [_build_edit_listener_middleware(callback_id)] + elif name == "save": + return [_build_save_listener_middleware()] + elif name == "execute": + return [_build_execute_listener_middleware()] + else: + raise ValueError(f"Invalid name {name}") + + +####################### +# Edit +####################### + + +def _build_edit_listener_middleware(callback_id: str): + async def edit_listener_middleware( + context: AsyncBoltContext, + client: AsyncWebClient, + body: dict, + next: Callable[[], Awaitable[BoltResponse]], + ): + context["configure"] = AsyncConfigure( + callback_id=callback_id, client=client, body=body, + ) + return await next() + + return AsyncCustomMiddleware(app_name=__name__, func=edit_listener_middleware) + + +####################### +# Save +####################### + + +def _build_save_listener_middleware(): + async def save_listener_middleware( + context: AsyncBoltContext, + client: AsyncWebClient, + body: dict, + next: Callable[[], Awaitable[BoltResponse]], + ): + context["update"] = AsyncUpdate(client=client, body=body,) + return await next() + + return AsyncCustomMiddleware(app_name=__name__, func=save_listener_middleware) + + +####################### +# Execute +####################### + + +def _build_execute_listener_middleware(): + async def save_listener_middleware( + context: AsyncBoltContext, + client: AsyncWebClient, + body: dict, + next: Callable[[], Awaitable[BoltResponse]], + ): + context["complete"] = AsyncComplete(client=client, body=body,) + context["fail"] = AsyncFail(client=client, body=body,) + return await next() + + return AsyncCustomMiddleware(app_name=__name__, func=save_listener_middleware) diff --git a/slack_bolt/workflows/step/async_step_middleware.py b/slack_bolt/workflows/step/async_step_middleware.py new file mode 100644 index 000000000..cac50d3b0 --- /dev/null +++ b/slack_bolt/workflows/step/async_step_middleware.py @@ -0,0 +1,53 @@ +from typing import Callable, Optional, Awaitable + +from slack_bolt.listener.async_listener import AsyncListener +from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner +from slack_bolt.middleware.async_middleware import AsyncMiddleware +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep + + +class AsyncWorkflowStepMiddleware(AsyncMiddleware): # type:ignore + def __init__(self, step: AsyncWorkflowStep, listener_runner: AsyncioListenerRunner): + self.step = step + self.listener_runner = listener_runner + + async def async_process( + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + next: Callable[[], Awaitable[BoltResponse]], + ) -> BoltResponse: + + if await self.step.edit.async_matches(req=req, resp=resp): + resp = await self._run(self.step.edit, req, resp) + if resp is not None: + return resp + elif await self.step.save.async_matches(req=req, resp=resp): + resp = await self._run(self.step.save, req, resp) + if resp is not None: + return resp + elif await self.step.execute.async_matches(req=req, resp=resp): + resp = await self._run(self.step.execute, req, resp) + if resp is not None: + return resp + + return await next() + + async def _run( + self, listener: AsyncListener, req: AsyncBoltRequest, resp: BoltResponse, + ) -> Optional[BoltResponse]: + resp, next_was_not_called = await listener.run_async_middleware( + req=req, resp=resp + ) + if next_was_not_called: + return None + + return await self.listener_runner.run( + request=req, + response=resp, + listener_name=listener.ack_function.__name__, + listener=listener, + ) diff --git a/slack_bolt/workflows/step/step.py b/slack_bolt/workflows/step/step.py new file mode 100644 index 000000000..c9de64f5a --- /dev/null +++ b/slack_bolt/workflows/step/step.py @@ -0,0 +1,136 @@ +from typing import Callable, Union, Optional + +from slack_bolt.context import BoltContext +from slack_bolt.listener import Listener, CustomListener +from slack_bolt.listener_matcher.builtins import ( + workflow_step_edit, + workflow_step_save, + workflow_step_execute, +) +from slack_bolt.middleware import CustomMiddleware +from slack_bolt.response import BoltResponse +from slack_bolt.workflows.step.utilities.complete import Complete +from slack_bolt.workflows.step.utilities.configure import Configure +from slack_bolt.workflows.step.utilities.fail import Fail +from slack_bolt.workflows.step.utilities.update import Update +from slack_sdk.web import WebClient + + +class WorkflowStep: + callback_id: str + edit: Listener + save: Listener + execute: Listener + + def __init__( + self, + *, + callback_id: str, + edit: Union[Callable[..., Optional[BoltResponse]], Listener], + save: Union[Callable[..., Optional[BoltResponse]], Listener], + execute: Union[Callable[..., Optional[BoltResponse]], Listener], + save_callback_id: Optional[str] = None, # TODO: better approach? + app_name: Optional[str] = None, + ): + self.callback_id = callback_id + app_name = app_name or __name__ + self.edit = self._build_listener(callback_id, app_name, edit, "edit") + self.save = self._build_listener( + save_callback_id or callback_id, app_name, save, "save" + ) + self.execute = self._build_listener(callback_id, app_name, execute, "execute") + + @classmethod + def _build_listener(cls, callback_id, app_name, listener, name): + if isinstance(listener, Listener): + return listener + elif isinstance(listener, Callable): + return CustomListener( + app_name=app_name, + matchers=cls._build_matchers(name, callback_id), + middleware=cls._build_middleware(name, callback_id), + ack_function=listener, + lazy_functions=[], + auto_acknowledgement=name == "execute", + ) + else: + raise ValueError(f"Invalid `{name}` listener") + + @classmethod + def _build_matchers(cls, name, callback_id): + if name == "edit": + return [workflow_step_edit(callback_id)] + elif name == "save": + return [workflow_step_save(callback_id)] + elif name == "execute": + return [workflow_step_execute()] + else: + raise ValueError(f"Invalid name {name}") + + @classmethod + def _build_middleware(cls, name, callback_id): + if name == "edit": + return [_build_edit_listener_middleware(callback_id)] + elif name == "save": + return [_build_save_listener_middleware()] + elif name == "execute": + return [_build_execute_listener_middleware()] + else: + raise ValueError(f"Invalid name {name}") + + +####################### +# Edit +####################### + + +def _build_edit_listener_middleware(callback_id): + def edit_listener_middleware( + context: BoltContext, + client: WebClient, + body: dict, + next: Callable[[], BoltResponse], + ): + context["configure"] = Configure( + callback_id=callback_id, client=client, body=body, + ) + return next() + + return CustomMiddleware(app_name=__name__, func=edit_listener_middleware) + + +####################### +# Save +####################### + + +def _build_save_listener_middleware(): + def save_listener_middleware( + context: BoltContext, + client: WebClient, + body: dict, + next: Callable[[], BoltResponse], + ): + context["update"] = Update(client=client, body=body,) + return next() + + return CustomMiddleware(app_name=__name__, func=save_listener_middleware) + + +####################### +# Execute +####################### + + +def _build_execute_listener_middleware(): + def save_listener_middleware( + context: BoltContext, + client: WebClient, + body: dict, + next: Callable[[], BoltResponse], + ): + context["complete"] = Complete(client=client, body=body,) + context["fail"] = Fail(client=client, body=body,) + return next() + + return CustomMiddleware(app_name=__name__, func=save_listener_middleware) diff --git a/slack_bolt/workflows/step/step_middleware.py b/slack_bolt/workflows/step/step_middleware.py new file mode 100644 index 000000000..2cbaac772 --- /dev/null +++ b/slack_bolt/workflows/step/step_middleware.py @@ -0,0 +1,47 @@ +from typing import Callable, Optional + +from slack_bolt.listener import Listener +from slack_bolt.listener.thread_runner import ThreadListenerRunner +from slack_bolt.middleware import Middleware +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.workflows.step.step import WorkflowStep + + +class WorkflowStepMiddleware(Middleware): # type:ignore + def __init__(self, step: WorkflowStep, listener_runner: ThreadListenerRunner): + self.step = step + self.listener_runner = listener_runner + + def process( + self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + ) -> BoltResponse: + + if self.step.edit.matches(req=req, resp=resp): + resp = self._run(self.step.edit, req, resp) + if resp is not None: + return resp + elif self.step.save.matches(req=req, resp=resp): + resp = self._run(self.step.save, req, resp) + if resp is not None: + return resp + elif self.step.execute.matches(req=req, resp=resp): + resp = self._run(self.step.execute, req, resp) + if resp is not None: + return resp + + return next() + + def _run( + self, listener: Listener, req: BoltRequest, resp: BoltResponse, + ) -> Optional[BoltResponse]: + resp, next_was_not_called = listener.run_middleware(req=req, resp=resp) + if next_was_not_called: + return None + + return self.listener_runner.run( + request=req, + response=resp, + listener_name=listener.ack_function.__name__, + listener=listener, + ) diff --git a/slack_bolt/workflows/step/utilities/__init__.py b/slack_bolt/workflows/step/utilities/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/slack_bolt/workflows/step/utilities/async_complete.py b/slack_bolt/workflows/step/utilities/async_complete.py new file mode 100644 index 000000000..1dc2302f6 --- /dev/null +++ b/slack_bolt/workflows/step/utilities/async_complete.py @@ -0,0 +1,15 @@ +from slack_sdk.web.async_client import AsyncWebClient + + +class AsyncComplete: + def __init__(self, *, client: AsyncWebClient, body: dict): + self.client = client + self.body = body + + async def __call__(self, *, outputs: dict,) -> None: + await self.client.workflows_stepCompleted( + workflow_step_execute_id=self.body["event"]["workflow_step"][ + "workflow_step_execute_id" + ], + outputs=outputs, + ) diff --git a/slack_bolt/workflows/step/utilities/async_configure.py b/slack_bolt/workflows/step/utilities/async_configure.py new file mode 100644 index 000000000..e8011d742 --- /dev/null +++ b/slack_bolt/workflows/step/utilities/async_configure.py @@ -0,0 +1,23 @@ +from typing import List, Optional, Union + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.models.blocks import Block + + +class AsyncConfigure: + def __init__(self, *, callback_id: str, client: AsyncWebClient, body: dict): + self.callback_id = callback_id + self.client = client + self.body = body + + async def __call__( + self, *, blocks: Optional[List[Union[dict, Block]]] = None, + ) -> None: + await self.client.views_open( + trigger_id=self.body["trigger_id"], + view={ + "type": "workflow_step", + "callback_id": self.callback_id, + "blocks": blocks, + }, + ) diff --git a/slack_bolt/workflows/step/utilities/async_fail.py b/slack_bolt/workflows/step/utilities/async_fail.py new file mode 100644 index 000000000..f2c6b1de4 --- /dev/null +++ b/slack_bolt/workflows/step/utilities/async_fail.py @@ -0,0 +1,15 @@ +from slack_sdk.web.async_client import AsyncWebClient + + +class AsyncFail: + def __init__(self, *, client: AsyncWebClient, body: dict): + self.client = client + self.body = body + + async def __call__(self, *, error: dict,) -> None: + await self.client.workflows_stepFailed( + workflow_step_execute_id=self.body["event"]["workflow_step"][ + "workflow_step_execute_id" + ], + error=error, + ) diff --git a/slack_bolt/workflows/step/utilities/async_update.py b/slack_bolt/workflows/step/utilities/async_update.py new file mode 100644 index 000000000..d2824f235 --- /dev/null +++ b/slack_bolt/workflows/step/utilities/async_update.py @@ -0,0 +1,14 @@ +from slack_sdk.web.async_client import AsyncWebClient + + +class AsyncUpdate: + def __init__(self, *, client: AsyncWebClient, body: dict): + self.client = client + self.body = body + + async def __call__(self, *, inputs: dict, outputs: list,) -> None: + await self.client.workflows_updateStep( + workflow_step_edit_id=self.body["workflow_step"]["workflow_step_edit_id"], + inputs=inputs, + outputs=outputs, + ) diff --git a/slack_bolt/workflows/step/utilities/complete.py b/slack_bolt/workflows/step/utilities/complete.py new file mode 100644 index 000000000..839068422 --- /dev/null +++ b/slack_bolt/workflows/step/utilities/complete.py @@ -0,0 +1,15 @@ +from slack_sdk.web import WebClient + + +class Complete: + def __init__(self, *, client: WebClient, body: dict): + self.client = client + self.body = body + + def __call__(self, *, outputs: dict,) -> None: + self.client.workflows_stepCompleted( + workflow_step_execute_id=self.body["event"]["workflow_step"][ + "workflow_step_execute_id" + ], + outputs=outputs, + ) diff --git a/slack_bolt/workflows/step/utilities/configure.py b/slack_bolt/workflows/step/utilities/configure.py new file mode 100644 index 000000000..9bc27c8b6 --- /dev/null +++ b/slack_bolt/workflows/step/utilities/configure.py @@ -0,0 +1,21 @@ +from typing import List, Optional, Union + +from slack_sdk.web import WebClient +from slack_sdk.models.blocks import Block + + +class Configure: + def __init__(self, *, callback_id: str, client: WebClient, body: dict): + self.callback_id = callback_id + self.client = client + self.body = body + + def __call__(self, *, blocks: Optional[List[Union[dict, Block]]] = None,) -> None: + self.client.views_open( + trigger_id=self.body["trigger_id"], + view={ + "type": "workflow_step", + "callback_id": self.callback_id, + "blocks": blocks, + }, + ) diff --git a/slack_bolt/workflows/step/utilities/fail.py b/slack_bolt/workflows/step/utilities/fail.py new file mode 100644 index 000000000..6b82dc272 --- /dev/null +++ b/slack_bolt/workflows/step/utilities/fail.py @@ -0,0 +1,15 @@ +from slack_sdk.web import WebClient + + +class Fail: + def __init__(self, *, client: WebClient, body: dict): + self.client = client + self.body = body + + def __call__(self, *, error: dict,) -> None: + self.client.workflows_stepFailed( + workflow_step_execute_id=self.body["event"]["workflow_step"][ + "workflow_step_execute_id" + ], + error=error, + ) diff --git a/slack_bolt/workflows/step/utilities/update.py b/slack_bolt/workflows/step/utilities/update.py new file mode 100644 index 000000000..823529d32 --- /dev/null +++ b/slack_bolt/workflows/step/utilities/update.py @@ -0,0 +1,14 @@ +from slack_sdk.web import WebClient + + +class Update: + def __init__(self, *, client: WebClient, body: dict): + self.client = client + self.body = body + + def __call__(self, *, inputs: dict, outputs: list,) -> None: + self.client.workflows_updateStep( + workflow_step_edit_id=self.body["workflow_step"]["workflow_step_edit_id"], + inputs=inputs, + outputs=outputs, + ) From 95d7dd0cb74a57330458f8a52c9cd038ec437272 Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Tue, 29 Sep 2020 16:11:44 -0700 Subject: [PATCH 121/865] docs > update spacing + step instantiation example --- docs/_steps/adding_editing_workflow_step.md | 79 +++++++++++---------- docs/_steps/creating_workflow_step.md | 15 ++-- docs/_steps/executing_workflow_steps.md | 19 ++--- docs/_steps/saving_workflow_step.md | 47 ++++++------ 4 files changed, 81 insertions(+), 79 deletions(-) diff --git a/docs/_steps/adding_editing_workflow_step.md b/docs/_steps/adding_editing_workflow_step.md index b11b8d384..cae48baf6 100644 --- a/docs/_steps/adding_editing_workflow_step.md +++ b/docs/_steps/adding_editing_workflow_step.md @@ -18,51 +18,52 @@ To learn more about opening configuration modals, [read the documentation](https
    ```python -def edit_handler(ack, step, configure): - ack() +def edit(ack, step, configure): + ack() - blocks = [ - { - "type": "input", - "block_id": "task_name_input", - "element": { - "type": "plain_text_input", - "action_id": "name", - "placeholder": { - "type": "plain_text", - "text": "Add a task name", - }, + blocks = [ + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "name", + "placeholder": { + "type": "plain_text", + "text": "Add a task name", + }, + }, + "label": { + "type": "plain_text", + "text": "Task name", + }, }, - "label": { - "type": "plain_text", - "text": "Task name", + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "description", + "placeholder": { + "type": "plain_text", + "text": "Add a task description", + }, + }, + "label": { + "type": "plain_text", + "text": "Task description", + }, }, - }, - { - "type": "input", - "block_id": "task_description_input", - "element": { - "type": "plain_text_input", - "action_id": "description", - "placeholder": { - "type": "plain_text", - "text": "Add a task description", - }, - }, - "label": { - "type": "plain_text", - "text": "Task description", - }, - }, ] configure(blocks=blocks) - -ws = WorkflowStep(callback_id="add_task", config={ - "edit": edit_handler, - "save": save_handler, - "execute": execute_handler, -}) + +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) app.step(ws) ``` diff --git a/docs/_steps/creating_workflow_step.md b/docs/_steps/creating_workflow_step.md index 2e4c49e7e..85937022c 100644 --- a/docs/_steps/creating_workflow_step.md +++ b/docs/_steps/creating_workflow_step.md @@ -26,15 +26,14 @@ app = App( signing_secret=os.environ.get("SLACK_SIGNING_SECRET") ) -# Define the step's configuration -ws_config = { - "edit": edit_handler, - "save": save_handler, - "execute": execute_handler, -} - # Create a new WorkflowStep instance -ws = WorkflowStep(callback_id="add_task", config=ws_config) +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners app.step(ws) ``` diff --git a/docs/_steps/executing_workflow_steps.md b/docs/_steps/executing_workflow_steps.md index 9f8725afa..6e7161774 100644 --- a/docs/_steps/executing_workflow_steps.md +++ b/docs/_steps/executing_workflow_steps.md @@ -16,16 +16,16 @@ Within the `execute` callback, your app must either call `complete()` to indicat ```python -def execute_handler(step, complete, fail): +def execute(step, complete, fail): inputs = step["inputs"] outputs = { - "task_name": inputs["task_name"]["value"], - "task_description": inputs["task_description"]["value"], + "task_name": inputs["task_name"]["value"], + "task_description": inputs["task_description"]["value"], } error = { - "message": "Just testing step failure!" + "message": "Just testing step failure!" } # if everything was successful @@ -34,11 +34,12 @@ def execute_handler(step, complete, fail): # if something went wrong fail(error=error) -ws = WorkflowStep(callback_id="add_task", config={ - "edit": edit_handler, - "save": save_handler, - "execute": execute_handler, -}) +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) app.step(ws) ``` \ No newline at end of file diff --git a/docs/_steps/saving_workflow_step.md b/docs/_steps/saving_workflow_step.md index 8a0d95b35..cbb8ec712 100644 --- a/docs/_steps/saving_workflow_step.md +++ b/docs/_steps/saving_workflow_step.md @@ -21,42 +21,43 @@ To learn more about how to structure these parameters, [read the documentation]( ```python -def save_handler(ack, view, update): +def save(ack, view, update): ack() - values = view.state + values = view["state"]["values"] task_name = values["task_name_input"]["name"] task_description = values["task_description_input"]["description"] inputs = { - "task_name": { - "value": task_name.value - }, - "task_description": { - "value": task_description.value - }, + "task_name": { + "value": task_name.value + }, + "task_description": { + "value": task_description.value + }, } outputs = [ - { - "type": "text", - "name": "task_name", - "label": "Task name", - }, - { - "type": "text", - "name": "task_description", - "label": "Task description", - } + { + "type": "text", + "name": "task_name", + "label": "Task name", + }, + { + "type": "text", + "name": "task_description", + "label": "Task description", + } ] update(inputs=inputs, outputs=outputs); -ws = WorkflowStep(callback_id="add_task", config={ - "edit": edit_handler, - "save": save_handler, - "execute": execute_handler, -}) +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) app.step(ws) ``` \ No newline at end of file From e1a14b890e69bf74379366a8fe71a3286e986f6a Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Tue, 29 Sep 2020 16:14:29 -0700 Subject: [PATCH 122/865] remove save_callback_id, fix callback def typo + doc string grammar --- slack_bolt/app/app.py | 4 +--- slack_bolt/app/async_app.py | 4 +--- slack_bolt/workflows/step/async_step.py | 9 +++------ slack_bolt/workflows/step/step.py | 12 +++++------- 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index e9b129e1a..481473710 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -352,19 +352,17 @@ def middleware(self, *args) -> None: def step( self, callback_id: Union[str, Pattern, WorkflowStep], - save_callback_id: Optional[Union[str, Pattern]] = None, edit: Optional[Union[Callable[..., Optional[BoltResponse]], Listener]] = None, save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener]] = None, execute: Optional[ Union[Callable[..., Optional[BoltResponse]], Listener] ] = None, ): - """Registers a new Workflow Steps from Apps listeners.""" + """Registers a new Workflow Step listener""" step = callback_id if isinstance(callback_id, (str, Pattern)): step = WorkflowStep( callback_id=callback_id, - save_callback_id=save_callback_id, edit=edit, save=save, execute=execute, diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 4f72a5977..39a74adf3 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -364,7 +364,6 @@ def middleware(self, *args) -> None: def step( self, callback_id: Union[str, Pattern, AsyncWorkflowStep], - save_callback_id: Optional[Union[str, Pattern]] = None, edit: Optional[ Union[Callable[..., Optional[BoltResponse]], AsyncListener] ] = None, @@ -375,12 +374,11 @@ def step( Union[Callable[..., Optional[BoltResponse]], AsyncListener] ] = None, ): - """Registers a new Workflow Steps from Apps listeners.""" + """Registers a new Workflow Step listener""" step = callback_id if isinstance(callback_id, (str, Pattern)): step = AsyncWorkflowStep( callback_id=callback_id, - save_callback_id=save_callback_id, edit=edit, save=save, execute=execute, diff --git a/slack_bolt/workflows/step/async_step.py b/slack_bolt/workflows/step/async_step.py index 049cc257d..ef1491254 100644 --- a/slack_bolt/workflows/step/async_step.py +++ b/slack_bolt/workflows/step/async_step.py @@ -29,15 +29,12 @@ def __init__( edit: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], save: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], execute: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], - save_callback_id: Optional[str] = None, # TODO: better approach? app_name: Optional[str] = None, ): self.callback_id = callback_id app_name = app_name or __name__ self.edit = self._build_listener(callback_id, app_name, edit, "edit") - self.save = self._build_listener( - save_callback_id or callback_id, app_name, save, "save" - ) + self.save = self._build_listener(callback_id, app_name, save, "save") self.execute = self._build_listener(callback_id, app_name, execute, "execute") @classmethod @@ -125,7 +122,7 @@ async def save_listener_middleware( def _build_execute_listener_middleware(): - async def save_listener_middleware( + async def execute_listener_middleware( context: AsyncBoltContext, client: AsyncWebClient, body: dict, @@ -135,4 +132,4 @@ async def save_listener_middleware( context["fail"] = AsyncFail(client=client, body=body,) return await next() - return AsyncCustomMiddleware(app_name=__name__, func=save_listener_middleware) + return AsyncCustomMiddleware(app_name=__name__, func=execute_listener_middleware) diff --git a/slack_bolt/workflows/step/step.py b/slack_bolt/workflows/step/step.py index c9de64f5a..cdfb600a6 100644 --- a/slack_bolt/workflows/step/step.py +++ b/slack_bolt/workflows/step/step.py @@ -29,16 +29,14 @@ def __init__( edit: Union[Callable[..., Optional[BoltResponse]], Listener], save: Union[Callable[..., Optional[BoltResponse]], Listener], execute: Union[Callable[..., Optional[BoltResponse]], Listener], - save_callback_id: Optional[str] = None, # TODO: better approach? app_name: Optional[str] = None, ): self.callback_id = callback_id app_name = app_name or __name__ self.edit = self._build_listener(callback_id, app_name, edit, "edit") - self.save = self._build_listener( - save_callback_id or callback_id, app_name, save, "save" - ) - self.execute = self._build_listener(callback_id, app_name, execute, "execute") + self.save = self._build_listener(callback_id, app_name, save, "save") + self.execute = self._build_listener( + callback_id, app_name, execute, "execute") @classmethod def _build_listener(cls, callback_id, app_name, listener, name): @@ -123,7 +121,7 @@ def save_listener_middleware( def _build_execute_listener_middleware(): - def save_listener_middleware( + def execute_listener_middleware( context: BoltContext, client: WebClient, body: dict, @@ -133,4 +131,4 @@ def save_listener_middleware( context["fail"] = Fail(client=client, body=body,) return next() - return CustomMiddleware(app_name=__name__, func=save_listener_middleware) + return CustomMiddleware(app_name=__name__, func=execute_listener_middleware) From 15a107b78953eadd6fdc69d1629c14029e54eda4 Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Tue, 29 Sep 2020 17:58:51 -0700 Subject: [PATCH 123/865] adjust step utilities to allow for optional args --- slack_bolt/workflows/step/utilities/async_complete.py | 4 ++-- slack_bolt/workflows/step/utilities/async_update.py | 5 ++--- slack_bolt/workflows/step/utilities/complete.py | 4 ++-- slack_bolt/workflows/step/utilities/configure.py | 3 ++- slack_bolt/workflows/step/utilities/update.py | 5 ++--- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/slack_bolt/workflows/step/utilities/async_complete.py b/slack_bolt/workflows/step/utilities/async_complete.py index 1dc2302f6..6459c3f8a 100644 --- a/slack_bolt/workflows/step/utilities/async_complete.py +++ b/slack_bolt/workflows/step/utilities/async_complete.py @@ -6,10 +6,10 @@ def __init__(self, *, client: AsyncWebClient, body: dict): self.client = client self.body = body - async def __call__(self, *, outputs: dict,) -> None: + async def __call__(self, **kwargs) -> None: await self.client.workflows_stepCompleted( workflow_step_execute_id=self.body["event"]["workflow_step"][ "workflow_step_execute_id" ], - outputs=outputs, + **kwargs, ) diff --git a/slack_bolt/workflows/step/utilities/async_update.py b/slack_bolt/workflows/step/utilities/async_update.py index d2824f235..a8c9e6f9c 100644 --- a/slack_bolt/workflows/step/utilities/async_update.py +++ b/slack_bolt/workflows/step/utilities/async_update.py @@ -6,9 +6,8 @@ def __init__(self, *, client: AsyncWebClient, body: dict): self.client = client self.body = body - async def __call__(self, *, inputs: dict, outputs: list,) -> None: + async def __call__(self, **kwargs) -> None: await self.client.workflows_updateStep( workflow_step_edit_id=self.body["workflow_step"]["workflow_step_edit_id"], - inputs=inputs, - outputs=outputs, + **kwargs, ) diff --git a/slack_bolt/workflows/step/utilities/complete.py b/slack_bolt/workflows/step/utilities/complete.py index 839068422..4fce1c3d2 100644 --- a/slack_bolt/workflows/step/utilities/complete.py +++ b/slack_bolt/workflows/step/utilities/complete.py @@ -6,10 +6,10 @@ def __init__(self, *, client: WebClient, body: dict): self.client = client self.body = body - def __call__(self, *, outputs: dict,) -> None: + def __call__(self, **kwargs) -> None: self.client.workflows_stepCompleted( workflow_step_execute_id=self.body["event"]["workflow_step"][ "workflow_step_execute_id" ], - outputs=outputs, + **kwargs, ) diff --git a/slack_bolt/workflows/step/utilities/configure.py b/slack_bolt/workflows/step/utilities/configure.py index 9bc27c8b6..ff156e213 100644 --- a/slack_bolt/workflows/step/utilities/configure.py +++ b/slack_bolt/workflows/step/utilities/configure.py @@ -10,12 +10,13 @@ def __init__(self, *, callback_id: str, client: WebClient, body: dict): self.client = client self.body = body - def __call__(self, *, blocks: Optional[List[Union[dict, Block]]] = None,) -> None: + def __call__(self, *, blocks: Optional[List[Union[dict, Block]]] = None, **kwargs) -> None: self.client.views_open( trigger_id=self.body["trigger_id"], view={ "type": "workflow_step", "callback_id": self.callback_id, "blocks": blocks, + **kwargs, }, ) diff --git a/slack_bolt/workflows/step/utilities/update.py b/slack_bolt/workflows/step/utilities/update.py index 823529d32..1c602400e 100644 --- a/slack_bolt/workflows/step/utilities/update.py +++ b/slack_bolt/workflows/step/utilities/update.py @@ -6,9 +6,8 @@ def __init__(self, *, client: WebClient, body: dict): self.client = client self.body = body - def __call__(self, *, inputs: dict, outputs: list,) -> None: + def __call__(self, **kwargs) -> None: self.client.workflows_updateStep( workflow_step_edit_id=self.body["workflow_step"]["workflow_step_edit_id"], - inputs=inputs, - outputs=outputs, + **kwargs, ) From 1b58dabb6512ece89b4a6dd3b9204a9dfc2ccaa9 Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Tue, 29 Sep 2020 18:28:47 -0700 Subject: [PATCH 124/865] update docs > create step with correct WorkflowStep path --- docs/_steps/creating_workflow_step.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/_steps/creating_workflow_step.md b/docs/_steps/creating_workflow_step.md index 85937022c..31436c7e2 100644 --- a/docs/_steps/creating_workflow_step.md +++ b/docs/_steps/creating_workflow_step.md @@ -18,7 +18,8 @@ After instantiating a `WorkflowStep`, you can pass it into `app.step()`. Behind ```python -from slack_bolt import App, WorkflowStep +from slack_bolt import App +from slack_bolt.workflows.step import WorkflowStep # Initiate the Bolt app as you normally would app = App( From a816027442a479fec1d1b9f40491e0a7d1407ef3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 1 Oct 2020 17:28:39 +0900 Subject: [PATCH 125/865] Update Workflow Steps documents --- docs/_steps/adding_editing_workflow_step.md | 22 ++++----------------- docs/_steps/creating_workflow_step.md | 11 ++++++++++- docs/_steps/executing_workflow_steps.md | 10 +++------- docs/_steps/saving_workflow_step.md | 13 +++--------- 4 files changed, 20 insertions(+), 36 deletions(-) diff --git a/docs/_steps/adding_editing_workflow_step.md b/docs/_steps/adding_editing_workflow_step.md index cae48baf6..55e040d62 100644 --- a/docs/_steps/adding_editing_workflow_step.md +++ b/docs/_steps/adding_editing_workflow_step.md @@ -28,15 +28,9 @@ def edit(ack, step, configure): "element": { "type": "plain_text_input", "action_id": "name", - "placeholder": { - "type": "plain_text", - "text": "Add a task name", - }, - }, - "label": { - "type": "plain_text", - "text": "Task name", + "placeholder": {"type": "plain_text", "text": "Add a task name"}, }, + "label": {"type": "plain_text", "text": "Task name"}, }, { "type": "input", @@ -44,18 +38,11 @@ def edit(ack, step, configure): "element": { "type": "plain_text_input", "action_id": "description", - "placeholder": { - "type": "plain_text", - "text": "Add a task description", - }, - }, - "label": { - "type": "plain_text", - "text": "Task description", + "placeholder": {"type": "plain_text", "text": "Add a task description"}, }, + "label": {"type": "plain_text", "text": "Task description"}, }, ] - configure(blocks=blocks) ws = WorkflowStep( @@ -64,6 +51,5 @@ ws = WorkflowStep( save=save, execute=execute, ) - app.step(ws) ``` diff --git a/docs/_steps/creating_workflow_step.md b/docs/_steps/creating_workflow_step.md index 31436c7e2..5d3db5000 100644 --- a/docs/_steps/creating_workflow_step.md +++ b/docs/_steps/creating_workflow_step.md @@ -18,6 +18,7 @@ After instantiating a `WorkflowStep`, you can pass it into `app.step()`. Behind ```python +import os from slack_bolt import App from slack_bolt.workflows.step import WorkflowStep @@ -27,6 +28,15 @@ app = App( signing_secret=os.environ.get("SLACK_SIGNING_SECRET") ) +def edit(ack, step, configure): + pass + +def save(ack, view, update): + pass + +def execute(step, complete, fail): + pass + # Create a new WorkflowStep instance ws = WorkflowStep( callback_id="add_task", @@ -34,7 +44,6 @@ ws = WorkflowStep( save=save, execute=execute, ) - # Pass Step to set up listeners app.step(ws) ``` diff --git a/docs/_steps/executing_workflow_steps.md b/docs/_steps/executing_workflow_steps.md index 6e7161774..e7a489e34 100644 --- a/docs/_steps/executing_workflow_steps.md +++ b/docs/_steps/executing_workflow_steps.md @@ -18,20 +18,17 @@ Within the `execute` callback, your app must either call `complete()` to indicat ```python def execute(step, complete, fail): inputs = step["inputs"] - + # if everything was successful outputs = { "task_name": inputs["task_name"]["value"], "task_description": inputs["task_description"]["value"], } + complete(outputs=outputs) + # if something went wrong error = { "message": "Just testing step failure!" } - - # if everything was successful - complete(outputs=outputs) - - # if something went wrong fail(error=error) ws = WorkflowStep( @@ -40,6 +37,5 @@ ws = WorkflowStep( save=save, execute=execute, ) - app.step(ws) ``` \ No newline at end of file diff --git a/docs/_steps/saving_workflow_step.md b/docs/_steps/saving_workflow_step.md index cbb8ec712..55d10ecfc 100644 --- a/docs/_steps/saving_workflow_step.md +++ b/docs/_steps/saving_workflow_step.md @@ -29,14 +29,9 @@ def save(ack, view, update): task_description = values["task_description_input"]["description"] inputs = { - "task_name": { - "value": task_name.value - }, - "task_description": { - "value": task_description.value - }, + "task_name": {"value": task_name.value}, + "task_description": {"value": task_description.value} } - outputs = [ { "type": "text", @@ -49,8 +44,7 @@ def save(ack, view, update): "label": "Task description", } ] - - update(inputs=inputs, outputs=outputs); + update(inputs=inputs, outputs=outputs) ws = WorkflowStep( callback_id="add_task", @@ -58,6 +52,5 @@ ws = WorkflowStep( save=save, execute=execute, ) - app.step(ws) ``` \ No newline at end of file From 30a42c7f60f415b1ed9812cf1d3959c6c632100e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 1 Oct 2020 18:08:10 +0900 Subject: [PATCH 126/865] Add step arg and fix bugs --- samples/async_steps_from_apps.py | 9 +- samples/steps_from_apps.py | 19 +- slack_bolt/app/app.py | 5 +- slack_bolt/app/async_app.py | 5 +- slack_bolt/kwargs_injection/async_utils.py | 3 + slack_bolt/kwargs_injection/utils.py | 3 + .../listener/async_listener_error_handler.py | 3 + slack_bolt/listener/listener_error_handler.py | 3 + slack_bolt/listener_matcher/builtins.py | 3 +- slack_bolt/util/payload_utils.py | 23 + slack_bolt/workflows/step/async_step.py | 2 +- slack_bolt/workflows/step/step.py | 5 +- .../workflows/step/utilities/configure.py | 4 +- tests/scenario_tests/test_workflow_steps.py | 395 +++++++++++++++++ .../test_workflow_steps.py | 417 ++++++++++++++++++ 15 files changed, 870 insertions(+), 29 deletions(-) create mode 100644 tests/scenario_tests/test_workflow_steps.py create mode 100644 tests/scenario_tests_async/test_workflow_steps.py diff --git a/samples/async_steps_from_apps.py b/samples/async_steps_from_apps.py index 90f1558bf..e38e352bb 100644 --- a/samples/async_steps_from_apps.py +++ b/samples/async_steps_from_apps.py @@ -21,7 +21,7 @@ # https://api.slack.com/tutorials/workflow-builder-steps -async def edit(ack: AsyncAck, configure: AsyncConfigure): +async def edit(ack: AsyncAck, step: dict, configure: AsyncConfigure): await ack() await configure( blocks=[ @@ -76,8 +76,8 @@ async def edit(ack: AsyncAck, configure: AsyncConfigure): ) -async def save(ack: AsyncAck, body: dict, update: AsyncUpdate): - state_values = body["view"]["state"]["values"] +async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): + state_values = view["state"]["values"] await update( inputs={ "taskName": { @@ -112,9 +112,8 @@ async def save(ack: AsyncAck, body: dict, update: AsyncUpdate): pseudo_database = {} -async def execute(body: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail): +async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail): try: - step = body["event"]["workflow_step"] await complete( outputs={ "taskName": step["inputs"]["taskName"]["value"], diff --git a/samples/steps_from_apps.py b/samples/steps_from_apps.py index 9419c4831..ab3921062 100644 --- a/samples/steps_from_apps.py +++ b/samples/steps_from_apps.py @@ -2,7 +2,6 @@ # instead of slack_bolt in requirements.txt import sys - sys.path.insert(1, "..") # ------------------------------------------------ @@ -30,7 +29,7 @@ def log_request(logger, body, next): # https://api.slack.com/tutorials/workflow-builder-steps -def edit(ack: Ack, configure: Configure): +def edit(ack: Ack, step, configure: Configure): ack() configure( blocks=[ @@ -85,8 +84,8 @@ def edit(ack: Ack, configure: Configure): ) -def save(ack: Ack, body: dict, update: Update): - state_values = body["view"]["state"]["values"] +def save(ack: Ack, view: dict, update: Update): + state_values = view["state"]["values"] update( inputs={ "taskName": { @@ -121,15 +120,14 @@ def save(ack: Ack, body: dict, update: Update): pseudo_database = {} -def execute(body: dict, client: WebClient, complete: Complete, fail: Fail): - step = body["event"]["workflow_step"] +def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): try: complete( outputs={ - "taskName": step["inputs"]["taskName"]["value"], - "taskDescription": step["inputs"]["taskDescription"]["value"], - "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], - } + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } ) user: SlackResponse = client.users_lookupByEmail( @@ -167,6 +165,7 @@ def execute(body: dict, client: WebClient, complete: Complete, fail: Fail): "message": "Something wrong!" }) + app.step( callback_id="copy_review", edit=edit, diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 481473710..0e1b2ed68 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -362,10 +362,7 @@ def step( step = callback_id if isinstance(callback_id, (str, Pattern)): step = WorkflowStep( - callback_id=callback_id, - edit=edit, - save=save, - execute=execute, + callback_id=callback_id, edit=edit, save=save, execute=execute, ) elif not isinstance(step, WorkflowStep): raise BoltError("Invalid step object") diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 39a74adf3..da9dd6fc6 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -378,10 +378,7 @@ def step( step = callback_id if isinstance(callback_id, (str, Pattern)): step = AsyncWorkflowStep( - callback_id=callback_id, - edit=edit, - save=save, - execute=execute, + callback_id=callback_id, edit=edit, save=save, execute=execute, ) elif not isinstance(step, AsyncWorkflowStep): raise BoltError("Invalid step object") diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index 7dd379648..58704e2bc 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -13,6 +13,7 @@ to_command, to_event, to_message, + to_step, ) @@ -41,6 +42,7 @@ def build_async_required_kwargs( "command": to_command(request.body), "event": to_event(request.body), "message": to_message(request.body), + "step": to_step(request.body), # utilities "ack": request.context.ack, "say": request.context.say, @@ -56,6 +58,7 @@ def build_async_required_kwargs( or all_available_args["command"] or all_available_args["event"] or all_available_args["message"] + or all_available_args["step"] or request.body ) for k, v in request.context.items(): diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index b28a09a14..5519b2a5e 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -13,6 +13,7 @@ to_command, to_event, to_message, + to_step, ) @@ -41,6 +42,7 @@ def build_required_kwargs( "command": to_command(request.body), "event": to_event(request.body), "message": to_message(request.body), + "step": to_step(request.body), # utilities "ack": request.context.ack, "say": request.context.say, @@ -56,6 +58,7 @@ def build_required_kwargs( or all_available_args["command"] or all_available_args["event"] or all_available_args["message"] + or all_available_args["step"] or request.body ) for k, v in request.context.items(): diff --git a/slack_bolt/listener/async_listener_error_handler.py b/slack_bolt/listener/async_listener_error_handler.py index 15a3efaa5..86e311098 100644 --- a/slack_bolt/listener/async_listener_error_handler.py +++ b/slack_bolt/listener/async_listener_error_handler.py @@ -14,6 +14,7 @@ to_command, to_event, to_message, + to_step, ) @@ -66,6 +67,7 @@ async def handle( "command": to_command(request.body), "event": to_event(request.body), "message": to_message(request.body), + "step": to_step(request.body), # utilities "say": request.context.say, "respond": request.context.respond, @@ -78,6 +80,7 @@ async def handle( or all_available_args["command"] or all_available_args["event"] or all_available_args["message"] + or all_available_args["step"] or request.body ) diff --git a/slack_bolt/listener/listener_error_handler.py b/slack_bolt/listener/listener_error_handler.py index 7c3fb5c7f..7526920b0 100644 --- a/slack_bolt/listener/listener_error_handler.py +++ b/slack_bolt/listener/listener_error_handler.py @@ -14,6 +14,7 @@ to_command, to_event, to_message, + to_step, ) @@ -60,6 +61,7 @@ def handle( "command": to_command(request.body), "event": to_event(request.body), "message": to_message(request.body), + "step": to_step(request.body), # utilities "say": request.context.say, "respond": request.context.respond, @@ -72,6 +74,7 @@ def handle( or all_available_args["command"] or all_available_args["event"] or all_available_args["message"] + or all_available_args["step"] or request.body ) diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index b8f511a3b..6c0b2077a 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -110,13 +110,14 @@ def func(body: Dict[str, Any]) -> bool: def workflow_step_execute( - asyncio: bool = False, + callback_id: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return ( is_event(body) and _matches("workflow_step_execute", body["event"]["type"]) and "workflow_step" in body["event"] + and _matches(callback_id, body["event"]["callback_id"]) ) return build_listener_matcher(func, asyncio) diff --git a/slack_bolt/util/payload_utils.py b/slack_bolt/util/payload_utils.py index a1b4da136..529628b24 100644 --- a/slack_bolt/util/payload_utils.py +++ b/slack_bolt/util/payload_utils.py @@ -29,6 +29,14 @@ def is_event(body: Dict[str, Any]) -> bool: ) +def is_workflow_step_execute(body: Dict[str, Any]) -> bool: + return ( + is_event(body) + and body["event"]["type"] == "workflow_step_edit" + and "workflow_step" in body["event"] + ) + + # ------------------- # Slash Commands # ------------------- @@ -205,6 +213,21 @@ def is_workflow_step_save(body: Dict[str, Any]) -> bool: return is_view_submission(body) and body["view"]["type"] == "workflow_step" +# ------------------- +# Workflow Steps +# ------------------- + + +def to_step(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + # edit + if is_workflow_step_edit(body): + return body["workflow_step"] + # execute + if is_workflow_step_execute(body): + return body["event"]["workflow_step"] + return None + + # ------------------------------------------ # Internal Utilities # ------------------------------------------ diff --git a/slack_bolt/workflows/step/async_step.py b/slack_bolt/workflows/step/async_step.py index ef1491254..334d98821 100644 --- a/slack_bolt/workflows/step/async_step.py +++ b/slack_bolt/workflows/step/async_step.py @@ -62,7 +62,7 @@ def _build_matchers(cls, name: str, callback_id: str): elif name == "save": return [workflow_step_save(callback_id, asyncio=True)] elif name == "execute": - return [workflow_step_execute(asyncio=True)] + return [workflow_step_execute(callback_id, asyncio=True)] else: raise ValueError(f"Invalid name {name}") diff --git a/slack_bolt/workflows/step/step.py b/slack_bolt/workflows/step/step.py index cdfb600a6..3d83569ca 100644 --- a/slack_bolt/workflows/step/step.py +++ b/slack_bolt/workflows/step/step.py @@ -35,8 +35,7 @@ def __init__( app_name = app_name or __name__ self.edit = self._build_listener(callback_id, app_name, edit, "edit") self.save = self._build_listener(callback_id, app_name, save, "save") - self.execute = self._build_listener( - callback_id, app_name, execute, "execute") + self.execute = self._build_listener(callback_id, app_name, execute, "execute") @classmethod def _build_listener(cls, callback_id, app_name, listener, name): @@ -61,7 +60,7 @@ def _build_matchers(cls, name, callback_id): elif name == "save": return [workflow_step_save(callback_id)] elif name == "execute": - return [workflow_step_execute()] + return [workflow_step_execute(callback_id)] else: raise ValueError(f"Invalid name {name}") diff --git a/slack_bolt/workflows/step/utilities/configure.py b/slack_bolt/workflows/step/utilities/configure.py index ff156e213..8f1d29a90 100644 --- a/slack_bolt/workflows/step/utilities/configure.py +++ b/slack_bolt/workflows/step/utilities/configure.py @@ -10,7 +10,9 @@ def __init__(self, *, callback_id: str, client: WebClient, body: dict): self.client = client self.body = body - def __call__(self, *, blocks: Optional[List[Union[dict, Block]]] = None, **kwargs) -> None: + def __call__( + self, *, blocks: Optional[List[Union[dict, Block]]] = None, **kwargs + ) -> None: self.client.views_open( trigger_id=self.body["trigger_id"], view={ diff --git a/tests/scenario_tests/test_workflow_steps.py b/tests/scenario_tests/test_workflow_steps.py new file mode 100644 index 000000000..9b22c3901 --- /dev/null +++ b/tests/scenario_tests/test_workflow_steps.py @@ -0,0 +1,395 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient, SlackResponse + +from slack_bolt import App, BoltRequest, Ack +from slack_bolt.workflows.step import Complete, Fail, Update, Configure +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 TestWorkflowSteps: + 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) + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(callback_id="copy_review", edit=edit, save=save, execute=execute) + + 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 test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +def edit(ack: Ack, step, configure: Configure): + assert step is not None + ack() + configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +def save(ack: Ack, view: dict, update: Update): + state_values = view["state"]["values"] + update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + {"name": "taskName", "type": "text", "label": "Task Name",}, + {"name": "taskDescription", "type": "text", "label": "Task Description",}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + ], + ) + ack() + + +pseudo_database = {} + + +def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): + assert step is not None + try: + complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) diff --git a/tests/scenario_tests_async/test_workflow_steps.py b/tests/scenario_tests_async/test_workflow_steps.py new file mode 100644 index 000000000..e49fc3b99 --- /dev/null +++ b/tests/scenario_tests_async/test_workflow_steps.py @@ -0,0 +1,417 @@ +import asyncio +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import SlackResponse +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.workflows.step.utilities.async_complete import AsyncComplete +from slack_bolt.workflows.step.utilities.async_configure import AsyncConfigure +from slack_bolt.workflows.step.utilities.async_fail import AsyncFail +from slack_bolt.workflows.step.utilities.async_update import AsyncUpdate +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 TestAsyncEvents: + 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,) + + @pytest.fixture + def event_loop(self): + old_os_env = remove_os_env_temporarily() + try: + setup_mock_web_api_server(self) + self.app = AsyncApp( + client=self.web_client, signing_secret=self.signing_secret + ) + self.app.step( + callback_id="copy_review", edit=edit, save=save, execute=execute + ) + + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + finally: + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, timestamp=timestamp, + ) + + @pytest.mark.asyncio + async def test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +async def edit(ack: AsyncAck, step, configure: AsyncConfigure): + assert step is not None + await ack() + await configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): + state_values = view["state"]["values"] + await update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + {"name": "taskName", "type": "text", "label": "Task Name",}, + {"name": "taskDescription", "type": "text", "label": "Task Description",}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + ], + ) + await ack() + + +pseudo_database = {} + + +async def execute( + step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail +): + assert step is not None + try: + await complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = await client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) From 6036b94af6253a8a62b1d86a257eeb3c672ff3f5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 1 Oct 2020 18:15:36 +0900 Subject: [PATCH 127/865] version 0.9.0b0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index b57a6795b..a31c42706 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.6.4a0" +__version__ = "0.9.0b0" From 3dc09f46b5bc148fdfe4d2a56089916520c1a019 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 1 Oct 2020 18:19:53 +0900 Subject: [PATCH 128/865] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f53bdb6c7..3babe6b6f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Bolt for Python (still in beta) +# Bolt for Python [![Python Version][python-version]][pypi-url] [![pypi package][pypi-image]][pypi-url] From 37b0b0cea727883e56ccef10012441b79f742052 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 1 Oct 2020 18:31:45 +0900 Subject: [PATCH 129/865] Fix the sidebar to work anyways --- docs/_includes/sidebar.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html index e51aedcff..ecadae7b0 100644 --- a/docs/_includes/sidebar.html +++ b/docs/_includes/sidebar.html @@ -13,8 +13,9 @@ {% assign localized_base_url = page.lang | prepend: "/" | remove_first: "/en" %} - {% assign release = site.github.releases | sort: "created_at" | reverse | pop %} - {{ release[0].tag_name }} + + {% assign release = site.github.releases[0] %} + {{ release.tag_name }} From 42bb3af4ce84e126a3ac1a8e51680fc5ae65dbcb Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Fri, 2 Oct 2020 14:22:08 -0700 Subject: [PATCH 130/865] docs > updating modals - fix error in sample code --- docs/_basic/opening_modals.md | 1 - docs/_basic/updating_pushing_modals.md | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/_basic/opening_modals.md b/docs/_basic/opening_modals.md index 29a2fcba5..9c71763e7 100644 --- a/docs/_basic/opening_modals.md +++ b/docs/_basic/opening_modals.md @@ -21,7 +21,6 @@ Read more about modal composition in the Date: Fri, 2 Oct 2020 11:46:21 +0900 Subject: [PATCH 131/865] Rename samples to examples --- docs/_advanced/adapters.md | 2 +- docs/_advanced/async.md | 2 +- docs/_tutorials/getting_started.md | 4 ++-- {samples => examples}/app.py | 0 {samples => examples}/app_authorize.py | 0 {samples => examples}/async_app.py | 0 {samples => examples}/async_app_authorize.py | 0 {samples => examples}/async_steps_from_apps.py | 0 {samples => examples}/async_steps_from_apps_primitive.py | 0 {samples => examples}/aws_chalice/.chalice/config.json.oauth | 0 {samples => examples}/aws_chalice/.chalice/config.json.simple | 0 {samples => examples}/aws_chalice/.gitignore | 0 {samples => examples}/aws_chalice/app.py | 0 {samples => examples}/aws_chalice/deploy.sh | 0 {samples => examples}/aws_chalice/oauth_app.py | 0 {samples => examples}/aws_chalice/requirements.txt | 0 {samples => examples}/aws_chalice/simple_app.py | 0 {samples => examples}/aws_lambda/.env.oauth_sample | 0 {samples => examples}/aws_lambda/.env.sample | 0 {samples => examples}/aws_lambda/.gitignore | 0 {samples => examples}/aws_lambda/aws_lambda.py | 0 {samples => examples}/aws_lambda/aws_lambda_config.yaml | 0 {samples => examples}/aws_lambda/aws_lambda_oauth.py | 0 {samples => examples}/aws_lambda/aws_lambda_oauth_config.yaml | 0 {samples => examples}/aws_lambda/deploy.sh | 0 {samples => examples}/aws_lambda/deploy_lazy.sh | 0 {samples => examples}/aws_lambda/deploy_oauth.sh | 0 {samples => examples}/aws_lambda/lazy_aws_lambda.py | 0 {samples => examples}/aws_lambda/lazy_aws_lambda_config.yaml | 0 {samples => examples}/aws_lambda/requirements.txt | 0 {samples => examples}/aws_lambda/requirements_oauth.txt | 0 {samples => examples}/bottle/app.py | 0 {samples => examples}/bottle/oauth_app.py | 0 {samples => examples}/bottle/requirements.txt | 0 {samples => examples}/cherrypy/app.py | 0 {samples => examples}/cherrypy/oauth_app.py | 0 {samples => examples}/cherrypy/requirements.txt | 0 {samples => examples}/dialogs_app.py | 0 {samples => examples}/django/.gitignore | 0 {samples => examples}/django/README.md | 0 {samples => examples}/django/manage.py | 0 {samples => examples}/django/requirements.txt | 0 {samples => examples}/django/slackapp/__init__.py | 0 {samples => examples}/django/slackapp/apps.py | 0 .../django/slackapp/migrations/0001_initial.py | 0 {samples => examples}/django/slackapp/migrations/__init__.py | 0 {samples => examples}/django/slackapp/models.py | 0 {samples => examples}/django/slackapp/settings.py | 0 {samples => examples}/django/slackapp/urls.py | 0 {samples => examples}/django/slackapp/views.py | 0 {samples => examples}/django/slackapp/wsgi.py | 0 {samples => examples}/docker/aiohttp/Dockerfile | 0 {samples => examples}/docker/aiohttp/main.py | 0 {samples => examples}/docker/aiohttp/requirements.txt | 0 {samples => examples}/docker/fastapi-gunicorn/Dockerfile | 0 {samples => examples}/docker/fastapi-gunicorn/main.py | 0 .../docker/fastapi-gunicorn/requirements.txt | 0 {samples => examples}/docker/flask-gunicorn/Dockerfile | 0 {samples => examples}/docker/flask-gunicorn/main.py | 0 {samples => examples}/docker/flask-gunicorn/requirements.txt | 0 {samples => examples}/docker/flask-uwsgi/Dockerfile | 0 {samples => examples}/docker/flask-uwsgi/main.py | 0 {samples => examples}/docker/flask-uwsgi/requirements.txt | 0 {samples => examples}/docker/flask-uwsgi/uwsgi.ini | 0 {samples => examples}/docker/sanic/Dockerfile | 0 {samples => examples}/docker/sanic/main.py | 0 {samples => examples}/docker/sanic/requirements.txt | 0 {samples => examples}/events_app.py | 0 {samples => examples}/falcon/app.py | 0 {samples => examples}/falcon/oauth_app.py | 0 {samples => examples}/falcon/requirements.txt | 0 {samples => examples}/fastapi/app.py | 0 {samples => examples}/fastapi/async_app.py | 0 {samples => examples}/fastapi/async_oauth_app.py | 0 {samples => examples}/fastapi/oauth_app.py | 0 {samples => examples}/fastapi/requirements.txt | 0 {samples => examples}/flask/app.py | 0 {samples => examples}/flask/oauth_app.py | 0 {samples => examples}/flask/requirements.txt | 0 {samples => examples}/getting_started/.gitignore | 0 {samples => examples}/getting_started/README.md | 0 {samples => examples}/getting_started/app.py | 0 {samples => examples}/getting_started/requirements.txt | 0 {samples => examples}/google_app_engine/flask/.gcloudignore | 0 {samples => examples}/google_app_engine/flask/.gitignore | 0 {samples => examples}/google_app_engine/flask/app.yaml | 0 .../google_app_engine/flask/env_variables.yaml.sample | 0 {samples => examples}/google_app_engine/flask/main.py | 0 .../google_app_engine/flask/requirements.txt | 0 {samples => examples}/google_cloud_functions/.env.yaml.sample | 0 {samples => examples}/google_cloud_functions/.gcloudignore | 0 {samples => examples}/google_cloud_functions/.gitignore | 0 {samples => examples}/google_cloud_functions/main.py | 0 {samples => examples}/google_cloud_functions/requirements.txt | 0 {samples => examples}/google_cloud_run/aiohttp/Dockerfile | 0 {samples => examples}/google_cloud_run/aiohttp/main.py | 0 .../google_cloud_run/aiohttp/requirements.txt | 0 .../google_cloud_run/flask-gunicorn/Dockerfile | 0 {samples => examples}/google_cloud_run/flask-gunicorn/main.py | 0 .../google_cloud_run/flask-gunicorn/requirements.txt | 0 {samples => examples}/google_cloud_run/sanic/Dockerfile | 0 {samples => examples}/google_cloud_run/sanic/main.py | 0 {samples => examples}/google_cloud_run/sanic/requirements.txt | 0 {samples => examples}/heroku/Procfile | 0 {samples => examples}/heroku/main.py | 0 {samples => examples}/heroku/requirements.txt | 0 {samples => examples}/lazy_async_modals_app.py | 0 {samples => examples}/lazy_modals_app.py | 0 {samples => examples}/message_events.py | 0 {samples => examples}/modals_app.py | 0 {samples => examples}/modals_app_typed.py | 0 {samples => examples}/oauth_app.py | 0 {samples => examples}/oauth_app_settings.py | 0 {samples => examples}/oauth_sqlite3_app.py | 0 {samples => examples}/pyramid/app.py | 0 {samples => examples}/pyramid/oauth_app.py | 0 {samples => examples}/pyramid/requirements.txt | 0 {samples => examples}/readme_app.py | 0 {samples => examples}/readme_async_app.py | 0 {samples => examples}/sanic/async_app.py | 0 {samples => examples}/sanic/async_oauth_app.py | 0 {samples => examples}/sanic/requirements.txt | 0 {samples => examples}/sqlalchemy/async_oauth_app.py | 0 {samples => examples}/sqlalchemy/oauth_app.py | 0 {samples => examples}/sqlalchemy/requirements.txt | 0 {samples => examples}/sqlalchemy/requirements_async.txt | 0 {samples => examples}/starlette/app.py | 0 {samples => examples}/starlette/async_app.py | 0 {samples => examples}/starlette/async_oauth_app.py | 0 {samples => examples}/starlette/oauth_app.py | 0 {samples => examples}/starlette/requirements.txt | 0 {samples => examples}/steps_from_apps.py | 0 {samples => examples}/steps_from_apps_primitive.py | 0 {samples => examples}/tornado/app.py | 0 {samples => examples}/tornado/oauth_app.py | 0 {samples => examples}/tornado/requirements.txt | 0 136 files changed, 4 insertions(+), 4 deletions(-) rename {samples => examples}/app.py (100%) rename {samples => examples}/app_authorize.py (100%) rename {samples => examples}/async_app.py (100%) rename {samples => examples}/async_app_authorize.py (100%) rename {samples => examples}/async_steps_from_apps.py (100%) rename {samples => examples}/async_steps_from_apps_primitive.py (100%) rename {samples => examples}/aws_chalice/.chalice/config.json.oauth (100%) rename {samples => examples}/aws_chalice/.chalice/config.json.simple (100%) rename {samples => examples}/aws_chalice/.gitignore (100%) rename {samples => examples}/aws_chalice/app.py (100%) rename {samples => examples}/aws_chalice/deploy.sh (100%) rename {samples => examples}/aws_chalice/oauth_app.py (100%) rename {samples => examples}/aws_chalice/requirements.txt (100%) rename {samples => examples}/aws_chalice/simple_app.py (100%) rename {samples => examples}/aws_lambda/.env.oauth_sample (100%) rename {samples => examples}/aws_lambda/.env.sample (100%) rename {samples => examples}/aws_lambda/.gitignore (100%) rename {samples => examples}/aws_lambda/aws_lambda.py (100%) rename {samples => examples}/aws_lambda/aws_lambda_config.yaml (100%) rename {samples => examples}/aws_lambda/aws_lambda_oauth.py (100%) rename {samples => examples}/aws_lambda/aws_lambda_oauth_config.yaml (100%) rename {samples => examples}/aws_lambda/deploy.sh (100%) rename {samples => examples}/aws_lambda/deploy_lazy.sh (100%) rename {samples => examples}/aws_lambda/deploy_oauth.sh (100%) rename {samples => examples}/aws_lambda/lazy_aws_lambda.py (100%) rename {samples => examples}/aws_lambda/lazy_aws_lambda_config.yaml (100%) rename {samples => examples}/aws_lambda/requirements.txt (100%) rename {samples => examples}/aws_lambda/requirements_oauth.txt (100%) rename {samples => examples}/bottle/app.py (100%) rename {samples => examples}/bottle/oauth_app.py (100%) rename {samples => examples}/bottle/requirements.txt (100%) rename {samples => examples}/cherrypy/app.py (100%) rename {samples => examples}/cherrypy/oauth_app.py (100%) rename {samples => examples}/cherrypy/requirements.txt (100%) rename {samples => examples}/dialogs_app.py (100%) rename {samples => examples}/django/.gitignore (100%) rename {samples => examples}/django/README.md (100%) rename {samples => examples}/django/manage.py (100%) rename {samples => examples}/django/requirements.txt (100%) rename {samples => examples}/django/slackapp/__init__.py (100%) rename {samples => examples}/django/slackapp/apps.py (100%) rename {samples => examples}/django/slackapp/migrations/0001_initial.py (100%) rename {samples => examples}/django/slackapp/migrations/__init__.py (100%) rename {samples => examples}/django/slackapp/models.py (100%) rename {samples => examples}/django/slackapp/settings.py (100%) rename {samples => examples}/django/slackapp/urls.py (100%) rename {samples => examples}/django/slackapp/views.py (100%) rename {samples => examples}/django/slackapp/wsgi.py (100%) rename {samples => examples}/docker/aiohttp/Dockerfile (100%) rename {samples => examples}/docker/aiohttp/main.py (100%) rename {samples => examples}/docker/aiohttp/requirements.txt (100%) rename {samples => examples}/docker/fastapi-gunicorn/Dockerfile (100%) rename {samples => examples}/docker/fastapi-gunicorn/main.py (100%) rename {samples => examples}/docker/fastapi-gunicorn/requirements.txt (100%) rename {samples => examples}/docker/flask-gunicorn/Dockerfile (100%) rename {samples => examples}/docker/flask-gunicorn/main.py (100%) rename {samples => examples}/docker/flask-gunicorn/requirements.txt (100%) rename {samples => examples}/docker/flask-uwsgi/Dockerfile (100%) rename {samples => examples}/docker/flask-uwsgi/main.py (100%) rename {samples => examples}/docker/flask-uwsgi/requirements.txt (100%) rename {samples => examples}/docker/flask-uwsgi/uwsgi.ini (100%) rename {samples => examples}/docker/sanic/Dockerfile (100%) rename {samples => examples}/docker/sanic/main.py (100%) rename {samples => examples}/docker/sanic/requirements.txt (100%) rename {samples => examples}/events_app.py (100%) rename {samples => examples}/falcon/app.py (100%) rename {samples => examples}/falcon/oauth_app.py (100%) rename {samples => examples}/falcon/requirements.txt (100%) rename {samples => examples}/fastapi/app.py (100%) rename {samples => examples}/fastapi/async_app.py (100%) rename {samples => examples}/fastapi/async_oauth_app.py (100%) rename {samples => examples}/fastapi/oauth_app.py (100%) rename {samples => examples}/fastapi/requirements.txt (100%) rename {samples => examples}/flask/app.py (100%) rename {samples => examples}/flask/oauth_app.py (100%) rename {samples => examples}/flask/requirements.txt (100%) rename {samples => examples}/getting_started/.gitignore (100%) rename {samples => examples}/getting_started/README.md (100%) rename {samples => examples}/getting_started/app.py (100%) rename {samples => examples}/getting_started/requirements.txt (100%) rename {samples => examples}/google_app_engine/flask/.gcloudignore (100%) rename {samples => examples}/google_app_engine/flask/.gitignore (100%) rename {samples => examples}/google_app_engine/flask/app.yaml (100%) rename {samples => examples}/google_app_engine/flask/env_variables.yaml.sample (100%) rename {samples => examples}/google_app_engine/flask/main.py (100%) rename {samples => examples}/google_app_engine/flask/requirements.txt (100%) rename {samples => examples}/google_cloud_functions/.env.yaml.sample (100%) rename {samples => examples}/google_cloud_functions/.gcloudignore (100%) rename {samples => examples}/google_cloud_functions/.gitignore (100%) rename {samples => examples}/google_cloud_functions/main.py (100%) rename {samples => examples}/google_cloud_functions/requirements.txt (100%) rename {samples => examples}/google_cloud_run/aiohttp/Dockerfile (100%) rename {samples => examples}/google_cloud_run/aiohttp/main.py (100%) rename {samples => examples}/google_cloud_run/aiohttp/requirements.txt (100%) rename {samples => examples}/google_cloud_run/flask-gunicorn/Dockerfile (100%) rename {samples => examples}/google_cloud_run/flask-gunicorn/main.py (100%) rename {samples => examples}/google_cloud_run/flask-gunicorn/requirements.txt (100%) rename {samples => examples}/google_cloud_run/sanic/Dockerfile (100%) rename {samples => examples}/google_cloud_run/sanic/main.py (100%) rename {samples => examples}/google_cloud_run/sanic/requirements.txt (100%) rename {samples => examples}/heroku/Procfile (100%) rename {samples => examples}/heroku/main.py (100%) rename {samples => examples}/heroku/requirements.txt (100%) rename {samples => examples}/lazy_async_modals_app.py (100%) rename {samples => examples}/lazy_modals_app.py (100%) rename {samples => examples}/message_events.py (100%) rename {samples => examples}/modals_app.py (100%) rename {samples => examples}/modals_app_typed.py (100%) rename {samples => examples}/oauth_app.py (100%) rename {samples => examples}/oauth_app_settings.py (100%) rename {samples => examples}/oauth_sqlite3_app.py (100%) rename {samples => examples}/pyramid/app.py (100%) rename {samples => examples}/pyramid/oauth_app.py (100%) rename {samples => examples}/pyramid/requirements.txt (100%) rename {samples => examples}/readme_app.py (100%) rename {samples => examples}/readme_async_app.py (100%) rename {samples => examples}/sanic/async_app.py (100%) rename {samples => examples}/sanic/async_oauth_app.py (100%) rename {samples => examples}/sanic/requirements.txt (100%) rename {samples => examples}/sqlalchemy/async_oauth_app.py (100%) rename {samples => examples}/sqlalchemy/oauth_app.py (100%) rename {samples => examples}/sqlalchemy/requirements.txt (100%) rename {samples => examples}/sqlalchemy/requirements_async.txt (100%) rename {samples => examples}/starlette/app.py (100%) rename {samples => examples}/starlette/async_app.py (100%) rename {samples => examples}/starlette/async_oauth_app.py (100%) rename {samples => examples}/starlette/oauth_app.py (100%) rename {samples => examples}/starlette/requirements.txt (100%) rename {samples => examples}/steps_from_apps.py (100%) rename {samples => examples}/steps_from_apps_primitive.py (100%) rename {samples => examples}/tornado/app.py (100%) rename {samples => examples}/tornado/oauth_app.py (100%) rename {samples => examples}/tornado/requirements.txt (100%) diff --git a/docs/_advanced/adapters.md b/docs/_advanced/adapters.md index 0887b335c..65f9367c3 100644 --- a/docs/_advanced/adapters.md +++ b/docs/_advanced/adapters.md @@ -12,7 +12,7 @@ By default, Bolt will use the built-in `samples` folder. +The full list adapters, as well as configuration and sample usage, can be found within the repository's `examples` folder. ```python diff --git a/docs/_advanced/async.md b/docs/_advanced/async.md index 6850764e1..02b60cc4e 100644 --- a/docs/_advanced/async.md +++ b/docs/_advanced/async.md @@ -8,7 +8,7 @@ order: 2
    To use the async version of Bolt, you can import and initialize an `AsyncApp` instance (rather than `App`). `AsyncApp` relies on AIOHTTP to make API requests, which means you'll need to install `aiohttp` (by adding to `requirements.txt` or running `pip install aiohttp`). -Sample async projects can be found within the repository's `samples` folder. +Sample async projects can be found within the repository's `examples` folder.
    ```python diff --git a/docs/_tutorials/getting_started.md b/docs/_tutorials/getting_started.md index 4174d78a6..c71ba9436 100644 --- a/docs/_tutorials/getting_started.md +++ b/docs/_tutorials/getting_started.md @@ -14,7 +14,7 @@ redirect_from: This guide is meant to walk you through getting up and running with a Slack app using Bolt for Python. Along the way, we’ll create a new Slack app, set up your local environment, and develop an app that listens and responds to messages from a Slack workspace. -When you're finished, you'll have this ⚡️[Getting Started with Slack app](https://github.com/slackapi/bolt-python/tree/main/samples/getting_started) to run, modify, and make your own. +When you're finished, you'll have this ⚡️[Getting Started with Slack app](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started) to run, modify, and make your own. --- @@ -307,7 +307,7 @@ You can see that we used `app.action()` to listen for the `action_id` that we na --- ### Next steps -You just built your first [Bolt for Python app](https://github.com/slackapi/bolt-python/tree/main/samples/getting_started)! 🎉 +You just built your first [Bolt for Python app](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started)! 🎉 Now that you have a basic app up and running, you can start exploring how to make your Bolt app stand out. Here are some ideas about what to explore next: diff --git a/samples/app.py b/examples/app.py similarity index 100% rename from samples/app.py rename to examples/app.py diff --git a/samples/app_authorize.py b/examples/app_authorize.py similarity index 100% rename from samples/app_authorize.py rename to examples/app_authorize.py diff --git a/samples/async_app.py b/examples/async_app.py similarity index 100% rename from samples/async_app.py rename to examples/async_app.py diff --git a/samples/async_app_authorize.py b/examples/async_app_authorize.py similarity index 100% rename from samples/async_app_authorize.py rename to examples/async_app_authorize.py diff --git a/samples/async_steps_from_apps.py b/examples/async_steps_from_apps.py similarity index 100% rename from samples/async_steps_from_apps.py rename to examples/async_steps_from_apps.py diff --git a/samples/async_steps_from_apps_primitive.py b/examples/async_steps_from_apps_primitive.py similarity index 100% rename from samples/async_steps_from_apps_primitive.py rename to examples/async_steps_from_apps_primitive.py diff --git a/samples/aws_chalice/.chalice/config.json.oauth b/examples/aws_chalice/.chalice/config.json.oauth similarity index 100% rename from samples/aws_chalice/.chalice/config.json.oauth rename to examples/aws_chalice/.chalice/config.json.oauth diff --git a/samples/aws_chalice/.chalice/config.json.simple b/examples/aws_chalice/.chalice/config.json.simple similarity index 100% rename from samples/aws_chalice/.chalice/config.json.simple rename to examples/aws_chalice/.chalice/config.json.simple diff --git a/samples/aws_chalice/.gitignore b/examples/aws_chalice/.gitignore similarity index 100% rename from samples/aws_chalice/.gitignore rename to examples/aws_chalice/.gitignore diff --git a/samples/aws_chalice/app.py b/examples/aws_chalice/app.py similarity index 100% rename from samples/aws_chalice/app.py rename to examples/aws_chalice/app.py diff --git a/samples/aws_chalice/deploy.sh b/examples/aws_chalice/deploy.sh similarity index 100% rename from samples/aws_chalice/deploy.sh rename to examples/aws_chalice/deploy.sh diff --git a/samples/aws_chalice/oauth_app.py b/examples/aws_chalice/oauth_app.py similarity index 100% rename from samples/aws_chalice/oauth_app.py rename to examples/aws_chalice/oauth_app.py diff --git a/samples/aws_chalice/requirements.txt b/examples/aws_chalice/requirements.txt similarity index 100% rename from samples/aws_chalice/requirements.txt rename to examples/aws_chalice/requirements.txt diff --git a/samples/aws_chalice/simple_app.py b/examples/aws_chalice/simple_app.py similarity index 100% rename from samples/aws_chalice/simple_app.py rename to examples/aws_chalice/simple_app.py diff --git a/samples/aws_lambda/.env.oauth_sample b/examples/aws_lambda/.env.oauth_sample similarity index 100% rename from samples/aws_lambda/.env.oauth_sample rename to examples/aws_lambda/.env.oauth_sample diff --git a/samples/aws_lambda/.env.sample b/examples/aws_lambda/.env.sample similarity index 100% rename from samples/aws_lambda/.env.sample rename to examples/aws_lambda/.env.sample diff --git a/samples/aws_lambda/.gitignore b/examples/aws_lambda/.gitignore similarity index 100% rename from samples/aws_lambda/.gitignore rename to examples/aws_lambda/.gitignore diff --git a/samples/aws_lambda/aws_lambda.py b/examples/aws_lambda/aws_lambda.py similarity index 100% rename from samples/aws_lambda/aws_lambda.py rename to examples/aws_lambda/aws_lambda.py diff --git a/samples/aws_lambda/aws_lambda_config.yaml b/examples/aws_lambda/aws_lambda_config.yaml similarity index 100% rename from samples/aws_lambda/aws_lambda_config.yaml rename to examples/aws_lambda/aws_lambda_config.yaml diff --git a/samples/aws_lambda/aws_lambda_oauth.py b/examples/aws_lambda/aws_lambda_oauth.py similarity index 100% rename from samples/aws_lambda/aws_lambda_oauth.py rename to examples/aws_lambda/aws_lambda_oauth.py diff --git a/samples/aws_lambda/aws_lambda_oauth_config.yaml b/examples/aws_lambda/aws_lambda_oauth_config.yaml similarity index 100% rename from samples/aws_lambda/aws_lambda_oauth_config.yaml rename to examples/aws_lambda/aws_lambda_oauth_config.yaml diff --git a/samples/aws_lambda/deploy.sh b/examples/aws_lambda/deploy.sh similarity index 100% rename from samples/aws_lambda/deploy.sh rename to examples/aws_lambda/deploy.sh diff --git a/samples/aws_lambda/deploy_lazy.sh b/examples/aws_lambda/deploy_lazy.sh similarity index 100% rename from samples/aws_lambda/deploy_lazy.sh rename to examples/aws_lambda/deploy_lazy.sh diff --git a/samples/aws_lambda/deploy_oauth.sh b/examples/aws_lambda/deploy_oauth.sh similarity index 100% rename from samples/aws_lambda/deploy_oauth.sh rename to examples/aws_lambda/deploy_oauth.sh diff --git a/samples/aws_lambda/lazy_aws_lambda.py b/examples/aws_lambda/lazy_aws_lambda.py similarity index 100% rename from samples/aws_lambda/lazy_aws_lambda.py rename to examples/aws_lambda/lazy_aws_lambda.py diff --git a/samples/aws_lambda/lazy_aws_lambda_config.yaml b/examples/aws_lambda/lazy_aws_lambda_config.yaml similarity index 100% rename from samples/aws_lambda/lazy_aws_lambda_config.yaml rename to examples/aws_lambda/lazy_aws_lambda_config.yaml diff --git a/samples/aws_lambda/requirements.txt b/examples/aws_lambda/requirements.txt similarity index 100% rename from samples/aws_lambda/requirements.txt rename to examples/aws_lambda/requirements.txt diff --git a/samples/aws_lambda/requirements_oauth.txt b/examples/aws_lambda/requirements_oauth.txt similarity index 100% rename from samples/aws_lambda/requirements_oauth.txt rename to examples/aws_lambda/requirements_oauth.txt diff --git a/samples/bottle/app.py b/examples/bottle/app.py similarity index 100% rename from samples/bottle/app.py rename to examples/bottle/app.py diff --git a/samples/bottle/oauth_app.py b/examples/bottle/oauth_app.py similarity index 100% rename from samples/bottle/oauth_app.py rename to examples/bottle/oauth_app.py diff --git a/samples/bottle/requirements.txt b/examples/bottle/requirements.txt similarity index 100% rename from samples/bottle/requirements.txt rename to examples/bottle/requirements.txt diff --git a/samples/cherrypy/app.py b/examples/cherrypy/app.py similarity index 100% rename from samples/cherrypy/app.py rename to examples/cherrypy/app.py diff --git a/samples/cherrypy/oauth_app.py b/examples/cherrypy/oauth_app.py similarity index 100% rename from samples/cherrypy/oauth_app.py rename to examples/cherrypy/oauth_app.py diff --git a/samples/cherrypy/requirements.txt b/examples/cherrypy/requirements.txt similarity index 100% rename from samples/cherrypy/requirements.txt rename to examples/cherrypy/requirements.txt diff --git a/samples/dialogs_app.py b/examples/dialogs_app.py similarity index 100% rename from samples/dialogs_app.py rename to examples/dialogs_app.py diff --git a/samples/django/.gitignore b/examples/django/.gitignore similarity index 100% rename from samples/django/.gitignore rename to examples/django/.gitignore diff --git a/samples/django/README.md b/examples/django/README.md similarity index 100% rename from samples/django/README.md rename to examples/django/README.md diff --git a/samples/django/manage.py b/examples/django/manage.py similarity index 100% rename from samples/django/manage.py rename to examples/django/manage.py diff --git a/samples/django/requirements.txt b/examples/django/requirements.txt similarity index 100% rename from samples/django/requirements.txt rename to examples/django/requirements.txt diff --git a/samples/django/slackapp/__init__.py b/examples/django/slackapp/__init__.py similarity index 100% rename from samples/django/slackapp/__init__.py rename to examples/django/slackapp/__init__.py diff --git a/samples/django/slackapp/apps.py b/examples/django/slackapp/apps.py similarity index 100% rename from samples/django/slackapp/apps.py rename to examples/django/slackapp/apps.py diff --git a/samples/django/slackapp/migrations/0001_initial.py b/examples/django/slackapp/migrations/0001_initial.py similarity index 100% rename from samples/django/slackapp/migrations/0001_initial.py rename to examples/django/slackapp/migrations/0001_initial.py diff --git a/samples/django/slackapp/migrations/__init__.py b/examples/django/slackapp/migrations/__init__.py similarity index 100% rename from samples/django/slackapp/migrations/__init__.py rename to examples/django/slackapp/migrations/__init__.py diff --git a/samples/django/slackapp/models.py b/examples/django/slackapp/models.py similarity index 100% rename from samples/django/slackapp/models.py rename to examples/django/slackapp/models.py diff --git a/samples/django/slackapp/settings.py b/examples/django/slackapp/settings.py similarity index 100% rename from samples/django/slackapp/settings.py rename to examples/django/slackapp/settings.py diff --git a/samples/django/slackapp/urls.py b/examples/django/slackapp/urls.py similarity index 100% rename from samples/django/slackapp/urls.py rename to examples/django/slackapp/urls.py diff --git a/samples/django/slackapp/views.py b/examples/django/slackapp/views.py similarity index 100% rename from samples/django/slackapp/views.py rename to examples/django/slackapp/views.py diff --git a/samples/django/slackapp/wsgi.py b/examples/django/slackapp/wsgi.py similarity index 100% rename from samples/django/slackapp/wsgi.py rename to examples/django/slackapp/wsgi.py diff --git a/samples/docker/aiohttp/Dockerfile b/examples/docker/aiohttp/Dockerfile similarity index 100% rename from samples/docker/aiohttp/Dockerfile rename to examples/docker/aiohttp/Dockerfile diff --git a/samples/docker/aiohttp/main.py b/examples/docker/aiohttp/main.py similarity index 100% rename from samples/docker/aiohttp/main.py rename to examples/docker/aiohttp/main.py diff --git a/samples/docker/aiohttp/requirements.txt b/examples/docker/aiohttp/requirements.txt similarity index 100% rename from samples/docker/aiohttp/requirements.txt rename to examples/docker/aiohttp/requirements.txt diff --git a/samples/docker/fastapi-gunicorn/Dockerfile b/examples/docker/fastapi-gunicorn/Dockerfile similarity index 100% rename from samples/docker/fastapi-gunicorn/Dockerfile rename to examples/docker/fastapi-gunicorn/Dockerfile diff --git a/samples/docker/fastapi-gunicorn/main.py b/examples/docker/fastapi-gunicorn/main.py similarity index 100% rename from samples/docker/fastapi-gunicorn/main.py rename to examples/docker/fastapi-gunicorn/main.py diff --git a/samples/docker/fastapi-gunicorn/requirements.txt b/examples/docker/fastapi-gunicorn/requirements.txt similarity index 100% rename from samples/docker/fastapi-gunicorn/requirements.txt rename to examples/docker/fastapi-gunicorn/requirements.txt diff --git a/samples/docker/flask-gunicorn/Dockerfile b/examples/docker/flask-gunicorn/Dockerfile similarity index 100% rename from samples/docker/flask-gunicorn/Dockerfile rename to examples/docker/flask-gunicorn/Dockerfile diff --git a/samples/docker/flask-gunicorn/main.py b/examples/docker/flask-gunicorn/main.py similarity index 100% rename from samples/docker/flask-gunicorn/main.py rename to examples/docker/flask-gunicorn/main.py diff --git a/samples/docker/flask-gunicorn/requirements.txt b/examples/docker/flask-gunicorn/requirements.txt similarity index 100% rename from samples/docker/flask-gunicorn/requirements.txt rename to examples/docker/flask-gunicorn/requirements.txt diff --git a/samples/docker/flask-uwsgi/Dockerfile b/examples/docker/flask-uwsgi/Dockerfile similarity index 100% rename from samples/docker/flask-uwsgi/Dockerfile rename to examples/docker/flask-uwsgi/Dockerfile diff --git a/samples/docker/flask-uwsgi/main.py b/examples/docker/flask-uwsgi/main.py similarity index 100% rename from samples/docker/flask-uwsgi/main.py rename to examples/docker/flask-uwsgi/main.py diff --git a/samples/docker/flask-uwsgi/requirements.txt b/examples/docker/flask-uwsgi/requirements.txt similarity index 100% rename from samples/docker/flask-uwsgi/requirements.txt rename to examples/docker/flask-uwsgi/requirements.txt diff --git a/samples/docker/flask-uwsgi/uwsgi.ini b/examples/docker/flask-uwsgi/uwsgi.ini similarity index 100% rename from samples/docker/flask-uwsgi/uwsgi.ini rename to examples/docker/flask-uwsgi/uwsgi.ini diff --git a/samples/docker/sanic/Dockerfile b/examples/docker/sanic/Dockerfile similarity index 100% rename from samples/docker/sanic/Dockerfile rename to examples/docker/sanic/Dockerfile diff --git a/samples/docker/sanic/main.py b/examples/docker/sanic/main.py similarity index 100% rename from samples/docker/sanic/main.py rename to examples/docker/sanic/main.py diff --git a/samples/docker/sanic/requirements.txt b/examples/docker/sanic/requirements.txt similarity index 100% rename from samples/docker/sanic/requirements.txt rename to examples/docker/sanic/requirements.txt diff --git a/samples/events_app.py b/examples/events_app.py similarity index 100% rename from samples/events_app.py rename to examples/events_app.py diff --git a/samples/falcon/app.py b/examples/falcon/app.py similarity index 100% rename from samples/falcon/app.py rename to examples/falcon/app.py diff --git a/samples/falcon/oauth_app.py b/examples/falcon/oauth_app.py similarity index 100% rename from samples/falcon/oauth_app.py rename to examples/falcon/oauth_app.py diff --git a/samples/falcon/requirements.txt b/examples/falcon/requirements.txt similarity index 100% rename from samples/falcon/requirements.txt rename to examples/falcon/requirements.txt diff --git a/samples/fastapi/app.py b/examples/fastapi/app.py similarity index 100% rename from samples/fastapi/app.py rename to examples/fastapi/app.py diff --git a/samples/fastapi/async_app.py b/examples/fastapi/async_app.py similarity index 100% rename from samples/fastapi/async_app.py rename to examples/fastapi/async_app.py diff --git a/samples/fastapi/async_oauth_app.py b/examples/fastapi/async_oauth_app.py similarity index 100% rename from samples/fastapi/async_oauth_app.py rename to examples/fastapi/async_oauth_app.py diff --git a/samples/fastapi/oauth_app.py b/examples/fastapi/oauth_app.py similarity index 100% rename from samples/fastapi/oauth_app.py rename to examples/fastapi/oauth_app.py diff --git a/samples/fastapi/requirements.txt b/examples/fastapi/requirements.txt similarity index 100% rename from samples/fastapi/requirements.txt rename to examples/fastapi/requirements.txt diff --git a/samples/flask/app.py b/examples/flask/app.py similarity index 100% rename from samples/flask/app.py rename to examples/flask/app.py diff --git a/samples/flask/oauth_app.py b/examples/flask/oauth_app.py similarity index 100% rename from samples/flask/oauth_app.py rename to examples/flask/oauth_app.py diff --git a/samples/flask/requirements.txt b/examples/flask/requirements.txt similarity index 100% rename from samples/flask/requirements.txt rename to examples/flask/requirements.txt diff --git a/samples/getting_started/.gitignore b/examples/getting_started/.gitignore similarity index 100% rename from samples/getting_started/.gitignore rename to examples/getting_started/.gitignore diff --git a/samples/getting_started/README.md b/examples/getting_started/README.md similarity index 100% rename from samples/getting_started/README.md rename to examples/getting_started/README.md diff --git a/samples/getting_started/app.py b/examples/getting_started/app.py similarity index 100% rename from samples/getting_started/app.py rename to examples/getting_started/app.py diff --git a/samples/getting_started/requirements.txt b/examples/getting_started/requirements.txt similarity index 100% rename from samples/getting_started/requirements.txt rename to examples/getting_started/requirements.txt diff --git a/samples/google_app_engine/flask/.gcloudignore b/examples/google_app_engine/flask/.gcloudignore similarity index 100% rename from samples/google_app_engine/flask/.gcloudignore rename to examples/google_app_engine/flask/.gcloudignore diff --git a/samples/google_app_engine/flask/.gitignore b/examples/google_app_engine/flask/.gitignore similarity index 100% rename from samples/google_app_engine/flask/.gitignore rename to examples/google_app_engine/flask/.gitignore diff --git a/samples/google_app_engine/flask/app.yaml b/examples/google_app_engine/flask/app.yaml similarity index 100% rename from samples/google_app_engine/flask/app.yaml rename to examples/google_app_engine/flask/app.yaml diff --git a/samples/google_app_engine/flask/env_variables.yaml.sample b/examples/google_app_engine/flask/env_variables.yaml.sample similarity index 100% rename from samples/google_app_engine/flask/env_variables.yaml.sample rename to examples/google_app_engine/flask/env_variables.yaml.sample diff --git a/samples/google_app_engine/flask/main.py b/examples/google_app_engine/flask/main.py similarity index 100% rename from samples/google_app_engine/flask/main.py rename to examples/google_app_engine/flask/main.py diff --git a/samples/google_app_engine/flask/requirements.txt b/examples/google_app_engine/flask/requirements.txt similarity index 100% rename from samples/google_app_engine/flask/requirements.txt rename to examples/google_app_engine/flask/requirements.txt diff --git a/samples/google_cloud_functions/.env.yaml.sample b/examples/google_cloud_functions/.env.yaml.sample similarity index 100% rename from samples/google_cloud_functions/.env.yaml.sample rename to examples/google_cloud_functions/.env.yaml.sample diff --git a/samples/google_cloud_functions/.gcloudignore b/examples/google_cloud_functions/.gcloudignore similarity index 100% rename from samples/google_cloud_functions/.gcloudignore rename to examples/google_cloud_functions/.gcloudignore diff --git a/samples/google_cloud_functions/.gitignore b/examples/google_cloud_functions/.gitignore similarity index 100% rename from samples/google_cloud_functions/.gitignore rename to examples/google_cloud_functions/.gitignore diff --git a/samples/google_cloud_functions/main.py b/examples/google_cloud_functions/main.py similarity index 100% rename from samples/google_cloud_functions/main.py rename to examples/google_cloud_functions/main.py diff --git a/samples/google_cloud_functions/requirements.txt b/examples/google_cloud_functions/requirements.txt similarity index 100% rename from samples/google_cloud_functions/requirements.txt rename to examples/google_cloud_functions/requirements.txt diff --git a/samples/google_cloud_run/aiohttp/Dockerfile b/examples/google_cloud_run/aiohttp/Dockerfile similarity index 100% rename from samples/google_cloud_run/aiohttp/Dockerfile rename to examples/google_cloud_run/aiohttp/Dockerfile diff --git a/samples/google_cloud_run/aiohttp/main.py b/examples/google_cloud_run/aiohttp/main.py similarity index 100% rename from samples/google_cloud_run/aiohttp/main.py rename to examples/google_cloud_run/aiohttp/main.py diff --git a/samples/google_cloud_run/aiohttp/requirements.txt b/examples/google_cloud_run/aiohttp/requirements.txt similarity index 100% rename from samples/google_cloud_run/aiohttp/requirements.txt rename to examples/google_cloud_run/aiohttp/requirements.txt diff --git a/samples/google_cloud_run/flask-gunicorn/Dockerfile b/examples/google_cloud_run/flask-gunicorn/Dockerfile similarity index 100% rename from samples/google_cloud_run/flask-gunicorn/Dockerfile rename to examples/google_cloud_run/flask-gunicorn/Dockerfile diff --git a/samples/google_cloud_run/flask-gunicorn/main.py b/examples/google_cloud_run/flask-gunicorn/main.py similarity index 100% rename from samples/google_cloud_run/flask-gunicorn/main.py rename to examples/google_cloud_run/flask-gunicorn/main.py diff --git a/samples/google_cloud_run/flask-gunicorn/requirements.txt b/examples/google_cloud_run/flask-gunicorn/requirements.txt similarity index 100% rename from samples/google_cloud_run/flask-gunicorn/requirements.txt rename to examples/google_cloud_run/flask-gunicorn/requirements.txt diff --git a/samples/google_cloud_run/sanic/Dockerfile b/examples/google_cloud_run/sanic/Dockerfile similarity index 100% rename from samples/google_cloud_run/sanic/Dockerfile rename to examples/google_cloud_run/sanic/Dockerfile diff --git a/samples/google_cloud_run/sanic/main.py b/examples/google_cloud_run/sanic/main.py similarity index 100% rename from samples/google_cloud_run/sanic/main.py rename to examples/google_cloud_run/sanic/main.py diff --git a/samples/google_cloud_run/sanic/requirements.txt b/examples/google_cloud_run/sanic/requirements.txt similarity index 100% rename from samples/google_cloud_run/sanic/requirements.txt rename to examples/google_cloud_run/sanic/requirements.txt diff --git a/samples/heroku/Procfile b/examples/heroku/Procfile similarity index 100% rename from samples/heroku/Procfile rename to examples/heroku/Procfile diff --git a/samples/heroku/main.py b/examples/heroku/main.py similarity index 100% rename from samples/heroku/main.py rename to examples/heroku/main.py diff --git a/samples/heroku/requirements.txt b/examples/heroku/requirements.txt similarity index 100% rename from samples/heroku/requirements.txt rename to examples/heroku/requirements.txt diff --git a/samples/lazy_async_modals_app.py b/examples/lazy_async_modals_app.py similarity index 100% rename from samples/lazy_async_modals_app.py rename to examples/lazy_async_modals_app.py diff --git a/samples/lazy_modals_app.py b/examples/lazy_modals_app.py similarity index 100% rename from samples/lazy_modals_app.py rename to examples/lazy_modals_app.py diff --git a/samples/message_events.py b/examples/message_events.py similarity index 100% rename from samples/message_events.py rename to examples/message_events.py diff --git a/samples/modals_app.py b/examples/modals_app.py similarity index 100% rename from samples/modals_app.py rename to examples/modals_app.py diff --git a/samples/modals_app_typed.py b/examples/modals_app_typed.py similarity index 100% rename from samples/modals_app_typed.py rename to examples/modals_app_typed.py diff --git a/samples/oauth_app.py b/examples/oauth_app.py similarity index 100% rename from samples/oauth_app.py rename to examples/oauth_app.py diff --git a/samples/oauth_app_settings.py b/examples/oauth_app_settings.py similarity index 100% rename from samples/oauth_app_settings.py rename to examples/oauth_app_settings.py diff --git a/samples/oauth_sqlite3_app.py b/examples/oauth_sqlite3_app.py similarity index 100% rename from samples/oauth_sqlite3_app.py rename to examples/oauth_sqlite3_app.py diff --git a/samples/pyramid/app.py b/examples/pyramid/app.py similarity index 100% rename from samples/pyramid/app.py rename to examples/pyramid/app.py diff --git a/samples/pyramid/oauth_app.py b/examples/pyramid/oauth_app.py similarity index 100% rename from samples/pyramid/oauth_app.py rename to examples/pyramid/oauth_app.py diff --git a/samples/pyramid/requirements.txt b/examples/pyramid/requirements.txt similarity index 100% rename from samples/pyramid/requirements.txt rename to examples/pyramid/requirements.txt diff --git a/samples/readme_app.py b/examples/readme_app.py similarity index 100% rename from samples/readme_app.py rename to examples/readme_app.py diff --git a/samples/readme_async_app.py b/examples/readme_async_app.py similarity index 100% rename from samples/readme_async_app.py rename to examples/readme_async_app.py diff --git a/samples/sanic/async_app.py b/examples/sanic/async_app.py similarity index 100% rename from samples/sanic/async_app.py rename to examples/sanic/async_app.py diff --git a/samples/sanic/async_oauth_app.py b/examples/sanic/async_oauth_app.py similarity index 100% rename from samples/sanic/async_oauth_app.py rename to examples/sanic/async_oauth_app.py diff --git a/samples/sanic/requirements.txt b/examples/sanic/requirements.txt similarity index 100% rename from samples/sanic/requirements.txt rename to examples/sanic/requirements.txt diff --git a/samples/sqlalchemy/async_oauth_app.py b/examples/sqlalchemy/async_oauth_app.py similarity index 100% rename from samples/sqlalchemy/async_oauth_app.py rename to examples/sqlalchemy/async_oauth_app.py diff --git a/samples/sqlalchemy/oauth_app.py b/examples/sqlalchemy/oauth_app.py similarity index 100% rename from samples/sqlalchemy/oauth_app.py rename to examples/sqlalchemy/oauth_app.py diff --git a/samples/sqlalchemy/requirements.txt b/examples/sqlalchemy/requirements.txt similarity index 100% rename from samples/sqlalchemy/requirements.txt rename to examples/sqlalchemy/requirements.txt diff --git a/samples/sqlalchemy/requirements_async.txt b/examples/sqlalchemy/requirements_async.txt similarity index 100% rename from samples/sqlalchemy/requirements_async.txt rename to examples/sqlalchemy/requirements_async.txt diff --git a/samples/starlette/app.py b/examples/starlette/app.py similarity index 100% rename from samples/starlette/app.py rename to examples/starlette/app.py diff --git a/samples/starlette/async_app.py b/examples/starlette/async_app.py similarity index 100% rename from samples/starlette/async_app.py rename to examples/starlette/async_app.py diff --git a/samples/starlette/async_oauth_app.py b/examples/starlette/async_oauth_app.py similarity index 100% rename from samples/starlette/async_oauth_app.py rename to examples/starlette/async_oauth_app.py diff --git a/samples/starlette/oauth_app.py b/examples/starlette/oauth_app.py similarity index 100% rename from samples/starlette/oauth_app.py rename to examples/starlette/oauth_app.py diff --git a/samples/starlette/requirements.txt b/examples/starlette/requirements.txt similarity index 100% rename from samples/starlette/requirements.txt rename to examples/starlette/requirements.txt diff --git a/samples/steps_from_apps.py b/examples/steps_from_apps.py similarity index 100% rename from samples/steps_from_apps.py rename to examples/steps_from_apps.py diff --git a/samples/steps_from_apps_primitive.py b/examples/steps_from_apps_primitive.py similarity index 100% rename from samples/steps_from_apps_primitive.py rename to examples/steps_from_apps_primitive.py diff --git a/samples/tornado/app.py b/examples/tornado/app.py similarity index 100% rename from samples/tornado/app.py rename to examples/tornado/app.py diff --git a/samples/tornado/oauth_app.py b/examples/tornado/oauth_app.py similarity index 100% rename from samples/tornado/oauth_app.py rename to examples/tornado/oauth_app.py diff --git a/samples/tornado/requirements.txt b/examples/tornado/requirements.txt similarity index 100% rename from samples/tornado/requirements.txt rename to examples/tornado/requirements.txt From 6a8d38da5704be7d0d7124162057b3d71a77ed15 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 3 Oct 2020 08:36:06 +0900 Subject: [PATCH 132/865] Fix more on samples to examples renaming --- .github/maintainers_guide.md | 6 +++--- README.md | 6 +++--- examples/getting_started/README.md | 2 +- setup.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index e1e00eb40..6839007d8 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -84,7 +84,7 @@ $ ./scripts/run_tests.sh tests/scenario_tests/test_app.py #### Run the Samples -If you make changes to `slack_bolt/adapter/*`, please verify if it surely works by running the apps under `samples` directory. +If you make changes to `slack_bolt/adapter/*`, please verify if it surely works by running the apps under `examples` directory. ```bash # Install all optional dependencies @@ -95,12 +95,12 @@ $ export SLACK_SIGNING_SECRET=*** $ export SLACK_BOT_TOKEN=xoxb-*** # Standalone apps -$ cd samples/ +$ cd examples/ $ python app.py $ python async_app.py # Flask apps -$ cd samples/flask +$ cd examples/flask $ FLASK_APP=app.py FLASK_ENV=development flask run -p 3000 # In another terminal diff --git a/README.md b/README.md index 3babe6b6f..bb91bff44 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Build Status][travis-image]][travis-url] [![Codecov][codecov-image]][codecov-url] -A Python framework to build Slack apps in a flash with the latest platform features. Check the [document](https://slack.dev/bolt-python/) and [samples](https://github.com/slackapi/bolt-python/tree/main/samples) to know how to use this framework. +A Python framework to build Slack apps in a flash with the latest platform features. Check the [document](https://slack.dev/bolt-python/) and [examples](https://github.com/slackapi/bolt-python/tree/main/examples) to know how to use this framework. ## Setup @@ -141,9 +141,9 @@ python app.py ngrok http 3000 ``` -If you want to use another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at the built-in adapters and their samples. +If you want to use another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at the built-in adapters and their examples. -* [The Bolt app samples](https://github.com/slackapi/bolt-python/tree/main/samples) +* [The Bolt app examples](https://github.com/slackapi/bolt-python/tree/main/examples) * [The built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) # Feedback diff --git a/examples/getting_started/README.md b/examples/getting_started/README.md index 0682fd9c5..63875a4dd 100644 --- a/examples/getting_started/README.md +++ b/examples/getting_started/README.md @@ -23,7 +23,7 @@ export SLACK_SIGNING_SECRET= git clone https://github.com/slackapi/bolt-python.git # Change into this project -cd bolt-python/samples/getting_started/ +cd bolt-python/examples/getting_started/ # Setup virtual environment python3 -m venv .venv diff --git a/setup.py b/setup.py index 78244ba6e..47c8f6fc5 100755 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ long_description_content_type="text/markdown", url="https://github.com/slackapi/bolt-python", packages=setuptools.find_packages( - exclude=["samples", "integration_tests", "tests", "tests.*",] + exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in install_requires=["slack_sdk>=3.0.0a9",], From 50e99d332880b56c48968826eeaad47e33d3cc98 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 4 Oct 2020 13:08:00 +0900 Subject: [PATCH 133/865] Fix #111 by correcting the logic to check event type --- slack_bolt/util/payload_utils.py | 2 +- tests/scenario_tests/test_workflow_steps.py | 3 ++ .../test_workflow_steps.py | 2 + .../listener_matcher/test_builtins.py | 42 ++++++++++++++++++- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/slack_bolt/util/payload_utils.py b/slack_bolt/util/payload_utils.py index 529628b24..dec22fd62 100644 --- a/slack_bolt/util/payload_utils.py +++ b/slack_bolt/util/payload_utils.py @@ -32,7 +32,7 @@ def is_event(body: Dict[str, Any]) -> bool: def is_workflow_step_execute(body: Dict[str, Any]) -> bool: return ( is_event(body) - and body["event"]["type"] == "workflow_step_edit" + and body["event"]["type"] == "workflow_step_execute" and "workflow_step" in body["event"] ) diff --git a/tests/scenario_tests/test_workflow_steps.py b/tests/scenario_tests/test_workflow_steps.py index 9b22c3901..aa437c450 100644 --- a/tests/scenario_tests/test_workflow_steps.py +++ b/tests/scenario_tests/test_workflow_steps.py @@ -1,4 +1,5 @@ import json +import time as time_module from time import time from urllib.parse import quote @@ -85,6 +86,8 @@ def test_execute(self): response = self.app.dispatch(request) assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 1 + time_module.sleep(0.5) + assert self.mock_received_requests["/workflows.stepCompleted"] == 1 self.app = App(client=self.web_client, signing_secret=self.signing_secret) self.app.step( diff --git a/tests/scenario_tests_async/test_workflow_steps.py b/tests/scenario_tests_async/test_workflow_steps.py index e49fc3b99..b8be4913d 100644 --- a/tests/scenario_tests_async/test_workflow_steps.py +++ b/tests/scenario_tests_async/test_workflow_steps.py @@ -105,6 +105,8 @@ async def test_execute(self): response = await self.app.async_dispatch(request) assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(0.5) + assert self.mock_received_requests["/workflows.stepCompleted"] == 1 self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) self.app.step( diff --git a/tests/slack_bolt/listener_matcher/test_builtins.py b/tests/slack_bolt/listener_matcher/test_builtins.py index c15361722..f8185d938 100644 --- a/tests/slack_bolt/listener_matcher/test_builtins.py +++ b/tests/slack_bolt/listener_matcher/test_builtins.py @@ -3,7 +3,11 @@ from urllib.parse import quote from slack_bolt import BoltRequest, BoltResponse -from slack_bolt.listener_matcher.builtins import block_action, action +from slack_bolt.listener_matcher.builtins import ( + block_action, + action, + workflow_step_execute, +) class TestBuiltins: @@ -95,3 +99,39 @@ def test_block_action(self): ) is False ) + + def test_workflow_step_execute(self): + payload = { + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": {"taskName": {"value": "a"}}, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"} + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, + } + + request = BoltRequest(body=f"payload={quote(json.dumps(payload))}") + + m = workflow_step_execute("copy_review") + assert m.matches(request, None) == True + + m = workflow_step_execute("copy_review_2") + assert m.matches(request, None) == False + + m = workflow_step_execute(re.compile("copy_.+")) + assert m.matches(request, None) == True From 7ac456f229f780b5176ce41af2bbe9c334e6089b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 4 Oct 2020 13:31:29 +0900 Subject: [PATCH 134/865] Fix missing step arg in save listeners --- slack_bolt/util/payload_utils.py | 3 +++ tests/scenario_tests/test_workflow_steps.py | 4 +++- tests/scenario_tests_async/test_workflow_steps.py | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/slack_bolt/util/payload_utils.py b/slack_bolt/util/payload_utils.py index dec22fd62..ef3dffc73 100644 --- a/slack_bolt/util/payload_utils.py +++ b/slack_bolt/util/payload_utils.py @@ -222,6 +222,9 @@ def to_step(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: # edit if is_workflow_step_edit(body): return body["workflow_step"] + # save + if is_workflow_step_save(body): + return body["workflow_step"] # execute if is_workflow_step_execute(body): return body["event"]["workflow_step"] diff --git a/tests/scenario_tests/test_workflow_steps.py b/tests/scenario_tests/test_workflow_steps.py index aa437c450..82a67a356 100644 --- a/tests/scenario_tests/test_workflow_steps.py +++ b/tests/scenario_tests/test_workflow_steps.py @@ -325,7 +325,9 @@ def edit(ack: Ack, step, configure: Configure): ) -def save(ack: Ack, view: dict, update: Update): +def save(ack: Ack, step: dict, view: dict, update: Update): + assert step is not None + assert view is not None state_values = view["state"]["values"] update( inputs={ diff --git a/tests/scenario_tests_async/test_workflow_steps.py b/tests/scenario_tests_async/test_workflow_steps.py index b8be4913d..de92b5b55 100644 --- a/tests/scenario_tests_async/test_workflow_steps.py +++ b/tests/scenario_tests_async/test_workflow_steps.py @@ -344,7 +344,9 @@ async def edit(ack: AsyncAck, step, configure: AsyncConfigure): ) -async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): +async def save(ack: AsyncAck, step: dict, view: dict, update: AsyncUpdate): + assert step is not None + assert view is not None state_values = view["state"]["values"] await update( inputs={ From 14fe0568f05f84e8c05c97320568b2c4bd09ebd8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 4 Oct 2020 18:59:44 +0900 Subject: [PATCH 135/865] Fix decorators' side effects to methods --- slack_bolt/app/app.py | 59 ++++++---- slack_bolt/app/async_app.py | 62 ++++++---- tests/scenario_tests/test_app_decorators.py | 106 +++++++++++++++++ .../test_app_decorators.py | 110 ++++++++++++++++++ 4 files changed, 293 insertions(+), 44 deletions(-) create mode 100644 tests/scenario_tests/test_app_decorators.py create mode 100644 tests/scenario_tests_async/test_app_decorators.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 0e1b2ed68..e4946706c 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -327,11 +327,11 @@ def middleware_next(): # ------------------------- # middleware - def use(self, *args) -> None: + def use(self, *args) -> Optional[Callable]: """Refer to middleware method's docstring for details.""" return self.middleware(*args) - def middleware(self, *args) -> None: + def middleware(self, *args) -> Optional[Callable]: """Registers a new middleware to this Bolt app. :param args: a list of middleware. Passing a single middleware is supported. @@ -341,10 +341,16 @@ def middleware(self, *args) -> None: middleware_or_callable = args[0] if isinstance(middleware_or_callable, Middleware): self._middleware_list.append(middleware_or_callable) - else: + elif isinstance(middleware_or_callable, Callable): self._middleware_list.append( CustomMiddleware(app_name=self.name, func=middleware_or_callable) ) + return middleware_or_callable + else: + raise BoltError( + f"Unexpected type for a middleware ({type(middleware_or_callable)})" + ) + return None # ------------------------- # Workflows: Steps from Apps @@ -372,7 +378,9 @@ def step( # ------------------------- # global error handler - def error(self, func: Callable[..., None]) -> None: + def error( + self, func: Callable[..., None] + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Updates the global error handler. :param func: The function that is supposed to be executed @@ -382,6 +390,7 @@ def error(self, func: Callable[..., None]) -> None: self._listener_runner.listener_error_handler = CustomListenerErrorHandler( logger=self._framework_logger, func=func, ) + return func # ------------------------- # events @@ -391,7 +400,7 @@ def event( event: Union[str, Pattern, Dict[str, str]], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new event listener. :param event: The conditions to match against a request payload @@ -414,7 +423,7 @@ def message( keyword: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new message event listener. Check the #event method's docstring for details. """ @@ -441,7 +450,7 @@ def command( command: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new slash command listener. :param command: The conditions to match against a request payload @@ -467,7 +476,7 @@ def shortcut( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new shortcut listener. :param constraints: The conditions to match against a request payload @@ -490,7 +499,7 @@ def global_shortcut( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new global shortcut listener.""" def __call__(*args, **kwargs): @@ -507,7 +516,7 @@ def message_shortcut( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new message shortcut listener.""" def __call__(*args, **kwargs): @@ -527,7 +536,7 @@ def action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new action listener. :param constraints: The conditions to match against a request payload @@ -550,7 +559,7 @@ def block_action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new block_actions listener.""" def __call__(*args, **kwargs): @@ -567,7 +576,7 @@ def attachment_action( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new interactive_message listener.""" def __call__(*args, **kwargs): @@ -584,7 +593,7 @@ def dialog_submission( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new dialog_submission listener.""" def __call__(*args, **kwargs): @@ -601,7 +610,7 @@ def dialog_cancellation( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new dialog_cancellation listener.""" def __call__(*args, **kwargs): @@ -621,7 +630,7 @@ def view( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new view submission/closed event listener. :param constraints: The conditions to match against a request payload @@ -644,7 +653,7 @@ def view_submission( constraints: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new view_submission listener.""" def __call__(*args, **kwargs): @@ -661,7 +670,7 @@ def view_closed( constraints: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new view_closed listener.""" def __call__(*args, **kwargs): @@ -681,7 +690,7 @@ def options( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new options listener. :param constraints: The conditions to match against a request payload @@ -704,7 +713,7 @@ def block_suggestion( action_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new block_suggestion listener.""" def __call__(*args, **kwargs): @@ -721,7 +730,7 @@ def dialog_suggestion( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., bool]]] = None, middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new dialog_submission listener.""" def __call__(*args, **kwargs): @@ -758,9 +767,14 @@ def _register_listener( matchers: Optional[List[Callable[..., bool]]], middleware: Optional[List[Union[Callable, Middleware]]], auto_acknowledgement: bool = False, - ) -> None: + ) -> Optional[Callable[..., Optional[BoltResponse]]]: + value_to_return = None if not isinstance(functions, list): functions = list(functions) + if len(functions) == 1: + # In the case where the function is registered using decorator, + # the registration should return the original function. + value_to_return = functions[0] listener_matchers = [ CustomListenerMatcher(app_name=self.name, func=f) for f in (matchers or []) @@ -785,6 +799,7 @@ def _register_listener( auto_acknowledgement=auto_acknowledgement, ) ) + return value_to_return # ------------------------- diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index da9dd6fc6..272a01747 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -337,11 +337,11 @@ async def async_middleware_next(): # ------------------------- # middleware - def use(self, *args) -> None: + def use(self, *args) -> Optional[Callable]: """Refer to middleware method's docstring for details.""" return self.middleware(*args) - def middleware(self, *args) -> None: + def middleware(self, *args) -> Optional[Callable]: """Registers a new middleware to this Bolt app. :param args: a list of middleware. Passing a single middleware is supported. @@ -351,12 +351,18 @@ def middleware(self, *args) -> None: middleware_or_callable = args[0] if isinstance(middleware_or_callable, AsyncMiddleware): self._async_middleware_list.append(middleware_or_callable) - else: + elif isinstance(middleware_or_callable, Callable): self._async_middleware_list.append( AsyncCustomMiddleware( app_name=self.name, func=middleware_or_callable ) ) + return middleware_or_callable + else: + raise BoltError( + f"Unexpected type for a middleware ({type(middleware_or_callable)})" + ) + return None # ------------------------- # Workflows: Steps from Apps @@ -388,7 +394,9 @@ def step( # ------------------------- # global error handler - def error(self, func: Callable[..., Awaitable[None]]) -> None: + def error( + self, func: Callable[..., Awaitable[None]] + ) -> Callable[..., Awaitable[None]]: """Updates the global error handler. :param func: The function that is supposed to be executed @@ -398,6 +406,7 @@ def error(self, func: Callable[..., Awaitable[None]]) -> None: self._async_listener_runner.listener_error_handler = AsyncCustomListenerErrorHandler( logger=self._framework_logger, func=func, ) + return func # ------------------------- # events @@ -407,7 +416,7 @@ def event( event: Union[str, Pattern, Dict[str, str]], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new event listener. :param event: The conditions to match against a request payload @@ -430,7 +439,7 @@ def message( keyword: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Register a new message event listener.""" matchers = matchers if matchers else [] middleware = middleware if middleware else [] @@ -455,7 +464,7 @@ def command( command: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new slash command listener. :param command: The conditions to match against a request payload @@ -481,7 +490,7 @@ def shortcut( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new shortcut listener. :param constraints: The conditions to match against a request payload @@ -504,7 +513,7 @@ def global_shortcut( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new global shortcut listener.""" def __call__(*args, **kwargs): @@ -521,7 +530,7 @@ def message_shortcut( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new message shortcut listener.""" def __call__(*args, **kwargs): @@ -541,7 +550,7 @@ def action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new action listener. :param constraints: The conditions to match against a request payload @@ -564,7 +573,7 @@ def block_action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new block_actions listener.""" def __call__(*args, **kwargs): @@ -581,7 +590,7 @@ def attachment_action( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new interactive_message listener.""" def __call__(*args, **kwargs): @@ -598,7 +607,7 @@ def dialog_submission( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new dialog_submission listener.""" def __call__(*args, **kwargs): @@ -615,7 +624,7 @@ def dialog_cancellation( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new dialog_cancellation listener.""" def __call__(*args, **kwargs): @@ -635,7 +644,7 @@ def view( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new view submission/closed listener. :param constraints: The conditions to match against a request payload @@ -658,7 +667,7 @@ def view_submission( constraints: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new view_submission listener.""" def __call__(*args, **kwargs): @@ -675,7 +684,7 @@ def view_closed( constraints: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new view_closed listener.""" def __call__(*args, **kwargs): @@ -695,7 +704,7 @@ def options( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new options listener. :param constraints: The conditions to match against a request payload @@ -718,7 +727,7 @@ def block_suggestion( action_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new block_suggestion listener.""" def __call__(*args, **kwargs): @@ -735,7 +744,7 @@ def dialog_suggestion( callback_id: Union[str, Pattern], matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Callable[..., None]: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new dialog_submission listener.""" def __call__(*args, **kwargs): @@ -772,7 +781,14 @@ def _register_listener( matchers: Optional[List[Callable[..., Awaitable[bool]]]], middleware: Optional[List[Union[Callable, AsyncMiddleware]]], auto_acknowledgement: bool = False, - ) -> None: + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + value_to_return = None + if not isinstance(functions, list): + functions = list(functions) + if len(functions) == 1: + # In the case where the function is registered using decorator, + # the registration should return the original function. + value_to_return = functions[0] for func in functions: if not inspect.iscoroutinefunction(func): @@ -805,3 +821,5 @@ def _register_listener( auto_acknowledgement=auto_acknowledgement, ) ) + + return value_to_return diff --git a/tests/scenario_tests/test_app_decorators.py b/tests/scenario_tests/test_app_decorators.py new file mode 100644 index 000000000..2cf299eb8 --- /dev/null +++ b/tests/scenario_tests/test_app_decorators.py @@ -0,0 +1,106 @@ +from typing import Callable + +from slack_sdk import WebClient + +from slack_bolt import App, Ack, BoltResponse +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 NoopAck(Ack): + def __call__(self) -> BoltResponse: + pass + + +class TestAppDecorators: + 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 test_decorators(self): + try: + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + app = App(signing_secret=self.signing_secret, client=self.web_client) + ack = NoopAck() + + @app.event("app_home_opened") + def handle_events(body: dict): + assert body is not None + + handle_events({}) + assert isinstance(handle_events, Callable) + + @app.message("hi") + def handle_message_events(body: dict): + assert body is not None + + handle_message_events({}) + assert isinstance(handle_message_events, Callable) + + @app.command("/hello") + def handle_commands(ack: Ack, body: dict): + assert body is not None + ack() + + handle_commands(ack, {}) + assert isinstance(handle_commands, Callable) + + @app.shortcut("test-shortcut") + def handle_shortcuts(ack: Ack, body: dict): + assert body is not None + ack() + + handle_shortcuts(ack, {}) + assert isinstance(handle_shortcuts, Callable) + + @app.action("some-action-id") + def handle_actions(ack: Ack, body: dict): + assert body is not None + ack() + + handle_actions(ack, {}) + assert isinstance(handle_actions, Callable) + + @app.view("some-callback-id") + def handle_views(ack: Ack, body: dict): + assert body is not None + ack() + + handle_views(ack, {}) + assert isinstance(handle_views, Callable) + + @app.options("some-id") + def handle_views(ack: Ack, body: dict): + assert body is not None + ack() + + handle_views(ack, {}) + assert isinstance(handle_views, Callable) + + @app.error + def handle_errors(body: dict): + assert body is not None + + handle_errors({}) + assert isinstance(handle_errors, Callable) + + @app.use + def middleware(body, next): + assert body is not None + next() + + def next_func(): + pass + + middleware({}, next_func) + assert isinstance(middleware, Callable) + + finally: + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) diff --git a/tests/scenario_tests_async/test_app_decorators.py b/tests/scenario_tests_async/test_app_decorators.py new file mode 100644 index 000000000..15d02c09b --- /dev/null +++ b/tests/scenario_tests_async/test_app_decorators.py @@ -0,0 +1,110 @@ +from typing import Callable + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import BoltResponse +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +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 NoopAsyncAck(AsyncAck): + async def __call__(self) -> BoltResponse: + pass + + +class TestAppDecorators: + 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.mark.asyncio + async def test_decorators(self): + try: + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + app = AsyncApp(signing_secret=self.signing_secret, client=self.web_client) + ack = NoopAsyncAck() + + @app.event("app_home_opened") + async def handle_events(body: dict): + assert body is not None + + await handle_events({}) + assert isinstance(handle_events, Callable) + + @app.message("hi") + async def handle_message_events(body: dict): + assert body is not None + + await handle_message_events({}) + assert isinstance(handle_message_events, Callable) + + @app.command("/hello") + async def handle_commands(ack: AsyncAck, body: dict): + assert body is not None + await ack() + + await handle_commands(ack, {}) + assert isinstance(handle_commands, Callable) + + @app.shortcut("test-shortcut") + async def handle_shortcuts(ack: AsyncAck, body: dict): + assert body is not None + await ack() + + await handle_shortcuts(ack, {}) + assert isinstance(handle_shortcuts, Callable) + + @app.action("some-action-id") + async def handle_actions(ack: AsyncAck, body: dict): + assert body is not None + await ack() + + await handle_actions(ack, {}) + assert isinstance(handle_actions, Callable) + + @app.view("some-callback-id") + async def handle_views(ack: AsyncAck, body: dict): + assert body is not None + await ack() + + await handle_views(ack, {}) + assert isinstance(handle_views, Callable) + + @app.options("some-id") + async def handle_views(ack: AsyncAck, body: dict): + assert body is not None + await ack() + + await handle_views(ack, {}) + assert isinstance(handle_views, Callable) + + @app.error + async def handle_errors(body: dict): + assert body is not None + + await handle_errors({}) + assert isinstance(handle_errors, Callable) + + @app.use + async def middleware(body, next): + assert body is not None + await next() + + async def next_func(): + pass + + await middleware({}, next_func) + assert isinstance(middleware, Callable) + + finally: + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) From b4bc3e133415694f5d1f8cc3e6080b2003ce6412 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 4 Oct 2020 20:49:05 +0900 Subject: [PATCH 136/865] version 0.9.1b0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index a31c42706..83eb6f1ee 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.9.0b0" +__version__ = "0.9.1b0" From afec102bbea7d75762b1c528c21589103edda7ce Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 6 Oct 2020 19:48:31 +0900 Subject: [PATCH 137/865] slack_sdk v3.0.0b0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 47c8f6fc5..8f35239e4 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.0.0a9",], + install_requires=["slack_sdk>=3.0.0b0",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", From 0cb7bbba31cd00948c6d8617d96967b1396b8b87 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 6 Oct 2020 19:49:19 +0900 Subject: [PATCH 138/865] Add more fields to context for Bolt JS compatibility --- slack_bolt/authorization/authorize_result.py | 14 +++- slack_bolt/context/base_context.py | 39 +++++++++-- .../async_multi_teams_authorization.py | 2 +- .../async_single_team_authorization.py | 18 ++--- .../multi_teams_authorization.py | 2 +- .../single_team_authorization.py | 10 +-- tests/mock_web_api_server.py | 27 +++++++- tests/scenario_tests/test_authorize.py | 65 +++++++++++++++++- tests/scenario_tests/test_block_actions.py | 10 ++- tests/scenario_tests_async/test_authorize.py | 67 ++++++++++++++++++- .../test_block_actions.py | 9 ++- 11 files changed, 232 insertions(+), 31 deletions(-) diff --git a/slack_bolt/authorization/authorize_result.py b/slack_bolt/authorization/authorize_result.py index f38fbdf92..0a80a3d7a 100644 --- a/slack_bolt/authorization/authorize_result.py +++ b/slack_bolt/authorization/authorize_result.py @@ -53,12 +53,22 @@ def from_auth_test_response( user_token: Optional[str] = None, auth_test_response: SlackResponse, ) -> "AuthorizeResult": + bot_user_id: Optional[str] = ( # type:ignore + auth_test_response.get("user_id") + if auth_test_response.get("bot_id") is not None + else None + ) + user_id: Optional[str] = ( # type:ignore + auth_test_response.get("user_id") + if auth_test_response.get("bot_id") is None + else None + ) return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), - bot_user_id=auth_test_response.get("user_id"), bot_id=auth_test_response.get("bot_id"), - user_id=auth_test_response.get("user_id"), + bot_user_id=bot_user_id, + user_id=user_id, bot_token=bot_token, user_token=user_token, ) diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 6f21cd7bd..39468b922 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -5,10 +5,6 @@ class BaseContext(dict): - @property - def authorize_result(self) -> Optional[AuthorizeResult]: - return self.get("authorize_result") - @property def logger(self) -> Logger: return self["logger"] @@ -41,3 +37,38 @@ def response_url(self) -> Optional[str]: def matches(self) -> Optional[Tuple]: """Returns all the matched parts in message listener's regexp""" return self.get("matches") + + # -------------------------------- + + @property + def authorize_result(self) -> Optional[AuthorizeResult]: + return self.get("authorize_result") + + @property + def bot_token(self) -> Optional[str]: + return self.get("bot_token") + + @property + def bot_id(self) -> Optional[str]: + return self.get("bot_id") + + @property + def bot_user_id(self) -> Optional[str]: + return self.get("bot_user_id") + + @property + def user_token(self) -> Optional[str]: + return self.get("user_token") + + def set_authorize_result(self, authorize_result: AuthorizeResult): + self["authorize_result"] = authorize_result + if authorize_result.bot_id is not None: + self["bot_id"] = authorize_result.bot_id + if authorize_result.bot_user_id is not None: + self["bot_user_id"] = authorize_result.bot_user_id + if authorize_result.bot_token is not None: + self["bot_token"] = authorize_result.bot_token + if authorize_result.user_id is not None: + self["user_id"] = authorize_result.user_id + if authorize_result.user_token is not None: + self["user_token"] = authorize_result.user_token diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index 2dbac0a90..840a5fc0a 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -39,7 +39,7 @@ async def async_process( user_id=req.context.user_id, ) if auth_result: - req.context["authorize_result"] = auth_result + req.context.set_authorize_result(auth_result) token = auth_result.bot_token or auth_result.user_token req.context["token"] = token req.context["client"] = create_async_web_client(token) diff --git a/slack_bolt/middleware/authorization/async_single_team_authorization.py b/slack_bolt/middleware/authorization/async_single_team_authorization.py index 5ca888dc1..8a307d3da 100644 --- a/slack_bolt/middleware/authorization/async_single_team_authorization.py +++ b/slack_bolt/middleware/authorization/async_single_team_authorization.py @@ -13,7 +13,7 @@ class AsyncSingleTeamAuthorization(AsyncAuthorization): def __init__(self): """Single-workspace authorization.""" - self.auth_result: Optional[AsyncSlackResponse] = None + self.auth_test_result: Optional[AsyncSlackResponse] = None self.logger = get_bolt_logger(AsyncSingleTeamAuthorization) async def async_process( @@ -27,14 +27,16 @@ async def async_process( return await next() try: - if self.auth_result is None: - self.auth_result = await req.context.client.auth_test() + if self.auth_test_result is None: + self.auth_test_result = await req.context.client.auth_test() - if self.auth_result: - req.context["authorize_result"] = _to_authorize_result( - auth_test_result=self.auth_result, - token=req.context.client.token, - request_user_id=req.context.user_id, + if self.auth_test_result: + req.context.set_authorize_result( + _to_authorize_result( + auth_test_result=self.auth_test_result, + token=req.context.client.token, + request_user_id=req.context.user_id, + ) ) return await next() else: diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index 9d1bc578c..7efa8ae8c 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -37,7 +37,7 @@ def process( user_id=req.context.user_id, ) if auth_result is not None: - req.context["authorize_result"] = auth_result + req.context.set_authorize_result(auth_result) token = auth_result.bot_token or auth_result.user_token req.context["token"] = token req.context["client"] = create_web_client(token) diff --git a/slack_bolt/middleware/authorization/single_team_authorization.py b/slack_bolt/middleware/authorization/single_team_authorization.py index f59e4414e..8506ebff0 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -33,10 +33,12 @@ def process( self.auth_test_result = req.context.client.auth_test() if self.auth_test_result: - req.context["authorize_result"] = _to_authorize_result( - auth_test_result=self.auth_test_result, - token=req.context.client.token, - request_user_id=req.context.user_id, + req.context.set_authorize_result( + _to_authorize_result( + auth_test_result=self.auth_test_result, + token=req.context.client.token, + request_user_id=req.context.user_id, + ) ) return next() else: diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index 31fcf2ffc..41c1d09c5 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -19,6 +19,11 @@ def is_valid_token(self): self.headers["Authorization"] ).startswith("Bearer xoxb-") + def is_valid_user_token(self): + return "Authorization" in self.headers and str( + self.headers["Authorization"] + ).startswith("Bearer xoxp-") + def set_common_headers(self): self.send_header("content-type", "application/json;charset=utf-8") self.send_header("connection", "close") @@ -53,7 +58,7 @@ def set_common_headers(self): } } """ - auth_test_response = """ + bot_auth_test_response = """ { "ok": true, "url": "https://subarachnoid.slack.com/", @@ -63,6 +68,17 @@ def set_common_headers(self): "user_id": "W23456789", "bot_id": "BZYBOTHED" } +""" + + user_auth_test_response = """ +{ + "ok": true, + "url": "https://subarachnoid.slack.com/", + "team": "Subarachnoid Workspace", + "user": "some-user", + "team_id": "T0G9PQBBK", + "user_id": "W99999" +} """ def _handle(self): @@ -75,13 +91,20 @@ def _handle(self): self.wfile.write(self.oauth_v2_access_response.encode("utf-8")) return + if self.is_valid_user_token(): + if self.path == "/auth.test": + self.send_response(200) + self.set_common_headers() + self.wfile.write(self.user_auth_test_response.encode("utf-8")) + return + if self.is_valid_token(): parsed_path = urlparse(self.path) if self.path == "/auth.test": self.send_response(200) self.set_common_headers() - self.wfile.write(self.auth_test_response.encode("utf-8")) + self.wfile.write(self.bot_auth_test_response.encode("utf-8")) return len_header = self.headers.get("Content-Length") or 0 diff --git a/tests/scenario_tests/test_authorize.py b/tests/scenario_tests/test_authorize.py index 4bf924217..86bebef7d 100644 --- a/tests/scenario_tests/test_authorize.py +++ b/tests/scenario_tests/test_authorize.py @@ -15,22 +15,33 @@ from tests.utils import remove_os_env_temporarily, restore_os_env valid_token = "xoxb-valid" +valid_user_token = "xoxp-valid" def authorize(enterprise_id, team_id, user_id, client: WebClient): assert enterprise_id == "E111" assert team_id == "T111" - assert user_id == "W111" + assert user_id == "W99999" auth_test = client.auth_test(token=valid_token) return AuthorizeResult.from_auth_test_response( auth_test_response=auth_test, bot_token=valid_token, ) +def user_authorize(enterprise_id, team_id, user_id, client: WebClient): + assert enterprise_id == "E111" + assert team_id == "T111" + assert user_id == "W99999" + auth_test = client.auth_test(token=valid_user_token) + return AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test, user_token=valid_user_token, + ) + + def error_authorize(enterprise_id, team_id, user_id): assert enterprise_id == "E111" assert team_id == "T111" - assert user_id == "W111" + assert user_id == "W99999" return None @@ -94,11 +105,39 @@ def test_failure(self): assert response.body == ":x: Please install this app into the workspace :bow:" assert self.mock_received_requests.get("/auth.test") == None + def test_bot_context_attributes(self): + app = App( + client=self.web_client, + authorize=authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(assert_bot_context_attributes) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert self.mock_received_requests["/auth.test"] == 1 + + def test_user_context_attributes(self): + app = App( + client=self.web_client, + authorize=user_authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(assert_user_context_attributes) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert self.mock_received_requests["/auth.test"] == 1 + body = { "type": "block_actions", "user": { - "id": "W111", + "id": "W99999", "username": "primary-owner", "name": "primary-owner", "team_id": "T111", @@ -141,3 +180,23 @@ def simple_listener(ack, body, payload, action): assert payload == action assert action["action_id"] == "a" ack() + + +def assert_bot_context_attributes(ack, context): + assert context["bot_id"] == "BZYBOTHED" + assert context["bot_user_id"] == "W23456789" + assert context["bot_token"] == "xoxb-valid" + assert context["token"] == "xoxb-valid" + assert context["user_id"] == "W99999" + assert context.get("user_token") is None + ack() + + +def assert_user_context_attributes(ack, context): + assert context.get("bot_id") is None + assert context.get("bot_user_id") is None + assert context.get("bot_token") is None + assert context["token"] == "xoxp-valid" + assert context["user_id"] == "W99999" + assert context["user_token"] == "xoxp-valid" + ack() diff --git a/tests/scenario_tests/test_block_actions.py b/tests/scenario_tests/test_block_actions.py index 9aaefa7fe..bfe851c6f 100644 --- a/tests/scenario_tests/test_block_actions.py +++ b/tests/scenario_tests/test_block_actions.py @@ -5,7 +5,7 @@ from slack_sdk import WebClient from slack_sdk.signature import SignatureVerifier -from slack_bolt import BoltRequest +from slack_bolt import BoltRequest, BoltContext from slack_bolt.app import App from tests.mock_web_api_server import ( setup_mock_web_api_server, @@ -174,9 +174,15 @@ def test_failure_2(self): raw_body = f"payload={quote(json.dumps(body))}" -def simple_listener(ack, body, payload, action): +def simple_listener(ack, body, payload, action, context: BoltContext): assert body["trigger_id"] == "111.222.valid" assert body["actions"][0] == payload assert payload == action assert action["action_id"] == "a" + assert context.bot_id == "BZYBOTHED" + assert context.bot_user_id == "W23456789" + assert context.bot_token == "xoxb-valid" + assert context.token == "xoxb-valid" + assert context.user_id == "W111" + assert context.user_token is None ack() diff --git a/tests/scenario_tests_async/test_authorize.py b/tests/scenario_tests_async/test_authorize.py index c24e506b4..83e7d45a3 100644 --- a/tests/scenario_tests_async/test_authorize.py +++ b/tests/scenario_tests_async/test_authorize.py @@ -17,22 +17,33 @@ from tests.utils import remove_os_env_temporarily, restore_os_env valid_token = "xoxb-valid" +valid_user_token = "xoxp-valid" async def authorize(enterprise_id, team_id, user_id, client: AsyncWebClient): assert enterprise_id == "E111" assert team_id == "T111" - assert user_id == "W111" + assert user_id == "W99999" auth_test = await client.auth_test(token=valid_token) return AuthorizeResult.from_auth_test_response( auth_test_response=auth_test, bot_token=valid_token, ) +async def user_authorize(enterprise_id, team_id, user_id, client: AsyncWebClient): + assert enterprise_id == "E111" + assert team_id == "T111" + assert user_id == "W99999" + auth_test = await client.auth_test(token=valid_user_token) + return AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test, user_token=valid_user_token, + ) + + async def error_authorize(enterprise_id, team_id, user_id): assert enterprise_id == "E111" assert team_id == "T111" - assert user_id == "W111" + assert user_id == "W99999" return None @@ -102,11 +113,41 @@ async def test_failure(self): assert response.body == ":x: Please install this app into the workspace :bow:" assert self.mock_received_requests.get("/auth.test") == None + @pytest.mark.asyncio + async def test_bot_context_attributes(self): + app = AsyncApp( + client=self.web_client, + authorize=authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(assert_bot_context_attributes) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + assert self.mock_received_requests["/auth.test"] == 1 + + @pytest.mark.asyncio + async def test_user_context_attributes(self): + app = AsyncApp( + client=self.web_client, + authorize=user_authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(assert_user_context_attributes) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + assert self.mock_received_requests["/auth.test"] == 1 + body = { "type": "block_actions", "user": { - "id": "W111", + "id": "W99999", "username": "primary-owner", "name": "primary-owner", "team_id": "T111", @@ -149,3 +190,23 @@ async def simple_listener(ack, body, payload, action): assert payload == action assert action["action_id"] == "a" await ack() + + +async def assert_bot_context_attributes(ack, context): + assert context["bot_id"] == "BZYBOTHED" + assert context["bot_user_id"] == "W23456789" + assert context["bot_token"] == "xoxb-valid" + assert context["token"] == "xoxb-valid" + assert context["user_id"] == "W99999" + assert context.get("user_token") is None + await ack() + + +async def assert_user_context_attributes(ack, context): + assert context.get("bot_id") is None + assert context.get("bot_user_id") is None + assert context.get("bot_token") is None + assert context["token"] == "xoxp-valid" + assert context["user_id"] == "W99999" + assert context["user_token"] == "xoxp-valid" + await ack() diff --git a/tests/scenario_tests_async/test_block_actions.py b/tests/scenario_tests_async/test_block_actions.py index 34e7da238..3eab11552 100644 --- a/tests/scenario_tests_async/test_block_actions.py +++ b/tests/scenario_tests_async/test_block_actions.py @@ -8,6 +8,7 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( setup_mock_web_api_server, @@ -203,9 +204,15 @@ async def test_failure_2(self): raw_body = f"payload={quote(json.dumps(body))}" -async def simple_listener(ack, body, payload, action): +async def simple_listener(ack, body, payload, action, context: AsyncBoltContext): assert body["trigger_id"] == "111.222.valid" assert body["actions"][0] == payload assert payload == action assert action["action_id"] == "a" + assert context.bot_id == "BZYBOTHED" + assert context.bot_user_id == "W23456789" + assert context.bot_token == "xoxb-valid" + assert context.token == "xoxb-valid" + assert context.user_id == "W111" + assert context.user_token is None await ack() From 3b9e48c16de7659f35af7c86d6148f80fec8ed66 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 6 Oct 2020 20:17:49 +0900 Subject: [PATCH 139/865] version 0.9.2b0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 83eb6f1ee..f8184dc12 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.9.1b0" +__version__ = "0.9.2b0" From 4da5efccec3d8bfc6aa55eabfb81a7e49c1c458f Mon Sep 17 00:00:00 2001 From: Shay DeWael Date: Tue, 6 Oct 2020 13:09:46 -0700 Subject: [PATCH 140/865] Revise README (#115) * Make README revisions` Co-authored-by: Michael Brooks Co-authored-by: Kazuhiro Sera --- README.md | 139 +++++++++++++++++++++++++++--------------------------- 1 file changed, 69 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index bb91bff44..b64218451 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# Bolt for Python +# Bolt ![Bolt logo](docs/assets/bolt-logo.svg) for Python (beta) [![Python Version][python-version]][pypi-url] [![pypi package][pypi-image]][pypi-url] [![Build Status][travis-image]][travis-url] [![Codecov][codecov-image]][codecov-url] -A Python framework to build Slack apps in a flash with the latest platform features. Check the [document](https://slack.dev/bolt-python/) and [examples](https://github.com/slackapi/bolt-python/tree/main/examples) to know how to use this framework. +A Python framework to build Slack apps in a flash with the latest platform features. Read the [getting started guide](https://slack.dev/bolt-python/tutorial/getting-started) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. ## Setup @@ -18,9 +18,9 @@ pip install -U pip pip install slack_bolt ``` -## First Bolt App (app.py) +## Creating an app -Create an app by calling a constructor, which is a top-level export. +Create a Bolt for Python app by calling a constructor, which is a top-level export. If you'd prefer, you can create an [async app](#creating-an-async-app). ```python import logging @@ -32,59 +32,13 @@ from slack_bolt import App # export SLACK_BOT_TOKEN=xoxb-*** app = App() -# Events API: https://api.slack.com/events-api -@app.event("app_mention") -def event_test(say): - say("What's up?") - -# Interactivity: https://api.slack.com/interactivity -@app.shortcut("callback-id-here") -# @app.command("/hello-bolt-python") -def open_modal(ack, client, logger, body): - # acknowledge the incoming request from Slack immediately - ack() - # open a modal - api_response = client.views_open( - trigger_id=body["trigger_id"], - view={ - "type": "modal", - "callback_id": "view-id", - "title": { - "type": "plain_text", - "text": "My App", - }, - "submit": { - "type": "plain_text", - "text": "Submit", - }, - "blocks": [ - { - "type": "input", - "block_id": "b", - "element": { - "type": "plain_text_input", - "action_id": "a" - }, - "label": { - "type": "plain_text", - "text": "Label", - } - } - ] - }) - logger.debug(api_response) - -@app.view("view-id") -def view_submission(ack, view, logger): - ack() - # Prints {'b': {'a': {'type': 'plain_text_input', 'value': 'Your Input'}}} - logger.info(view["state"]["values"]) +# Add functionality here if __name__ == "__main__": app.start(3000) # POST http://localhost:3000/slack/events ``` -## Run the Bolt App +## Running an app ```bash export SLACK_SIGNING_SECRET=*** @@ -95,9 +49,61 @@ python app.py ngrok http 3000 ``` -## AsyncApp Setup +## Listening for events +Apps typically react to a collection of incoming events, which can correspond to [Events API events](https://api.slack.com/events-api), [actions](https://api.slack.com/interactivity/components), [shortcuts](https://api.slack.com/interactivity/shortcuts), [slash commands](https://api.slack.com/interactivity/slash-commands) or [options requests](https://api.slack.com/reference/block-kit/block-elements#external_select). For each type of +request, there's a method to build a listener function. + +```python +# Listen for an event from the Events API +app.event(event_type, fn) + +# Convenience method to listen to only `message` events using a string or re.Pattern +app.message([pattern ,] fn) + +# Listen for an action from a Block Kit element (buttons, select menus, date pickers, etc) +app.action(action_id, fn) + +# Listen for dialog submissions +app.action({"callback_id": callbackId}, fn) + +# Listen for a global or message shortcuts +app.shortcut(callback_id, fn) + +# Listen for slash commands +app.command(command_name, fn) + +# Listen for view_submission modal events +app.view(callback_id, fn) + +# Listen for options requests (from select menus with an external data source) +app.options(action_id, fn) +``` + +The recommended way to use these methods are decorators: + +```python +@app.event(event_type) +def handle_event(event): + pass +``` + +## Making things happen + +Most of the app's functionality will be inside listener functions (the `fn` parameters above). These functions are called with a set of arguments. + +| Argument | Description | +| :---: | :--- | +| `payload` | Contents of the incoming event. The payload structure depends on the listener. For example, for an Events API event, `payload` will be the [event type structure](https://api.slack.com/events-api#event_type_structure). For a block action, it will be the action from within the `actions` list. The `payload` dictionary is also accessible via the alias corresponding to the listener (`message`, `event`, `action`, `shortcut`, `view`, `command`, or `options`). For example, if you were building a `message()` listener, you could use the `payload` and `message` arguments interchangably. **An easy way to understand what's in a payload is to log it**. | +| `say` | Utility function to send a message to the channel associated with the incoming event. This argument is only available when the listener is triggered for events that contain a `channel_id` (the most common being `message` events). `say` accepts simple strings (for plain-text messages) and dictionaries (for messages containing blocks). +| `ack` | Function that **must** be called to acknowledge that your app received the incoming event. `ack` exists for all actions, shortcuts, view submissions, slash command and options requests. `ack` returns a promise that resolves when complete. Read more in [Acknowledging events](https://slack.dev/bolt-python/concepts#acknowledge). +| `client` | Web API client that uses the token associated with the event. For single-workspace installations, the token is provided to the constructor. For multi-workspace installations, the token is returned by using [the OAuth library](https://slack.dev/bolt-python/concepts#authenticating-oauth), or manually using the `authorize` function. +| `respond` | Utility function that responds to incoming events **if** it contains a `response_url` (shortcuts, actions, and slash commands). +| `context` | Event context. This dictionary contains data about the event and app, such as the `botId`. Middleware can add additional context before the event is passed to listeners. +| `body` | Dictionary that contains the entire body of the request (superset of `payload`). Some accessory data is only available outside of the payload (such as `trigger_id` and `authed_users`). + +## Creating an async app -If you prefer building Slack apps using [asyncio](https://docs.python.org/3/library/asyncio.html), you can go with `AsyncApp` instead. You can use async/await style for everything in the app. To use `AsyncApp`, [AIOHTTP](https://docs.aiohttp.org/en/stable/) library is required for asynchronous Slack Web API calls and the default web server. +If you'd prefer to build your app with [asyncio](https://docs.python.org/3/library/asyncio.html), you can import the [AIOHTTP](https://docs.aiohttp.org/en/stable/) library and call the `AsyncApp` constructor. Within async apps, you can use the async/await pattern. ```bash # Python 3.6+ required @@ -109,9 +115,10 @@ pip install -U pip pip install slack_bolt aiohttp ``` -Import `slack_bolt.async_app.AsyncApp` instead of `slack_bolt.App`. All middleware/listeners must be async functions. Inside the functions, all utility methods such as `ack`, `say`, and `respond` requires `await` keyword. +In async apps, all middleware/listeners must be async functions. When calling utility methods (like `ack` and `say`) within these functions, it's required to use the `await` keyword. ```python +# Import the async app instead of the regular one from slack_bolt.async_app import AsyncApp app = AsyncApp() @@ -130,29 +137,21 @@ if __name__ == "__main__": app.start(3000) ``` -Starting the app is exactly the same with the way using `slack_bolt.App`. - -```bash -export SLACK_SIGNING_SECRET=*** -export SLACK_BOT_TOKEN=xoxb-*** -python app.py - -# in another terminal -ngrok http 3000 -``` - If you want to use another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at the built-in adapters and their examples. * [The Bolt app examples](https://github.com/slackapi/bolt-python/tree/main/examples) * [The built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) +Apps can be run the same way as the syncronous example above. If you'd prefer another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at [the built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) and their corresponding [sample code](https://github.com/slackapi/bolt-python/tree/main/samples). + +## Getting Help -# Feedback +[The documentation](https://slack.dev/bolt-python) has more information on basic and advanced concepts for Bolt for Python. -We are keen to hear your feedback. Please feel free to [submit an issue](https://github.com/slackapi/bolt-python/issues)! +If you otherwise get stuck, we're here to help. The following are the best ways to get assistance working through your issue: -# License + * [Issue Tracker](http://github.com/slackapi/bolt-python/issues) for questions, bug reports, feature requests, and general discussion related to Bolt for Python. Try searching for an existing issue before creating a new one. + * [Email](mailto:support@slack.com) our developer support team: `support@slack.com` -The MIT License [pypi-image]: https://badge.fury.io/py/slack-bolt.svg [pypi-url]: https://pypi.org/project/slack-bolt/ From ef3f2fd13c6418217b97bb8c9af2608bf5c09bc5 Mon Sep 17 00:00:00 2001 From: Shay DeWael Date: Thu, 8 Oct 2020 12:09:30 -0700 Subject: [PATCH 141/865] fix svg (#117) --- docs/assets/bolt-py-logo.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/assets/bolt-py-logo.svg b/docs/assets/bolt-py-logo.svg index c434147aa..1dcab5261 100644 --- a/docs/assets/bolt-py-logo.svg +++ b/docs/assets/bolt-py-logo.svg @@ -1 +1 @@ -PY \ No newline at end of file + \ No newline at end of file From 171175c3d6e930c966810e78ad48609d70878561 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 9 Oct 2020 14:29:51 +0900 Subject: [PATCH 142/865] Fix minor errors in documents --- docs/_advanced/adapters.md | 2 +- docs/_advanced/authorization.md | 6 +++--- docs/_advanced/custom_adapters.md | 2 +- docs/_advanced/lazy_listener.md | 2 +- docs/_basic/authenticating_oauth.md | 9 ++++++++- docs/_basic/listening_messages.md | 4 +++- 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/_advanced/adapters.md b/docs/_advanced/adapters.md index 65f9367c3..8ff882898 100644 --- a/docs/_advanced/adapters.md +++ b/docs/_advanced/adapters.md @@ -10,7 +10,7 @@ Adapters are responsible for handling and parsing incoming events from Slack to By default, Bolt will use the built-in `HTTPSever` adapter. While this is okay for local development, it is not recommended for production. Bolt for Python includes a collection of built-in adapters that can be imported and used with your app. The built-in adapters support a variety of popular Python frameworks including Flask, Django, and Starlette among others. Adapters support the use of any production-ready web server of your choice. -To use an adapter, you'll create an app with the framework of your choosing and import its corresponding adapter. Then you'll initalize the adapter instance and call its function that handles and parses incoming events. +To use an adapter, you'll create an app with the framework of your choosing and import its corresponding adapter. Then you'll initialize the adapter instance and call its function that handles and parses incoming events. The full list adapters, as well as configuration and sample usage, can be found within the repository's `examples` folder. diff --git a/docs/_advanced/authorization.md b/docs/_advanced/authorization.md index 66cd48d5b..0a15c8a35 100644 --- a/docs/_advanced/authorization.md +++ b/docs/_advanced/authorization.md @@ -22,8 +22,8 @@ For a more custom solution, you can set the `authorize` parameter to a function ```python import os from slack_bolt import App -# Import the AuthorizationResult class -from slack_bolt.authorization import AuthorizationResult +# Import the AuthorizeResult class +from slack_bolt.authorization import AuthorizeResult # This is just an example (assumes there are no user tokens) # You should store authorizations in a secure DB @@ -46,7 +46,7 @@ installations = [ def authorize(enterprise_id, team_id, logger): # You can implement your own logic to fetch token here - for (team in installations): + for team in installations: # enterprise_id doesn't exist for some teams is_valid_enterprise = True if (("enterprise_id" not in team) or (enterprise_id == team["enterprise_id"])) else False if ((is_valid_enterprise == True) and (team["team_id"] == team_id)): diff --git a/docs/_advanced/custom_adapters.md b/docs/_advanced/custom_adapters.md index b15a36dc8..f06af77c1 100644 --- a/docs/_advanced/custom_adapters.md +++ b/docs/_advanced/custom_adapters.md @@ -18,7 +18,7 @@ order: 1 | `body: str` | The raw request body | **Yes** | | `query: any` | The query string data | No | | `headers: Dict[str, Union[str, List[str]]]` | Request headers | No | -| `context: Dict[str, str]` | Any context for the request | No | +| `context: BoltContext` | Any context for the request | No | `BoltRequest` will return [an instance of `BoltResponse`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/response/response.py) from the Bolt app. diff --git a/docs/_advanced/lazy_listener.md b/docs/_advanced/lazy_listener.md index f8e9702f4..c201743a2 100644 --- a/docs/_advanced/lazy_listener.md +++ b/docs/_advanced/lazy_listener.md @@ -12,7 +12,7 @@ Typically you'd call `ack()` as the first step of your listener functions. Calli However, apps running on FaaS or similar runtimes that don't allow you to run threads or processes after returning an HTTP response cannot follow this pattern. Instead, you should set the `process_before_response` flag to `True`. This allows you to create a listener that calls `ack()` and handles the event safely, though you still need to complete everything within 3 seconds. For events, while a listener doesn't need `ack()` method call as you normally would, the listener needs to complete within 3 seconds, too. -Rather than acting as a decorator, lazy listeners take two keyword args: +Lazy listeners can be a solution for this issue. Rather than acting as a decorator, lazy listeners take two keyword args: * `ack: Callable`: Responsible for calling `ack()` * `lazy: List[Callable]`: Responsible for handling any time-consuming processes related to the event. The lazy function does not have access to `ack()`. diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md index 82630e551..0de17ffe9 100644 --- a/docs/_basic/authenticating_oauth.md +++ b/docs/_basic/authenticating_oauth.md @@ -19,6 +19,7 @@ To learn more about the OAuth installation flow with Slack, [read the API docume ```python import os +from slack_bolt import App from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore @@ -55,7 +56,7 @@ You can override the default OAuth using `oauth_settings`, which can be passed i ```python from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs -import slack_bolt.response.BoltResponse +from slack_bolt.response import BoltResponse def success(args: SuccessArgs) -> BoltResponse: assert args.request is not None @@ -74,6 +75,12 @@ def failure(args: FailureArgs) -> BoltResponse: callback_options = CallbackOptions(success=success, failure=failure) +import os +from slack_bolt import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + app = App( signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), installation_store=FileInstallationStore(base_dir="./data"), diff --git a/docs/_basic/listening_messages.md b/docs/_basic/listening_messages.md index 22b777ab3..34c30e180 100644 --- a/docs/_basic/listening_messages.md +++ b/docs/_basic/listening_messages.md @@ -7,7 +7,7 @@ order: 1
    -To listen to messages that [your app has access to receive](https://api.slack.com/messaging/retrieving#permissions), you can use the `message()` method which filters out events that aren’t of type `message`. +To listen to messages that [your app has access to receive](https://api.slack.com/messaging/retrieving#permissions), you can use the `message()` method which filters out events that aren't of type `message`. `message()` accepts an argument of type `str` or `re.Pattern` object that filters out any messages that don’t match the pattern. @@ -33,6 +33,8 @@ The `re.compile()` method can be used instead of a string for more granular matc
    ```python +import re + @app.message(re.compile("(hi|hello|hey)")) def say_hello_regex(say, context): # regular expression matches are inside of context.matches From 1f189bf53bc8434b39f4a2e1eb035d8068ec8bd8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 14 Oct 2020 09:24:25 +0900 Subject: [PATCH 143/865] Fix document errors --- docs/_steps/executing_workflow_steps.md | 4 +--- docs/_steps/saving_workflow_step.md | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/_steps/executing_workflow_steps.md b/docs/_steps/executing_workflow_steps.md index e7a489e34..b767112a0 100644 --- a/docs/_steps/executing_workflow_steps.md +++ b/docs/_steps/executing_workflow_steps.md @@ -26,9 +26,7 @@ def execute(step, complete, fail): complete(outputs=outputs) # if something went wrong - error = { - "message": "Just testing step failure!" - } + error = {"message": "Just testing step failure!"} fail(error=error) ws = WorkflowStep( diff --git a/docs/_steps/saving_workflow_step.md b/docs/_steps/saving_workflow_step.md index 55d10ecfc..34f02f6a4 100644 --- a/docs/_steps/saving_workflow_step.md +++ b/docs/_steps/saving_workflow_step.md @@ -29,8 +29,8 @@ def save(ack, view, update): task_description = values["task_description_input"]["description"] inputs = { - "task_name": {"value": task_name.value}, - "task_description": {"value": task_description.value} + "task_name": {"value": task_name["value"]}, + "task_description": {"value": task_description["value"]} } outputs = [ { From 0d8d0c4eb0d7025b5d18c787a030de5cbecfb2dc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 14 Oct 2020 09:24:40 +0900 Subject: [PATCH 144/865] Refactor Django example app --- examples/django/slackapp/models.py | 130 ++++++++++++++++++++++++++++- examples/django/slackapp/views.py | 115 +------------------------ 2 files changed, 130 insertions(+), 115 deletions(-) diff --git a/examples/django/slackapp/models.py b/examples/django/slackapp/models.py index d0a3ff3da..8b84beaac 100644 --- a/examples/django/slackapp/models.py +++ b/examples/django/slackapp/models.py @@ -1,5 +1,8 @@ -from django.db import models +# ---------------------- +# Database tables +# ---------------------- +from django.db import models class SlackBot(models.Model): client_id = models.TextField(null=False) @@ -54,3 +57,128 @@ class Meta: class SlackOAuthState(models.Model): state = models.TextField(null=False) expire_at = models.DateTimeField(null=False) + + +# ---------------------- +# Bolt store implementations +# ---------------------- + + +from logging import Logger +from typing import Optional +from uuid import uuid4 +from django.db.models import F +from django.utils import timezone +from slack_sdk.oauth import InstallationStore, OAuthStateStore +from slack_sdk.oauth.installation_store import Bot, Installation + + +class DjangoInstallationStore(InstallationStore): + client_id: str + + def __init__( + self, client_id: str, logger: Logger, + ): + self.client_id = client_id + self._logger = logger + + @property + def logger(self) -> Logger: + return self._logger + + def save(self, installation: Installation): + i = installation.to_dict() + i["client_id"] = self.client_id + SlackInstallation(**i).save() + b = installation.to_bot().to_dict() + b["client_id"] = self.client_id + SlackBot(**b).save() + + def find_bot( + self, *, enterprise_id: Optional[str], team_id: Optional[str] + ) -> Optional[Bot]: + rows = ( + SlackBot.objects.filter(enterprise_id=enterprise_id) + .filter(team_id=team_id) + .order_by(F("installed_at").desc())[:1] + ) + if len(rows) > 0: + b = rows[0] + return Bot( + app_id=b.app_id, + enterprise_id=b.enterprise_id, + team_id=b.team_id, + bot_token=b.bot_token, + bot_id=b.bot_id, + bot_user_id=b.bot_user_id, + bot_scopes=b.bot_scopes, + installed_at=b.installed_at.timestamp(), + ) + return None + + +class DjangoOAuthStateStore(OAuthStateStore): + expiration_seconds: int + + def __init__( + self, expiration_seconds: int, logger: Logger, + ): + self.expiration_seconds = expiration_seconds + self._logger = logger + + @property + def logger(self) -> Logger: + return self._logger + + def issue(self) -> str: + state: str = str(uuid4()) + expire_at = timezone.now() + timezone.timedelta(seconds=self.expiration_seconds) + row = SlackOAuthState(state=state, expire_at=expire_at) + row.save() + return state + + def consume(self, state: str) -> bool: + rows = SlackOAuthState.objects.filter(state=state).filter( + expire_at__gte=timezone.now() + ) + if len(rows) > 0: + for row in rows: + row.delete() + return True + return False + +# ---------------------- +# Slack App +# ---------------------- + +import logging +import os +from slack_bolt import App +from slack_bolt.oauth.oauth_settings import OAuthSettings + +logger = logging.getLogger(__name__) +client_id, client_secret, signing_secret = ( + os.environ["SLACK_CLIENT_ID"], + os.environ["SLACK_CLIENT_SECRET"], + os.environ["SLACK_SIGNING_SECRET"], +) + +app = App( + signing_secret=signing_secret, + installation_store=DjangoInstallationStore(client_id=client_id, logger=logger,), + oauth_settings=OAuthSettings( + client_id=client_id, + client_secret=client_secret, + state_store=DjangoOAuthStateStore(expiration_seconds=120, logger=logger,), + ), +) + + +@app.event("app_mention") +def event_test(body, say, logger): + logger.info(body) + say("What's up?") + +@app.command("/hello-bolt-python") +def command(ack): + ack("This is a Django app!") \ No newline at end of file diff --git a/examples/django/slackapp/views.py b/examples/django/slackapp/views.py index 9b1721130..5ff1c8a29 100644 --- a/examples/django/slackapp/views.py +++ b/examples/django/slackapp/views.py @@ -1,121 +1,8 @@ -import logging -import os -from logging import Logger -from typing import Optional -from uuid import uuid4 - -from django.db.models import F -from django.db.models.functions import Coalesce from django.http import HttpRequest -from django.utils import timezone from django.views.decorators.csrf import csrf_exempt -from slack_sdk.oauth import InstallationStore, OAuthStateStore -from slack_sdk.oauth.installation_store import Bot, Installation -from slack_bolt import App from slack_bolt.adapter.django import SlackRequestHandler -from slack_bolt.oauth.oauth_settings import OAuthSettings -from .models import SlackOAuthState, SlackBot, SlackInstallation - - -class DjangoInstallationStore(InstallationStore): - client_id: str - - def __init__( - self, client_id: str, logger: Logger, - ): - self.client_id = client_id - self._logger = logger - - @property - def logger(self) -> Logger: - return self._logger - - def save(self, installation: Installation): - i = installation.to_dict() - i["client_id"] = self.client_id - SlackInstallation(**i).save() - b = installation.to_bot().to_dict() - b["client_id"] = self.client_id - SlackBot(**b).save() - - def find_bot( - self, *, enterprise_id: Optional[str], team_id: Optional[str] - ) -> Optional[Bot]: - rows = ( - SlackBot.objects.filter(enterprise_id=enterprise_id) - .filter(team_id=team_id) - .order_by(F("installed_at").desc())[:1] - ) - if len(rows) > 0: - b = rows[0] - return Bot( - app_id=b.app_id, - enterprise_id=b.enterprise_id, - team_id=b.team_id, - bot_token=b.bot_token, - bot_id=b.bot_id, - bot_user_id=b.bot_user_id, - bot_scopes=b.bot_scopes, - installed_at=b.installed_at.timestamp(), - ) - return None - - -class DjangoOAuthStateStore(OAuthStateStore): - expiration_seconds: int - - def __init__( - self, expiration_seconds: int, logger: Logger, - ): - self.expiration_seconds = expiration_seconds - self._logger = logger - - @property - def logger(self) -> Logger: - return self._logger - - def issue(self) -> str: - state: str = str(uuid4()) - expire_at = timezone.now() + timezone.timedelta(seconds=self.expiration_seconds) - row = SlackOAuthState(state=state, expire_at=expire_at) - row.save() - return state - - def consume(self, state: str) -> bool: - rows = SlackOAuthState.objects.filter(state=state).filter( - expire_at__gte=timezone.now() - ) - if len(rows) > 0: - for row in rows: - row.delete() - return True - return False - - -logger = logging.getLogger(__name__) -client_id, client_secret, signing_secret = ( - os.environ["SLACK_CLIENT_ID"], - os.environ["SLACK_CLIENT_SECRET"], - os.environ["SLACK_SIGNING_SECRET"], -) - -app = App( - signing_secret=signing_secret, - installation_store=DjangoInstallationStore(client_id=client_id, logger=logger,), - oauth_settings=OAuthSettings( - client_id=client_id, - client_secret=client_secret, - state_store=DjangoOAuthStateStore(expiration_seconds=120, logger=logger,), - ), -) - - -@app.event("app_mention") -def event_test(body, say, logger): - logger.info(body) - say("What's up?") - +from .models import app handler = SlackRequestHandler(app=app) From 585067c3350350ba115e3376d3443dd0adb3ff38 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 16 Oct 2020 16:35:33 +0900 Subject: [PATCH 145/865] Fix missing support for AWS Lambda's lazy listeners in #103 --- slack_bolt/adapter/aws_lambda/chalice_handler.py | 4 +++- slack_bolt/adapter/aws_lambda/handler.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/slack_bolt/adapter/aws_lambda/chalice_handler.py b/slack_bolt/adapter/aws_lambda/chalice_handler.py index d835055fb..48d64ab3a 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_handler.py +++ b/slack_bolt/adapter/aws_lambda/chalice_handler.py @@ -18,7 +18,9 @@ def __init__(self, app: App, chalice: Chalice): # type: ignore self.app = app self.chalice = chalice self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler) - self.app.lazy_listener_runner = ChaliceLazyListenerRunner(logger=self.logger) + self.app.listener_runner.lazy_listener_runner = ChaliceLazyListenerRunner( + logger=self.logger + ) if self.app.oauth_flow is not None: self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?" diff --git a/slack_bolt/adapter/aws_lambda/handler.py b/slack_bolt/adapter/aws_lambda/handler.py index 0134c6011..b4033c3a2 100644 --- a/slack_bolt/adapter/aws_lambda/handler.py +++ b/slack_bolt/adapter/aws_lambda/handler.py @@ -15,7 +15,9 @@ class SlackRequestHandler: def __init__(self, app: App): # type: ignore self.app = app self.logger = get_bolt_app_logger(app.name, SlackRequestHandler) - self.app.lazy_listener_runner = LambdaLazyListenerRunner(self.logger) + self.app.listener_runner.lazy_listener_runner = LambdaLazyListenerRunner( + self.logger + ) if self.app.oauth_flow is not None: self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?" From c37b104d73e2fb8b53f665151f465e3c0c1b6b3e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 16 Oct 2020 16:38:13 +0900 Subject: [PATCH 146/865] version 0.9.3b0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index f8184dc12..2f8ab2956 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.9.2b0" +__version__ = "0.9.3b0" From d2c1947e09f842d459f7fb56fe76e838b79d1fb2 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 21 Oct 2020 18:58:35 +0900 Subject: [PATCH 147/865] Fix a bug where LambdaS3OAuthFlow does not work when settings arg is given --- slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py index 66ac7e155..c87b4a59c 100644 --- a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py +++ b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py @@ -5,6 +5,7 @@ import boto3 +from slack_bolt.authorization.authorize import InstallationStoreAuthorize from slack_bolt.oauth import OAuthFlow from slack_sdk import WebClient from slack_sdk.oauth.installation_store.amazon_s3 import AmazonS3InstallationStore @@ -55,6 +56,16 @@ def __init__( bucket_name=installation_bucket_name, client_id=settings.client_id, ) + + # Set up authorize function to surely use this installation_store. + # When a developer use a settings initialized outside this constructor, + # the settings may already have pre-defined authorize. + # In this case, the /slack/events endpoint doesn't work along with the OAuth flow. + settings.authorize = InstallationStoreAuthorize( + logger=logger, + installation_store=settings.installation_store + ) + OAuthFlow.__init__(self, client=client, logger=logger, settings=settings) @property From bf428d096b9fcfed377953a30ba0a7dbd130e4cf Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 21 Oct 2020 19:19:28 +0900 Subject: [PATCH 148/865] Fix #118 by adding allowlist of events in ignore self middleware --- .../ignoring_self_events.py | 10 +- tests/scenario_tests/test_events.py | 174 +++++++++++++++++ tests/scenario_tests_async/test_events.py | 176 ++++++++++++++++++ 3 files changed, 358 insertions(+), 2 deletions(-) diff --git a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py index 0c42141a3..d24cedb9f 100644 --- a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -25,14 +25,20 @@ def process( # ----------------------------------------- - @staticmethod + # Its an Events API event that isn't of type message, + # but the user ID might match our own app. Filter these out. + # However, some events still must be fired, because they can make sense. + events_that_should_be_kept = ["member_joined_channel", "member_left_channel"] + + @classmethod def _is_self_event( - auth_result: AuthorizeResult, user_id: str, body: Dict[str, Any] + cls, auth_result: AuthorizeResult, user_id: str, body: Dict[str, Any] ): return ( auth_result is not None and user_id == auth_result.bot_user_id and body.get("event") is not None + and body.get("event").get("type") not in cls.events_that_should_be_kept ) def _debug_log(self, body: dict): diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index 366490571..e3c8d89eb 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -161,3 +161,177 @@ def handle_app_mention(): ) response = app.dispatch(request) assert response.status == 200 + + def test_self_events(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("reaction_added") + def handle_app_mention(say): + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + # The listener should not be executed + assert self.mock_received_requests.get("/chat.postMessage") is None + + def test_self_member_join_left_events(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + def handle_member_joined_channel(say): + say("What's up?") + + @app.event("member_left_channel") + def handle_member_left_channel(say): + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 2 + + def test_member_join_left_events(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "U999", # not self + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "U999", # not self + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + def handle_app_mention(say): + say("What's up?") + + @app.event("member_left_channel") + def handle_app_mention(say): + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + + sleep(1) # wait a bit after auto ack() + # the listeners should not be executed + assert self.mock_received_requests["/chat.postMessage"] == 2 diff --git a/tests/scenario_tests_async/test_events.py b/tests/scenario_tests_async/test_events.py index 66322cfa2..9cf1e2033 100644 --- a/tests/scenario_tests_async/test_events.py +++ b/tests/scenario_tests_async/test_events.py @@ -139,6 +139,182 @@ async def test_stable_auto_ack(self): response = await app.async_dispatch(request) assert response.status == 200 + @pytest.mark.asyncio + async def test_self_events(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.event("reaction_added")(whats_up) + + self_event = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(self_event) + request = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + # The listener should not be executed + assert self.mock_received_requests.get("/chat.postMessage") is None + + @pytest.mark.asyncio + async def test_self_joined_left_events(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.event("reaction_added")(whats_up) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + async def handle_member_joined_channel(say): + await say("What's up?") + + @app.event("member_left_channel") + async def handle_member_left_channel(say): + await say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + await asyncio.sleep(1) # wait a bit after auto ack() + # The listeners should be executed + assert self.mock_received_requests.get("/chat.postMessage") == 2 + + @pytest.mark.asyncio + async def test_joined_left_events(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app.event("reaction_added")(whats_up) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W111", # other user + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W111", # other user + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + async def handle_member_joined_channel(say): + await say("What's up?") + + @app.event("member_left_channel") + async def handle_member_left_channel(say): + await say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + await asyncio.sleep(1) # wait a bit after auto ack() + # The listeners should be executed + assert self.mock_received_requests.get("/chat.postMessage") == 2 + app_mention_body = { "token": "verification_token", From a9fe1017a919867b5c6195815c1f6ee20a93853c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 22 Oct 2020 05:45:12 +0900 Subject: [PATCH 149/865] Satisfy pytype --- slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py | 3 +-- .../middleware/ignoring_self_events/ignoring_self_events.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py index c87b4a59c..1282e76f5 100644 --- a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py +++ b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py @@ -62,8 +62,7 @@ def __init__( # the settings may already have pre-defined authorize. # In this case, the /slack/events endpoint doesn't work along with the OAuth flow. settings.authorize = InstallationStoreAuthorize( - logger=logger, - installation_store=settings.installation_store + logger=logger, installation_store=settings.installation_store ) OAuthFlow.__init__(self, client=client, logger=logger, settings=settings) diff --git a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py index d24cedb9f..e0fcbe867 100644 --- a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -38,7 +38,7 @@ def _is_self_event( auth_result is not None and user_id == auth_result.bot_user_id and body.get("event") is not None - and body.get("event").get("type") not in cls.events_that_should_be_kept + and body.get("event", {}).get("type") not in cls.events_that_should_be_kept ) def _debug_log(self, body: dict): From 02ea23a2f8ab84107c0acd7331fe491530418971 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 22 Oct 2020 15:30:58 +0900 Subject: [PATCH 150/865] version 0.9.4b0 --- setup.py | 2 +- slack_bolt/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 8f35239e4..c9c166120 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.0.0b0",], + install_requires=["slack_sdk>=3.0.0b1",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 2f8ab2956..bc28f6ddc 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.9.3b0" +__version__ = "0.9.4b0" From 495e8e789d40722caff3efff92fbd384ced11629 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 22 Oct 2020 19:14:54 +0900 Subject: [PATCH 151/865] Correct Content-Length to be valid even for non-ascii content --- slack_bolt/oauth/internals.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/slack_bolt/oauth/internals.py b/slack_bolt/oauth/internals.py index 645f350a3..510f59bfb 100644 --- a/slack_bolt/oauth/internals.py +++ b/slack_bolt/oauth/internals.py @@ -35,7 +35,7 @@ def _build_callback_success_response( # type: ignore status=200, headers={ "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(html), + "Content-Length": len(bytes(html, "utf-8")), "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), }, body=html, @@ -59,7 +59,7 @@ def _build_callback_failure_response( # type: ignore status=status, headers={ "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(html), + "Content-Length": len(bytes(html, "utf-8")), "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), }, body=html, From 4ecce6b1283543b479aee162b20a8be70668d179 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 23 Oct 2020 07:23:40 +0900 Subject: [PATCH 152/865] Add cache_enabled option to InstallationStoreAuthorize --- slack_bolt/authorization/async_authorize.py | 16 ++- slack_bolt/authorization/authorize.py | 16 ++- tests/slack_bolt/authorization/__init__.py | 0 .../authorization/test_authorize.py | 92 +++++++++++++++ .../authorization/__init__.py | 0 .../authorization/test_async_authorize.py | 106 ++++++++++++++++++ 6 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 tests/slack_bolt/authorization/__init__.py create mode 100644 tests/slack_bolt/authorization/test_authorize.py create mode 100644 tests/slack_bolt_async/authorization/__init__.py create mode 100644 tests/slack_bolt_async/authorization/test_async_authorize.py diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index 86eb563cd..02a61ca07 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -91,11 +91,18 @@ async def __call__( class AsyncInstallationStoreAuthorize(AsyncAuthorize): + authorize_result_cache: Dict[str, AuthorizeResult] = {} + def __init__( - self, *, logger: Logger, installation_store: AsyncInstallationStore, + self, + *, + logger: Logger, + installation_store: AsyncInstallationStore, + cache_enabled: bool = False, ): self.logger = logger self.installation_store = installation_store + self.cache_enabled = cache_enabled async def __call__( self, @@ -115,13 +122,18 @@ async def __call__( ) return None + if self.cache_enabled and bot.bot_token in self.authorize_result_cache: + return self.authorize_result_cache[bot.bot_token] try: auth_result = await context.client.auth_test(token=bot.bot_token) - return AuthorizeResult.from_auth_test_response( + authorize_result = AuthorizeResult.from_auth_test_response( auth_test_response=auth_result, bot_token=bot.bot_token, user_token=None, # Not yet supported ) + if self.cache_enabled: + self.authorize_result_cache[bot.bot_token] = authorize_result + return authorize_result except SlackApiError as err: self.logger.debug( f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} " diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 47e923af4..0fea0eaab 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -89,11 +89,18 @@ def __call__( class InstallationStoreAuthorize(Authorize): + authorize_result_cache: Dict[str, AuthorizeResult] = {} + def __init__( - self, *, logger: Logger, installation_store: InstallationStore, + self, + *, + logger: Logger, + installation_store: InstallationStore, + cache_enabled: bool = False, ): self.logger = logger self.installation_store = installation_store + self.cache_enabled = cache_enabled def __call__( self, @@ -113,13 +120,18 @@ def __call__( ) return None + if self.cache_enabled and bot.bot_token in self.authorize_result_cache: + return self.authorize_result_cache[bot.bot_token] try: auth_result = context.client.auth_test(token=bot.bot_token) - return AuthorizeResult.from_auth_test_response( + authorize_result = AuthorizeResult.from_auth_test_response( auth_test_response=auth_result, bot_token=bot.bot_token, user_token=None, # Not yet supported ) + if self.cache_enabled: + self.authorize_result_cache[bot.bot_token] = authorize_result + return authorize_result except SlackApiError as err: self.logger.debug( f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} " diff --git a/tests/slack_bolt/authorization/__init__.py b/tests/slack_bolt/authorization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/authorization/test_authorize.py b/tests/slack_bolt/authorization/test_authorize.py new file mode 100644 index 000000000..600c3c626 --- /dev/null +++ b/tests/slack_bolt/authorization/test_authorize.py @@ -0,0 +1,92 @@ +import datetime +import logging +from logging import Logger +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Bot, Installation + +from slack_bolt import BoltContext +from slack_bolt.authorization.authorize import InstallationStoreAuthorize +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) + + +class TestAuthorize: + mock_api_server_base_url = "http://localhost:8888" + + def setup_method(self): + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_installation_store(self): + installation_store = MemoryInstallationStore() + authorize = InstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert self.mock_received_requests["/auth.test"] == 1 + + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert self.mock_received_requests["/auth.test"] == 2 + + def test_installation_store_cached(self): + installation_store = MemoryInstallationStore() + authorize = InstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + cache_enabled=True, + ) + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert self.mock_received_requests["/auth.test"] == 1 + + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert self.mock_received_requests["/auth.test"] == 1 # cached + + +class MemoryInstallationStore(InstallationStore): + @property + def logger(self) -> Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_bot( + self, *, enterprise_id: Optional[str], team_id: Optional[str] + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) diff --git a/tests/slack_bolt_async/authorization/__init__.py b/tests/slack_bolt_async/authorization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/authorization/test_async_authorize.py b/tests/slack_bolt_async/authorization/test_async_authorize.py new file mode 100644 index 000000000..30b2d2d92 --- /dev/null +++ b/tests/slack_bolt_async/authorization/test_async_authorize.py @@ -0,0 +1,106 @@ +import asyncio +import datetime +import logging +from logging import Logger +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store import Bot, Installation +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.authorization.async_authorize import AsyncInstallationStoreAuthorize +from slack_bolt.context.async_context import AsyncBoltContext +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 TestAsyncAuthorize: + mock_api_server_base_url = "http://localhost:8888" + client = AsyncWebClient(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_installation_store(self): + installation_store = MemoryInstallationStore() + authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + context = AsyncBoltContext() + context["client"] = self.client + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert self.mock_received_requests["/auth.test"] == 1 + + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert self.mock_received_requests["/auth.test"] == 2 + + @pytest.mark.asyncio + async def test_installation_store_cached(self): + installation_store = MemoryInstallationStore() + authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + cache_enabled=True, + ) + context = AsyncBoltContext() + context["client"] = self.client + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert self.mock_received_requests["/auth.test"] == 1 + + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert self.mock_received_requests["/auth.test"] == 1 # cached + + +class MemoryInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_bot( + self, *, enterprise_id: Optional[str], team_id: Optional[str] + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) From 7b00feff90271bde928a622f646f1f79db80fd5f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 24 Oct 2020 09:04:04 +0900 Subject: [PATCH 153/865] Fix #121 by changing the built-in middleware --- .../async_multi_teams_authorization.py | 12 ++++ .../async_single_team_authorization.py | 13 +++- .../middleware/authorization/internals.py | 22 +++++++ .../multi_teams_authorization.py | 17 +++++- .../single_team_authorization.py | 12 ++++ .../ignoring_self_events.py | 1 + tests/scenario_tests/test_events.py | 59 +++++++++++++++++- tests/scenario_tests_async/test_events.py | 60 +++++++++++++++++++ 8 files changed, 193 insertions(+), 3 deletions(-) diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index 840a5fc0a..98e22aa4b 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -6,6 +6,7 @@ from slack_bolt.response import BoltResponse from .async_authorization import AsyncAuthorization from .async_internals import _build_error_response, _is_no_auth_required +from .internals import _is_no_auth_test_call_required from ...authorization import AuthorizeResult from ...authorization.async_authorize import AsyncAuthorize from ...util.async_utils import create_async_web_client @@ -31,6 +32,17 @@ async def async_process( ) -> BoltResponse: if _is_no_auth_required(req): return await next() + + if _is_no_auth_test_call_required(req): + req.context.set_authorize_result( + AuthorizeResult( + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) + ) + return await next() + try: auth_result: Optional[AuthorizeResult] = await self.authorize( context=req.context, diff --git a/slack_bolt/middleware/authorization/async_single_team_authorization.py b/slack_bolt/middleware/authorization/async_single_team_authorization.py index 8a307d3da..8db49432e 100644 --- a/slack_bolt/middleware/authorization/async_single_team_authorization.py +++ b/slack_bolt/middleware/authorization/async_single_team_authorization.py @@ -7,7 +7,8 @@ from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.errors import SlackApiError from .async_internals import _build_error_response, _is_no_auth_required -from .internals import _to_authorize_result +from .internals import _to_authorize_result, _is_no_auth_test_call_required +from ...authorization import AuthorizeResult class AsyncSingleTeamAuthorization(AsyncAuthorization): @@ -26,6 +27,16 @@ async def async_process( if _is_no_auth_required(req): return await next() + if _is_no_auth_test_call_required(req): + req.context.set_authorize_result( + AuthorizeResult( + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) + ) + return await next() + try: if self.auth_test_result is None: self.auth_test_result = await req.context.client.auth_test() diff --git a/slack_bolt/middleware/authorization/internals.py b/slack_bolt/middleware/authorization/internals.py index 3ec2d9d39..0ab025a94 100644 --- a/slack_bolt/middleware/authorization/internals.py +++ b/slack_bolt/middleware/authorization/internals.py @@ -21,10 +21,32 @@ def _is_ssl_check(req: BoltRequest) -> bool: ) +def _is_uninstallation_event(req: BoltRequest) -> bool: + return ( + req is not None + and req.body is not None + and req.body.get("type") == "event_callback" + and req.body.get("event", {}).get("type") == "app_uninstalled" + ) + + +def _is_tokens_revoked_event(req: BoltRequest) -> bool: + return ( + req is not None + and req.body is not None + and req.body.get("type") == "event_callback" + and req.body.get("event", {}).get("type") == "tokens_revoked" + ) + + def _is_no_auth_required(req: BoltRequest) -> bool: return _is_url_verification(req) or _is_ssl_check(req) +def _is_no_auth_test_call_required(req: BoltRequest) -> bool: + return _is_uninstallation_event(req) or _is_tokens_revoked_event(req) + + def _build_error_response() -> BoltResponse: # show an ephemeral message to the end-user return BoltResponse( diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index 7efa8ae8c..bfde5bb47 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -5,7 +5,11 @@ from slack_bolt.response import BoltResponse from slack_sdk.errors import SlackApiError from .authorization import Authorization -from .internals import _build_error_response, _is_no_auth_required +from .internals import ( + _build_error_response, + _is_no_auth_required, + _is_no_auth_test_call_required, +) from ...authorization import AuthorizeResult from ...authorization.authorize import Authorize from ...util.utils import create_web_client @@ -29,6 +33,17 @@ def process( ) -> BoltResponse: if _is_no_auth_required(req): return next() + + if _is_no_auth_test_call_required(req): + req.context.set_authorize_result( + AuthorizeResult( + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) + ) + return next() + try: auth_result: Optional[AuthorizeResult] = self.authorize( context=req.context, diff --git a/slack_bolt/middleware/authorization/single_team_authorization.py b/slack_bolt/middleware/authorization/single_team_authorization.py index 8506ebff0..e09d399ab 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -10,7 +10,9 @@ _build_error_response, _is_no_auth_required, _to_authorize_result, + _is_no_auth_test_call_required, ) +from ...authorization import AuthorizeResult class SingleTeamAuthorization(Authorization): @@ -28,6 +30,16 @@ def process( if _is_no_auth_required(req): return next() + if _is_no_auth_test_call_required(req): + req.context.set_authorize_result( + AuthorizeResult( + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) + ) + return next() + try: if not self.auth_test_result: self.auth_test_result = req.context.client.auth_test() diff --git a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py index e0fcbe867..dd9eb93a4 100644 --- a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -36,6 +36,7 @@ def _is_self_event( ): return ( auth_result is not None + and user_id is not None and user_id == auth_result.bot_user_id and body.get("event") is not None and body.get("event", {}).get("type") not in cls.events_that_should_be_kept diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index e3c8d89eb..4a2b02dc8 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -4,7 +4,7 @@ from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient -from slack_bolt import App, BoltRequest +from slack_bolt import App, BoltRequest, Say from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -335,3 +335,60 @@ def handle_app_mention(say): sleep(1) # wait a bit after auto ack() # the listeners should not be executed assert self.mock_received_requests["/chat.postMessage"] == 2 + + def test_uninstallation_and_revokes(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app._client = WebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event("app_uninstalled") + def handler1(say: Say): + say(channel="C111", text="What's up?") + + @app.event("tokens_revoked") + def handler2(say: Say): + say(channel="C111", text="What's up?") + + app_uninstalled_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + timestamp, body = str(int(time())), json.dumps(app_uninstalled_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + + tokens_revoked_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["UXXXXXXXX"], "bot": ["UXXXXXXXX"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + timestamp, body = str(int(time())), json.dumps(tokens_revoked_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 2 diff --git a/tests/scenario_tests_async/test_events.py b/tests/scenario_tests_async/test_events.py index 9cf1e2033..67e02db0b 100644 --- a/tests/scenario_tests_async/test_events.py +++ b/tests/scenario_tests_async/test_events.py @@ -8,6 +8,7 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.say.async_say import AsyncSay from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( setup_mock_web_api_server, @@ -315,6 +316,65 @@ async def handle_member_left_channel(say): # The listeners should be executed assert self.mock_received_requests.get("/chat.postMessage") == 2 + @pytest.mark.asyncio + async def test_uninstallation_and_revokes(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app._client = AsyncWebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event("app_uninstalled") + async def handler1(say: AsyncSay): + await say(channel="C111", text="What's up?") + + @app.event("tokens_revoked") + async def handler2(say: AsyncSay): + await say(channel="C111", text="What's up?") + + app_uninstalled_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + timestamp, body = str(int(time())), json.dumps(app_uninstalled_body) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + tokens_revoked_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["UXXXXXXXX"], "bot": ["UXXXXXXXX"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + timestamp, body = str(int(time())), json.dumps(tokens_revoked_body) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + # AsyncApp doesn't call auth.test when booting + assert self.mock_received_requests.get("/auth.test") is None + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 2 + app_mention_body = { "token": "verification_token", From a1f1ca145f1c2a4bd2845eaa6cb0a23e1bb580ef Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 24 Oct 2020 10:47:45 +0900 Subject: [PATCH 154/865] Correct type hints - thanks to pytype --- .../middleware/authorization/internals.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/slack_bolt/middleware/authorization/internals.py b/slack_bolt/middleware/authorization/internals.py index 0ab025a94..90f65af61 100644 --- a/slack_bolt/middleware/authorization/internals.py +++ b/slack_bolt/middleware/authorization/internals.py @@ -6,8 +6,16 @@ from slack_bolt.request.request import BoltRequest from slack_bolt.response import BoltResponse +# +# NOTE: this source file intentionally avoids having a reference to +# AsyncBoltRequest, AsyncSlackResponse, and whatever Async-prefixed. +# +# The reason why we do so is to enable developers use sync version of Bolt +# without installing aiohttp library (or any others we may use for async things) +# -def _is_url_verification(req: BoltRequest) -> bool: + +def _is_url_verification(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore return ( req is not None and req.body is not None @@ -15,13 +23,13 @@ def _is_url_verification(req: BoltRequest) -> bool: ) -def _is_ssl_check(req: BoltRequest) -> bool: +def _is_ssl_check(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore return ( req is not None and req.body is not None and req.body.get("type") == "ssl_check" ) -def _is_uninstallation_event(req: BoltRequest) -> bool: +def _is_uninstallation_event(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore return ( req is not None and req.body is not None @@ -30,7 +38,7 @@ def _is_uninstallation_event(req: BoltRequest) -> bool: ) -def _is_tokens_revoked_event(req: BoltRequest) -> bool: +def _is_tokens_revoked_event(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore return ( req is not None and req.body is not None @@ -39,11 +47,11 @@ def _is_tokens_revoked_event(req: BoltRequest) -> bool: ) -def _is_no_auth_required(req: BoltRequest) -> bool: +def _is_no_auth_required(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore return _is_url_verification(req) or _is_ssl_check(req) -def _is_no_auth_test_call_required(req: BoltRequest) -> bool: +def _is_no_auth_test_call_required(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore return _is_uninstallation_event(req) or _is_tokens_revoked_event(req) From 736f7646a97a60f317b014f43a62c350f6388766 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 25 Oct 2020 22:48:09 +0900 Subject: [PATCH 155/865] Enable WorkflowStep to have lazy listeners --- slack_bolt/app/app.py | 14 +- slack_bolt/app/async_app.py | 6 +- slack_bolt/workflows/step/async_step.py | 39 +++-- slack_bolt/workflows/step/step.py | 42 +++-- tests/scenario_tests/test_workflow_steps.py | 143 +++++++++++++--- .../test_workflow_steps.py | 153 +++++++++++++++--- 6 files changed, 322 insertions(+), 75 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index e4946706c..b6bd3c035 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -6,8 +6,6 @@ from http.server import SimpleHTTPRequestHandler, HTTPServer from typing import List, Union, Pattern, Callable, Dict, Optional -from slack_bolt.listener.thread_runner import ThreadListenerRunner -from slack_bolt.workflows.step import WorkflowStep, WorkflowStepMiddleware from slack_sdk.errors import SlackApiError from slack_sdk.oauth.installation_store import InstallationStore from slack_sdk.web import WebClient @@ -26,6 +24,7 @@ DefaultListenerErrorHandler, CustomListenerErrorHandler, ) +from slack_bolt.listener.thread_runner import ThreadListenerRunner from slack_bolt.listener_matcher import CustomListenerMatcher from slack_bolt.listener_matcher import builtins as builtin_matchers from slack_bolt.listener_matcher.listener_matcher import ListenerMatcher @@ -59,6 +58,7 @@ from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from slack_bolt.util.utils import create_web_client +from slack_bolt.workflows.step import WorkflowStep, WorkflowStepMiddleware class App: @@ -358,10 +358,14 @@ def middleware(self, *args) -> Optional[Callable]: def step( self, callback_id: Union[str, Pattern, WorkflowStep], - edit: Optional[Union[Callable[..., Optional[BoltResponse]], Listener]] = None, - save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener]] = None, + edit: Optional[ + Union[Callable[..., Optional[BoltResponse]], Listener, List[Callable]] + ] = None, + save: Optional[ + Union[Callable[..., Optional[BoltResponse]], Listener, List[Callable]] + ] = None, execute: Optional[ - Union[Callable[..., Optional[BoltResponse]], Listener] + Union[Callable[..., Optional[BoltResponse]], Listener, List[Callable]] ] = None, ): """Registers a new Workflow Step listener""" diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 272a01747..50e9a98ce 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -371,13 +371,13 @@ def step( self, callback_id: Union[str, Pattern, AsyncWorkflowStep], edit: Optional[ - Union[Callable[..., Optional[BoltResponse]], AsyncListener] + Union[Callable[..., Optional[BoltResponse]], AsyncListener, List[Callable]] ] = None, save: Optional[ - Union[Callable[..., Optional[BoltResponse]], AsyncListener] + Union[Callable[..., Optional[BoltResponse]], AsyncListener, List[Callable]] ] = None, execute: Optional[ - Union[Callable[..., Optional[BoltResponse]], AsyncListener] + Union[Callable[..., Optional[BoltResponse]], AsyncListener, List[Callable]] ] = None, ): """Registers a new Workflow Step listener""" diff --git a/slack_bolt/workflows/step/async_step.py b/slack_bolt/workflows/step/async_step.py index 334d98821..cf1ee73a6 100644 --- a/slack_bolt/workflows/step/async_step.py +++ b/slack_bolt/workflows/step/async_step.py @@ -1,4 +1,4 @@ -from typing import Callable, Union, Optional, Awaitable +from typing import Callable, Union, Optional, Awaitable, List from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -14,6 +14,8 @@ from .utilities.async_fail import AsyncFail from .utilities.async_complete import AsyncComplete from .utilities.async_update import AsyncUpdate +from ...listener_matcher.async_listener_matcher import AsyncListenerMatcher +from ...middleware.async_middleware import AsyncMiddleware class AsyncWorkflowStep: @@ -26,9 +28,15 @@ def __init__( self, *, callback_id: str, - edit: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], - save: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], - execute: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], + edit: Union[ + Callable[..., Awaitable[BoltResponse]], AsyncListener, List[Callable] + ], + save: Union[ + Callable[..., Awaitable[BoltResponse]], AsyncListener, List[Callable] + ], + execute: Union[ + Callable[..., Awaitable[BoltResponse]], AsyncListener, List[Callable] + ], app_name: Optional[str] = None, ): self.callback_id = callback_id @@ -40,7 +48,7 @@ def __init__( @classmethod def _build_listener( cls, callback_id: str, app_name: str, listener: AsyncListener, name: str, - ): + ) -> AsyncListener: if isinstance(listener, AsyncListener): return listener elif isinstance(listener, Callable): @@ -52,11 +60,22 @@ def _build_listener( lazy_functions=[], auto_acknowledgement=name == "execute", ) + elif isinstance(listener, list) and len(listener) > 0: + ack = listener.pop(0) + lazy = listener + return AsyncCustomListener( + app_name=app_name, + matchers=cls._build_matchers(name, callback_id), + middleware=cls._build_middleware(name, callback_id), + ack_function=ack, + lazy_functions=lazy, + auto_acknowledgement=name == "execute", + ) else: raise ValueError(f"Invalid `{name}` listener") @classmethod - def _build_matchers(cls, name: str, callback_id: str): + def _build_matchers(cls, name: str, callback_id: str) -> List[AsyncListenerMatcher]: if name == "edit": return [workflow_step_edit(callback_id, asyncio=True)] elif name == "save": @@ -67,7 +86,7 @@ def _build_matchers(cls, name: str, callback_id: str): raise ValueError(f"Invalid name {name}") @classmethod - def _build_middleware(cls, name: str, callback_id: str): + def _build_middleware(cls, name: str, callback_id: str) -> List[AsyncMiddleware]: if name == "edit": return [_build_edit_listener_middleware(callback_id)] elif name == "save": @@ -83,7 +102,7 @@ def _build_middleware(cls, name: str, callback_id: str): ####################### -def _build_edit_listener_middleware(callback_id: str): +def _build_edit_listener_middleware(callback_id: str) -> AsyncMiddleware: async def edit_listener_middleware( context: AsyncBoltContext, client: AsyncWebClient, @@ -103,7 +122,7 @@ async def edit_listener_middleware( ####################### -def _build_save_listener_middleware(): +def _build_save_listener_middleware() -> AsyncMiddleware: async def save_listener_middleware( context: AsyncBoltContext, client: AsyncWebClient, @@ -121,7 +140,7 @@ async def save_listener_middleware( ####################### -def _build_execute_listener_middleware(): +def _build_execute_listener_middleware() -> AsyncMiddleware: async def execute_listener_middleware( context: AsyncBoltContext, client: AsyncWebClient, diff --git a/slack_bolt/workflows/step/step.py b/slack_bolt/workflows/step/step.py index 3d83569ca..2fe342c57 100644 --- a/slack_bolt/workflows/step/step.py +++ b/slack_bolt/workflows/step/step.py @@ -1,13 +1,14 @@ -from typing import Callable, Union, Optional +from typing import Callable, Union, Optional, List from slack_bolt.context import BoltContext from slack_bolt.listener import Listener, CustomListener +from slack_bolt.listener_matcher import ListenerMatcher from slack_bolt.listener_matcher.builtins import ( workflow_step_edit, workflow_step_save, workflow_step_execute, ) -from slack_bolt.middleware import CustomMiddleware +from slack_bolt.middleware import CustomMiddleware, Middleware from slack_bolt.response import BoltResponse from slack_bolt.workflows.step.utilities.complete import Complete from slack_bolt.workflows.step.utilities.configure import Configure @@ -26,9 +27,9 @@ def __init__( self, *, callback_id: str, - edit: Union[Callable[..., Optional[BoltResponse]], Listener], - save: Union[Callable[..., Optional[BoltResponse]], Listener], - execute: Union[Callable[..., Optional[BoltResponse]], Listener], + edit: Union[Callable[..., Optional[BoltResponse]], Listener, List[Callable]], + save: Union[Callable[..., Optional[BoltResponse]], Listener, List[Callable]], + execute: Union[Callable[..., Optional[BoltResponse]], Listener, List[Callable]], app_name: Optional[str] = None, ): self.callback_id = callback_id @@ -38,7 +39,15 @@ def __init__( self.execute = self._build_listener(callback_id, app_name, execute, "execute") @classmethod - def _build_listener(cls, callback_id, app_name, listener, name): + def _build_listener( + cls, + callback_id: str, + app_name: str, + listener: Union[ + Callable[..., Optional[BoltResponse]], Listener, List[Callable] + ], + name: str, + ) -> Listener: if isinstance(listener, Listener): return listener elif isinstance(listener, Callable): @@ -50,11 +59,22 @@ def _build_listener(cls, callback_id, app_name, listener, name): lazy_functions=[], auto_acknowledgement=name == "execute", ) + elif isinstance(listener, list) and len(listener) > 0: + ack = listener.pop(0) + lazy = listener + return CustomListener( + app_name=app_name, + matchers=cls._build_matchers(name, callback_id), + middleware=cls._build_middleware(name, callback_id), + ack_function=ack, + lazy_functions=lazy, + auto_acknowledgement=name == "execute", + ) else: raise ValueError(f"Invalid `{name}` listener") @classmethod - def _build_matchers(cls, name, callback_id): + def _build_matchers(cls, name: str, callback_id: str) -> List[ListenerMatcher]: if name == "edit": return [workflow_step_edit(callback_id)] elif name == "save": @@ -65,7 +85,7 @@ def _build_matchers(cls, name, callback_id): raise ValueError(f"Invalid name {name}") @classmethod - def _build_middleware(cls, name, callback_id): + def _build_middleware(cls, name: str, callback_id: str) -> List[Middleware]: if name == "edit": return [_build_edit_listener_middleware(callback_id)] elif name == "save": @@ -81,7 +101,7 @@ def _build_middleware(cls, name, callback_id): ####################### -def _build_edit_listener_middleware(callback_id): +def _build_edit_listener_middleware(callback_id: str) -> Middleware: def edit_listener_middleware( context: BoltContext, client: WebClient, @@ -101,7 +121,7 @@ def edit_listener_middleware( ####################### -def _build_save_listener_middleware(): +def _build_save_listener_middleware() -> Middleware: def save_listener_middleware( context: BoltContext, client: WebClient, @@ -119,7 +139,7 @@ def save_listener_middleware( ####################### -def _build_execute_listener_middleware(): +def _build_execute_listener_middleware() -> Middleware: def execute_listener_middleware( context: BoltContext, client: WebClient, diff --git a/tests/scenario_tests/test_workflow_steps.py b/tests/scenario_tests/test_workflow_steps.py index 82a67a356..9dacd83a3 100644 --- a/tests/scenario_tests/test_workflow_steps.py +++ b/tests/scenario_tests/test_workflow_steps.py @@ -25,8 +25,6 @@ class TestWorkflowSteps: def setup_method(self): self.old_os_env = remove_os_env_temporarily() setup_mock_web_api_server(self) - self.app = App(client=self.web_client, signing_secret=self.signing_secret) - self.app.step(callback_id="copy_review", edit=edit, save=save, execute=execute) def teardown_method(self): cleanup_mock_web_api_server(self) @@ -37,7 +35,28 @@ def generate_signature(self, body: str, timestamp: str): body=body, timestamp=timestamp, ) + def build_app(self, callback_id: str): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.step(callback_id=callback_id, edit=edit, save=save, execute=execute) + return app + + def build_process_before_response_app(self, callback_id: str): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.step( + callback_id=callback_id, + edit=[edit_ack, edit_lazy], + save=[save_ack, save_lazy], + execute=[execute_ack, execute_lazy], + ) + return app + def test_edit(self): + app = self.build_app("copy_review") + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" headers = { "content-type": ["application/x-www-form-urlencoded"], @@ -45,18 +64,35 @@ def test_edit(self): "x-slack-request-timestamp": [timestamp], } request: BoltRequest = BoltRequest(body=body, headers=headers) - response = self.app.dispatch(request) + response = app.dispatch(request) assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 1 - self.app = App(client=self.web_client, signing_secret=self.signing_secret) - self.app.step( - callback_id="copy_review___", edit=edit, save=save, execute=execute - ) - response = self.app.dispatch(request) + app = self.build_app("copy_review___") + response = app.dispatch(request) + assert response.status == 404 + + def test_edit_process_before_response(self): + app = self.build_process_before_response_app("copy_review") + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + app = self.build_process_before_response_app("copy_review___") + response = app.dispatch(request) assert response.status == 404 def test_save(self): + app = self.build_app("copy_review") + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" headers = { "content-type": ["application/x-www-form-urlencoded"], @@ -64,18 +100,35 @@ def test_save(self): "x-slack-request-timestamp": [timestamp], } request: BoltRequest = BoltRequest(body=body, headers=headers) - response = self.app.dispatch(request) + response = app.dispatch(request) assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 1 - self.app = App(client=self.web_client, signing_secret=self.signing_secret) - self.app.step( - callback_id="copy_review___", edit=edit, save=save, execute=execute - ) - response = self.app.dispatch(request) + app = self.build_app("copy_review___") + response = app.dispatch(request) + assert response.status == 404 + + def test_save_process_before_response(self): + app = self.build_process_before_response_app("copy_review") + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + app = self.build_process_before_response_app("copy_review___") + response = app.dispatch(request) assert response.status == 404 def test_execute(self): + app = self.build_app("copy_review") + timestamp, body = str(int(time())), json.dumps(execute_payload) headers = { "content-type": ["application/json"], @@ -83,17 +136,34 @@ def test_execute(self): "x-slack-request-timestamp": [timestamp], } request: BoltRequest = BoltRequest(body=body, headers=headers) - response = self.app.dispatch(request) + response = app.dispatch(request) assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 1 time_module.sleep(0.5) assert self.mock_received_requests["/workflows.stepCompleted"] == 1 - self.app = App(client=self.web_client, signing_secret=self.signing_secret) - self.app.step( - callback_id="copy_review___", edit=edit, save=save, execute=execute - ) - response = self.app.dispatch(request) + app = self.build_app("copy_review___") + response = app.dispatch(request) + assert response.status == 404 + + def test_execute_process_before_response(self): + app = self.build_process_before_response_app("copy_review") + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + time_module.sleep(0.5) + assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + + app = self.build_process_before_response_app("copy_review___") + response = app.dispatch(request) assert response.status == 404 @@ -398,3 +468,36 @@ def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): ) except Exception as err: fail(error={"message": f"Something wrong! {err}"}) + + +def edit_ack(ack: Ack): + ack() + + +def edit_lazy(step, configure: Configure): + assert step is not None + configure(blocks=[]) + + +def save_ack(ack: Ack): + ack() + + +def save_lazy(step: dict, view: dict, update: Update): + assert step is not None + assert view is not None + update( + inputs={}, outputs=[], + ) + + +def execute_ack(): + pass + + +def execute_lazy(step: dict, complete: Complete, fail: Fail): + assert step is not None + try: + complete(outputs={}) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) diff --git a/tests/scenario_tests_async/test_workflow_steps.py b/tests/scenario_tests_async/test_workflow_steps.py index de92b5b55..525a6f568 100644 --- a/tests/scenario_tests_async/test_workflow_steps.py +++ b/tests/scenario_tests_async/test_workflow_steps.py @@ -22,7 +22,7 @@ from tests.utils import remove_os_env_temporarily, restore_os_env -class TestAsyncEvents: +class TestAsyncWorkflowSteps: signing_secret = "secret" valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" @@ -34,13 +34,6 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - self.app = AsyncApp( - client=self.web_client, signing_secret=self.signing_secret - ) - self.app.step( - callback_id="copy_review", edit=edit, save=save, execute=execute - ) - loop = asyncio.get_event_loop() yield loop loop.close() @@ -53,8 +46,29 @@ def generate_signature(self, body: str, timestamp: str): body=body, timestamp=timestamp, ) + def build_app(self, callback_id: str): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.step(callback_id=callback_id, edit=edit, save=save, execute=execute) + return app + + def build_process_before_response_app(self, callback_id: str): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.step( + callback_id=callback_id, + edit=[edit_ack, edit_lazy], + save=[save_ack, save_lazy], + execute=[execute_ack, execute_lazy], + ) + return app + @pytest.mark.asyncio async def test_edit(self): + app = self.build_app("copy_review") + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" headers = { "content-type": ["application/x-www-form-urlencoded"], @@ -62,19 +76,37 @@ async def test_edit(self): "x-slack-request-timestamp": [timestamp], } request = AsyncBoltRequest(body=body, headers=headers) - response = await self.app.async_dispatch(request) + response = await app.async_dispatch(request) assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 1 - self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) - self.app.step( - callback_id="copy_review___", edit=edit, save=save, execute=execute - ) - response = await self.app.async_dispatch(request) + app = self.build_app("copy_review___") + response = await app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_edit_process_before_response(self): + app = self.build_process_before_response_app("copy_review") + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + app = self.build_process_before_response_app("copy_review___") + response = await app.async_dispatch(request) assert response.status == 404 @pytest.mark.asyncio async def test_save(self): + app = self.build_app("copy_review") + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" headers = { "content-type": ["application/x-www-form-urlencoded"], @@ -82,19 +114,37 @@ async def test_save(self): "x-slack-request-timestamp": [timestamp], } request = AsyncBoltRequest(body=body, headers=headers) - response = await self.app.async_dispatch(request) + response = await app.async_dispatch(request) assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 1 - self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) - self.app.step( - callback_id="copy_review___", edit=edit, save=save, execute=execute - ) - response = await self.app.async_dispatch(request) + app = self.build_app("copy_review___") + response = await app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_save_process_before_response(self): + app = self.build_process_before_response_app("copy_review") + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + app = self.build_process_before_response_app("copy_review___") + response = await app.async_dispatch(request) assert response.status == 404 @pytest.mark.asyncio async def test_execute(self): + app = self.build_app("copy_review") + timestamp, body = str(int(time())), json.dumps(execute_payload) headers = { "content-type": ["application/json"], @@ -102,17 +152,35 @@ async def test_execute(self): "x-slack-request-timestamp": [timestamp], } request = AsyncBoltRequest(body=body, headers=headers) - response = await self.app.async_dispatch(request) + response = await app.async_dispatch(request) assert response.status == 200 assert self.mock_received_requests["/auth.test"] == 1 await asyncio.sleep(0.5) assert self.mock_received_requests["/workflows.stepCompleted"] == 1 - self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) - self.app.step( - callback_id="copy_review___", edit=edit, save=save, execute=execute - ) - response = await self.app.async_dispatch(request) + app = self.build_app("copy_review___") + response = await app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_execute_process_before_response(self): + app = self.build_process_before_response_app("copy_review") + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(0.5) + assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + + app = self.build_process_before_response_app("copy_review___") + response = await app.async_dispatch(request) assert response.status == 404 @@ -419,3 +487,36 @@ async def execute( ) except Exception as err: await fail(error={"message": f"Something wrong! {err}"}) + + +async def edit_ack(ack: AsyncAck): + await ack() + + +async def edit_lazy(step, configure: AsyncConfigure): + assert step is not None + await configure(blocks=[]) + + +async def save_ack(ack: AsyncAck): + await ack() + + +async def save_lazy(step: dict, view: dict, update: AsyncUpdate): + assert step is not None + assert view is not None + await update( + inputs={}, outputs=[], + ) + + +async def execute_ack(): + pass + + +async def execute_lazy(step: dict, complete: AsyncComplete, fail: AsyncFail): + assert step is not None + try: + await complete(outputs={}) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) From b6c2f1cbfe12f3314f9291467689b2ba7391f833 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 24 Oct 2020 08:20:44 +0900 Subject: [PATCH 156/865] Fix #123 by adding a validation in constructors (App/AsyncApp) --- slack_bolt/app/app.py | 3 +++ slack_bolt/app/async_app.py | 4 ++++ slack_bolt/logger/messages.py | 4 ++++ tests/scenario_tests/test_app.py | 29 ++++++++++++++++++++++++++ tests/scenario_tests_async/test_app.py | 29 ++++++++++++++++++++++++++ 5 files changed, 69 insertions(+) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index b6bd3c035..e0e61fc20 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -41,6 +41,7 @@ debug_running_listener, error_unexpected_listener_middleware, error_client_invalid_type, + error_authorize_conflicts, ) from slack_bolt.middleware import ( Middleware, @@ -131,6 +132,8 @@ def __init__( self._authorize: Optional[Authorize] = None if authorize is not None: + if oauth_settings is not None or oauth_flow is not None: + raise BoltError(error_authorize_conflicts()) self._authorize = CallableAuthorize( logger=self._framework_logger, func=authorize ) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 50e9a98ce..1051e0b4d 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -32,6 +32,7 @@ error_unexpected_listener_middleware, error_listener_function_must_be_coro_func, error_client_invalid_type_async, + error_authorize_conflicts, ) from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -138,6 +139,9 @@ def __init__( self._async_authorize: Optional[AsyncAuthorize] = None if authorize is not None: + if oauth_settings is not None or oauth_flow is not None: + raise BoltError(error_authorize_conflicts()) + self._async_authorize = AsyncCallableAuthorize( logger=self._framework_logger, func=authorize ) diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 0aee513d2..75abe3e39 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -45,6 +45,10 @@ def error_listener_function_must_be_coro_func(func_name: str) -> str: return f"The listener function ({func_name}) is not a coroutine function." +def error_authorize_conflicts() -> str: + return "`authorize` in the top-level arguments is not allowed when you pass either `oauth_settings` or `oauth_flow`" + + # ------------------------------- # Warning # ------------------------------- diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index 4bfef046f..fcf7b29d6 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -4,6 +4,7 @@ from slack_sdk.oauth.state_store import FileOAuthStateStore from slack_bolt import App +from slack_bolt.authorization import AuthorizeResult from slack_bolt.error import BoltError from slack_bolt.oauth import OAuthFlow from slack_bolt.oauth.oauth_settings import OAuthSettings @@ -92,3 +93,31 @@ def test_valid_multi_auth_secret_absence(self): signing_secret="valid", oauth_settings=OAuthSettings(client_id="111.222", client_secret=None), ) + + def test_authorize_conflicts(self): + oauth_settings = OAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + + # no error with this + App(signing_secret="valid", oauth_settings=oauth_settings) + + def authorize() -> AuthorizeResult: + return AuthorizeResult(enterprise_id="E111", team_id="T111") + + with pytest.raises(BoltError): + App( + signing_secret="valid", + authorize=authorize, + oauth_settings=oauth_settings, + ) + + oauth_flow = OAuthFlow(settings=oauth_settings) + # no error with this + App(signing_secret="valid", oauth_flow=oauth_flow) + + with pytest.raises(BoltError): + App(signing_secret="valid", authorize=authorize, oauth_flow=oauth_flow) diff --git a/tests/scenario_tests_async/test_app.py b/tests/scenario_tests_async/test_app.py index a22c95a3e..cd3b37865 100644 --- a/tests/scenario_tests_async/test_app.py +++ b/tests/scenario_tests_async/test_app.py @@ -4,6 +4,7 @@ from slack_sdk.oauth.state_store import FileOAuthStateStore from slack_bolt.async_app import AsyncApp +from slack_bolt.authorization import AuthorizeResult from slack_bolt.error import BoltError from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings @@ -98,3 +99,31 @@ def test_valid_multi_auth_secret_absence(self): signing_secret="valid", oauth_settings=OAuthSettings(client_id="111.222", client_secret=None), ) + + def test_authorize_conflicts(self): + oauth_settings = OAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + + # no error with this + AsyncApp(signing_secret="valid", oauth_settings=oauth_settings) + + def authorize() -> AuthorizeResult: + return AuthorizeResult(enterprise_id="E111", team_id="T111") + + with pytest.raises(BoltError): + AsyncApp( + signing_secret="valid", + authorize=authorize, + oauth_settings=oauth_settings, + ) + + oauth_flow = AsyncOAuthFlow(settings=oauth_settings) + # no error with this + AsyncApp(signing_secret="valid", oauth_flow=oauth_flow) + + with pytest.raises(BoltError): + AsyncApp(signing_secret="valid", authorize=authorize, oauth_flow=oauth_flow) From e5973803bb0523e62d486f43164ed2e0169841f1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 27 Oct 2020 16:30:14 +0900 Subject: [PATCH 157/865] versopm 0.9.5b0 --- setup.py | 2 +- slack_bolt/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index c9c166120..b5ea3d66d 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.0.0b1",], + install_requires=["slack_sdk>=3.0.0rc1",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index bc28f6ddc..338dfbcc1 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.9.4b0" +__version__ = "0.9.5b0" From 9eb014f099d10083ae604da5435825a793d0c1bb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 27 Oct 2020 19:18:16 +0900 Subject: [PATCH 158/865] Add Python 3.9 to CI builds --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 693347598..3f45f8b89 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,7 @@ python: - "3.6" - "3.7" - "3.8" + - "3.9" install: - python setup.py install - pip install -U pip From 8e1a8819ea68a2ddce6233698293db595eeef517 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 27 Oct 2020 20:36:37 +0900 Subject: [PATCH 159/865] Add Python 3.9 to the supported versions --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index b5ea3d66d..7d210ef96 100755 --- a/setup.py +++ b/setup.py @@ -75,6 +75,7 @@ "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", From 3a2d50f2c71b9b3933598108b7500e99dbc04beb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 27 Oct 2020 17:52:43 +0900 Subject: [PATCH 160/865] Update README for #128 --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b64218451..7d126c61a 100644 --- a/README.md +++ b/README.md @@ -89,17 +89,18 @@ def handle_event(event): ## Making things happen -Most of the app's functionality will be inside listener functions (the `fn` parameters above). These functions are called with a set of arguments. +Most of the app's functionality will be inside listener functions (the `fn` parameters above). These functions are called with a set of arguments. You can place the arguments in random order. Bolt resolves them by the keyword arguments' names. If you'd like to get all arguments in a single object, you can use `args`, a [`slack_bolt.kwargs_injection.Args`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/kwargs_injection/args.py) instance holding all available values in it. | Argument | Description | | :---: | :--- | +| `body` | Dictionary that contains the entire body of the request (superset of `payload`). Some accessory data is only available outside of the payload (such as `trigger_id` and `authed_users`). | `payload` | Contents of the incoming event. The payload structure depends on the listener. For example, for an Events API event, `payload` will be the [event type structure](https://api.slack.com/events-api#event_type_structure). For a block action, it will be the action from within the `actions` list. The `payload` dictionary is also accessible via the alias corresponding to the listener (`message`, `event`, `action`, `shortcut`, `view`, `command`, or `options`). For example, if you were building a `message()` listener, you could use the `payload` and `message` arguments interchangably. **An easy way to understand what's in a payload is to log it**. | -| `say` | Utility function to send a message to the channel associated with the incoming event. This argument is only available when the listener is triggered for events that contain a `channel_id` (the most common being `message` events). `say` accepts simple strings (for plain-text messages) and dictionaries (for messages containing blocks). +| `context` | Event context. This dictionary contains data about the event and app, such as the `botId`. Middleware can add additional context before the event is passed to listeners. | `ack` | Function that **must** be called to acknowledge that your app received the incoming event. `ack` exists for all actions, shortcuts, view submissions, slash command and options requests. `ack` returns a promise that resolves when complete. Read more in [Acknowledging events](https://slack.dev/bolt-python/concepts#acknowledge). -| `client` | Web API client that uses the token associated with the event. For single-workspace installations, the token is provided to the constructor. For multi-workspace installations, the token is returned by using [the OAuth library](https://slack.dev/bolt-python/concepts#authenticating-oauth), or manually using the `authorize` function. | `respond` | Utility function that responds to incoming events **if** it contains a `response_url` (shortcuts, actions, and slash commands). -| `context` | Event context. This dictionary contains data about the event and app, such as the `botId`. Middleware can add additional context before the event is passed to listeners. -| `body` | Dictionary that contains the entire body of the request (superset of `payload`). Some accessory data is only available outside of the payload (such as `trigger_id` and `authed_users`). +| `say` | Utility function to send a message to the channel associated with the incoming event. This argument is only available when the listener is triggered for events that contain a `channel_id` (the most common being `message` events). `say` accepts simple strings (for plain-text messages) and dictionaries (for messages containing blocks). +| `client` | Web API client that uses the token associated with the event. For single-workspace installations, the token is provided to the constructor. For multi-workspace installations, the token is returned by using [the OAuth library](https://slack.dev/bolt-python/concepts#authenticating-oauth), or manually using the `authorize` function. +| `logger` | The built-in [`logging.Logger`](https://docs.python.org/3/library/logging.html) instance you can use in middleware/listeners. ## Creating an async app From 1c4f4431aee686b1f4615b0754196d48d229f61a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 28 Oct 2020 06:33:40 +0900 Subject: [PATCH 161/865] Update README.md Co-authored-by: Alissa Renz --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d126c61a..783491def 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ def handle_event(event): ## Making things happen -Most of the app's functionality will be inside listener functions (the `fn` parameters above). These functions are called with a set of arguments. You can place the arguments in random order. Bolt resolves them by the keyword arguments' names. If you'd like to get all arguments in a single object, you can use `args`, a [`slack_bolt.kwargs_injection.Args`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/kwargs_injection/args.py) instance holding all available values in it. +Most of the app's functionality will be inside listener functions (the `fn` parameters above). These functions are called with a set of arguments, each of which can be used in any order. If you'd like to access arguments off of a single object, you can use `args`, an [`slack_bolt.kwargs_injection.Args`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/kwargs_injection/args.py) instance that contains all available arguments for that event. | Argument | Description | | :---: | :--- | From bea7fc787a1811b555c66a1d66a8fb2f343aa1f5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 28 Oct 2020 07:28:29 +0900 Subject: [PATCH 162/865] Fix a link in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 783491def..240a72d8f 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ If you want to use another async Web framework (e.g., Sanic, FastAPI, Starlette) * [The Bolt app examples](https://github.com/slackapi/bolt-python/tree/main/examples) * [The built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) -Apps can be run the same way as the syncronous example above. If you'd prefer another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at [the built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) and their corresponding [sample code](https://github.com/slackapi/bolt-python/tree/main/samples). +Apps can be run the same way as the syncronous example above. If you'd prefer another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at [the built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) and their corresponding [examples](https://github.com/slackapi/bolt-python/tree/main/examples). ## Getting Help From 3f67a08805be7fde2c87f5c752b21dfa0d654aeb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 29 Oct 2020 12:42:41 +0900 Subject: [PATCH 163/865] Fix #132 by handling requestContext in AWS Lambda payloads --- scripts/install_all_and_run_tests.sh | 2 ++ slack_bolt/adapter/aws_lambda/handler.py | 3 ++ tests/adapter_tests/test_aws_lambda.py | 43 ++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index c7eb6c1a6..2d5b4c14d 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -6,6 +6,8 @@ script_dir=`dirname $0` cd ${script_dir}/.. rm -rf ./slack_bolt.egg-info +# The package causes a conflict with moto +pip uninstall python-lambda test_target="$1" diff --git a/slack_bolt/adapter/aws_lambda/handler.py b/slack_bolt/adapter/aws_lambda/handler.py index b4033c3a2..c6e1e975a 100644 --- a/slack_bolt/adapter/aws_lambda/handler.py +++ b/slack_bolt/adapter/aws_lambda/handler.py @@ -33,6 +33,9 @@ def handle(self, event, context): self.logger.debug(f"Incoming event: {event}, context: {context}") method = event.get("requestContext", {}).get("http", {}).get("method") + if method is None: + method = event.get("requestContext", {}).get("httpMethod") + if method is None: return not_found() if method == "GET": diff --git a/tests/adapter_tests/test_aws_lambda.py b/tests/adapter_tests/test_aws_lambda.py index c4463c513..62793a837 100644 --- a/tests/adapter_tests/test_aws_lambda.py +++ b/tests/adapter_tests/test_aws_lambda.py @@ -115,6 +115,17 @@ def event_handler(): assert response["statusCode"] == 200 assert self.mock_received_requests["/auth.test"] == 1 + event = { + "body": body, + "queryStringParameters": {}, + "headers": self.build_headers(timestamp, body), + "requestContext": {"httpMethod": "POST"}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert self.mock_received_requests["/auth.test"] == 1 + @mock_lambda def test_shortcuts(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -151,6 +162,17 @@ def shortcut_handler(ack): assert response["statusCode"] == 200 assert self.mock_received_requests["/auth.test"] == 1 + event = { + "body": body, + "queryStringParameters": {}, + "headers": self.build_headers(timestamp, body), + "requestContext": {"httpMethod": "POST"}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert self.mock_received_requests["/auth.test"] == 1 + @mock_lambda def test_commands(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -187,6 +209,17 @@ def command_handler(ack): assert response["statusCode"] == 200 assert self.mock_received_requests["/auth.test"] == 1 + event = { + "body": body, + "queryStringParameters": {}, + "headers": self.build_headers(timestamp, body), + "requestContext": {"httpMethod": "POST"}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert self.mock_received_requests["/auth.test"] == 1 + @mock_lambda def test_lazy_listeners(self): app = App(client=self.web_client, signing_secret=self.signing_secret,) @@ -251,3 +284,13 @@ def test_oauth(self): } response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 302 + + event = { + "body": "", + "queryStringParameters": {}, + "headers": {}, + "requestContext": {"httpMethod": "GET"}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 302 From 8cad46e4d6d414645e653a89cb29a2baa409a960 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 29 Oct 2020 13:37:31 +0900 Subject: [PATCH 164/865] version 0.9.6b0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 338dfbcc1..68b82ae82 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.9.5b0" +__version__ = "0.9.6b0" From afb7a36634d6a3177775f90a0bb175982b2dcdda Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 2 Nov 2020 08:57:24 +0900 Subject: [PATCH 165/865] Move PayloadUtils to request package --- slack_bolt/kwargs_injection/async_utils.py | 2 +- slack_bolt/kwargs_injection/utils.py | 2 +- slack_bolt/listener/async_listener_error_handler.py | 2 +- slack_bolt/listener/listener_error_handler.py | 2 +- slack_bolt/listener_matcher/builtins.py | 4 ++-- slack_bolt/{util => request}/payload_utils.py | 0 6 files changed, 6 insertions(+), 6 deletions(-) rename slack_bolt/{util => request}/payload_utils.py (100%) diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index 58704e2bc..2c8c8f68d 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -5,7 +5,7 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from .async_args import AsyncArgs -from slack_bolt.util.payload_utils import ( +from slack_bolt.request.payload_utils import ( to_options, to_shortcut, to_action, diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 5519b2a5e..6338e4ad2 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -5,7 +5,7 @@ from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from .args import Args -from slack_bolt.util.payload_utils import ( +from slack_bolt.request.payload_utils import ( to_options, to_shortcut, to_action, diff --git a/slack_bolt/listener/async_listener_error_handler.py b/slack_bolt/listener/async_listener_error_handler.py index 86e311098..cbe839038 100644 --- a/slack_bolt/listener/async_listener_error_handler.py +++ b/slack_bolt/listener/async_listener_error_handler.py @@ -6,7 +6,7 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.util.payload_utils import ( +from slack_bolt.request.payload_utils import ( to_options, to_shortcut, to_action, diff --git a/slack_bolt/listener/listener_error_handler.py b/slack_bolt/listener/listener_error_handler.py index 7526920b0..5854fb356 100644 --- a/slack_bolt/listener/listener_error_handler.py +++ b/slack_bolt/listener/listener_error_handler.py @@ -6,7 +6,7 @@ from slack_bolt.request.request import BoltRequest from slack_bolt.response.response import BoltResponse -from slack_bolt.util.payload_utils import ( +from slack_bolt.request.payload_utils import ( to_options, to_shortcut, to_action, diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 6c0b2077a..3a34d855c 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -2,8 +2,8 @@ import inspect import sys -from ..error import BoltError -from ..util.payload_utils import ( +from slack_bolt.error import BoltError +from slack_bolt.request.payload_utils import ( is_block_actions, is_global_shortcut, is_message_shortcut, diff --git a/slack_bolt/util/payload_utils.py b/slack_bolt/request/payload_utils.py similarity index 100% rename from slack_bolt/util/payload_utils.py rename to slack_bolt/request/payload_utils.py From 31599be6a5fb2cdde8397ebfa8388481cb51dba6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 2 Nov 2020 09:12:18 +0900 Subject: [PATCH 166/865] Add default to CallbackOptions handler args --- examples/oauth_app_settings.py | 5 ++++- slack_bolt/oauth/async_callback_options.py | 6 ++++++ slack_bolt/oauth/async_oauth_flow.py | 22 ++++++++++++++++------ slack_bolt/oauth/callback_options.py | 6 ++++++ slack_bolt/oauth/oauth_flow.py | 22 ++++++++++++++++------ 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/examples/oauth_app_settings.py b/examples/oauth_app_settings.py index 0d1e39e81..4975de519 100644 --- a/examples/oauth_app_settings.py +++ b/examples/oauth_app_settings.py @@ -18,7 +18,10 @@ def success(args: SuccessArgs) -> BoltResponse: - return BoltResponse(status=200, body="Thanks!") + # Do anything here ... + # Call the default handler to return HTTP response + return args.default.success(args) + # return BoltResponse(status=200, body="Thanks!") def failure(args: FailureArgs) -> BoltResponse: diff --git a/slack_bolt/oauth/async_callback_options.py b/slack_bolt/oauth/async_callback_options.py index 6acdbd3aa..24c453b53 100644 --- a/slack_bolt/oauth/async_callback_options.py +++ b/slack_bolt/oauth/async_callback_options.py @@ -17,16 +17,19 @@ def __init__( # type: ignore request: AsyncBoltRequest, installation: Installation, settings: "AsyncOAuthSettings", + default: "AsyncCallbackOptions", ): """The arguments for a success function. :param request: The request. :param installation: The installation data. :param settings: The settings for OAuth flow. + :param default: The default AsyncCallbackOptions. """ self.request = request self.installation = installation self.settings = settings + self.default = default class AsyncFailureArgs: @@ -38,6 +41,7 @@ def __init__( # type: ignore error: Optional[Exception] = None, suggested_status_code: int, settings: "AsyncOAuthSettings", + default: "AsyncCallbackOptions", ): """The arguments for a failure function. @@ -46,12 +50,14 @@ def __init__( # type: ignore :param error: An exception if exists. :param suggested_status_code: The recommended HTTP status code for the failure. :param settings: The settings for OAuth flow. + :param default: The default AsyncCallbackOptions. """ self.request = request self.reason = reason self.error = error self.suggested_status_code = suggested_status_code self.settings = settings + self.default = default class AsyncCallbackOptions: diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 3ac865ca5..40c5cc315 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -69,12 +69,13 @@ def __init__( self.install_path = self.settings.install_path self.redirect_uri_path = self.settings.redirect_uri_path + self.default_callback_options = DefaultAsyncCallbackOptions( + logger=logger, + state_utils=self.settings.state_utils, + redirect_uri_page_renderer=self.settings.redirect_uri_page_renderer, + ) if settings.callback_options is None: - settings.callback_options = DefaultAsyncCallbackOptions( - logger=logger, - state_utils=self.settings.state_utils, - redirect_uri_page_renderer=self.settings.redirect_uri_page_renderer, - ) + settings.callback_options = self.default_callback_options self.success_handler = settings.callback_options.success self.failure_handler = settings.callback_options.failure @@ -186,6 +187,7 @@ async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse: reason=error, # type: ignore suggested_status_code=200, settings=self.settings, + default=self.default_callback_options, ) ) @@ -198,6 +200,7 @@ async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse: reason="invalid_browser", suggested_status_code=400, settings=self.settings, + default=self.default_callback_options, ) ) @@ -209,6 +212,7 @@ async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse: reason="invalid_state", suggested_status_code=401, settings=self.settings, + default=self.default_callback_options, ) ) @@ -221,6 +225,7 @@ async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse: reason="missing_code", suggested_status_code=401, settings=self.settings, + default=self.default_callback_options, ) ) @@ -233,6 +238,7 @@ async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse: reason="invalid_code", suggested_status_code=401, settings=self.settings, + default=self.default_callback_options, ) ) @@ -247,13 +253,17 @@ async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse: error=err, suggested_status_code=500, settings=self.settings, + default=self.default_callback_options, ) ) # display a successful completion page to the end-user return await self.success_handler( AsyncSuccessArgs( - request=request, installation=installation, settings=self.settings, + request=request, + installation=installation, + settings=self.settings, + default=self.default_callback_options, ) ) diff --git a/slack_bolt/oauth/callback_options.py b/slack_bolt/oauth/callback_options.py index e1368dae1..52a87659c 100644 --- a/slack_bolt/oauth/callback_options.py +++ b/slack_bolt/oauth/callback_options.py @@ -17,16 +17,19 @@ def __init__( # type: ignore request: BoltRequest, installation: Installation, settings: "OAuthSettings", + default: "CallbackOptions", ): """The arguments for a success function. :param request: The request. :param installation: The installation data. :param settings: The settings for OAuth flow. + :param default: The default CallbackOptions. """ self.request = request self.installation = installation self.settings = settings + self.default = default class FailureArgs: @@ -38,6 +41,7 @@ def __init__( # type: ignore error: Optional[Exception] = None, suggested_status_code: int, settings: "OAuthSettings", + default: "CallbackOptions", ): """The arguments for a failure function. @@ -46,12 +50,14 @@ def __init__( # type: ignore :param error: An exception if exists. :param suggested_status_code: The recommended HTTP status code for the failure. :param settings: The settings for OAuth flow. + :param default: The default CallbackOptions. """ self.request = request self.reason = reason self.error = error self.suggested_status_code = suggested_status_code self.settings = settings + self.default = default class CallbackOptions: diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index 8d37709d0..5b511aff7 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -69,12 +69,13 @@ def __init__( self.install_path = self.settings.install_path self.redirect_uri_path = self.settings.redirect_uri_path + self.default_callback_options = DefaultCallbackOptions( + logger=logger, + state_utils=self.settings.state_utils, + redirect_uri_page_renderer=self.settings.redirect_uri_page_renderer, + ) if settings.callback_options is None: - settings.callback_options = DefaultCallbackOptions( - logger=logger, - state_utils=self.settings.state_utils, - redirect_uri_page_renderer=self.settings.redirect_uri_page_renderer, - ) + settings.callback_options = self.default_callback_options self.success_handler = settings.callback_options.success self.failure_handler = settings.callback_options.failure @@ -186,6 +187,7 @@ def handle_callback(self, request: BoltRequest) -> BoltResponse: reason=error, suggested_status_code=200, settings=self.settings, + default=self.default_callback_options, ) ) @@ -198,6 +200,7 @@ def handle_callback(self, request: BoltRequest) -> BoltResponse: reason="invalid_browser", suggested_status_code=400, settings=self.settings, + default=self.default_callback_options, ) ) @@ -209,6 +212,7 @@ def handle_callback(self, request: BoltRequest) -> BoltResponse: reason="invalid_state", suggested_status_code=401, settings=self.settings, + default=self.default_callback_options, ) ) @@ -221,6 +225,7 @@ def handle_callback(self, request: BoltRequest) -> BoltResponse: reason="missing_code", suggested_status_code=401, settings=self.settings, + default=self.default_callback_options, ) ) @@ -233,6 +238,7 @@ def handle_callback(self, request: BoltRequest) -> BoltResponse: reason="invalid_code", suggested_status_code=401, settings=self.settings, + default=self.default_callback_options, ) ) @@ -247,13 +253,17 @@ def handle_callback(self, request: BoltRequest) -> BoltResponse: error=err, suggested_status_code=500, settings=self.settings, + default=self.default_callback_options, ) ) # display a successful completion page to the end-user return self.success_handler( SuccessArgs( - request=request, installation=installation, settings=self.settings, + request=request, + installation=installation, + settings=self.settings, + default=self.default_callback_options, ) ) From d4933199eb6c5205f376e71d6d538999a7054c3a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 3 Nov 2020 20:17:23 +0900 Subject: [PATCH 167/865] Fix #140 by updating the default install page handler --- slack_bolt/oauth/async_oauth_flow.py | 33 +++++++++++-------- slack_bolt/oauth/internals.py | 19 +++++++++++ slack_bolt/oauth/oauth_flow.py | 33 +++++++++++-------- tests/adapter_tests/test_aws_chalice.py | 5 ++- tests/adapter_tests/test_aws_lambda.py | 10 ++++-- tests/adapter_tests/test_bottle_oauth.py | 9 ++--- tests/adapter_tests/test_cherrypy_oauth.py | 2 +- tests/adapter_tests/test_django.py | 7 +++- tests/adapter_tests/test_falcon.py | 7 ++-- tests/adapter_tests/test_fastapi.py | 9 +++-- tests/adapter_tests/test_flask.py | 2 +- tests/adapter_tests/test_pyramid.py | 5 ++- tests/adapter_tests/test_starlette.py | 9 +++-- tests/adapter_tests/test_tornado_oauth.py | 4 +-- .../adapter_tests_async/test_async_fastapi.py | 9 +++-- tests/adapter_tests_async/test_async_sanic.py | 10 +++--- .../test_async_starlette.py | 10 +++--- tests/slack_bolt/oauth/test_oauth_flow.py | 15 +++------ .../oauth/test_oauth_flow_sqlite3.py | 15 +++------ .../oauth/test_async_oauth_flow.py | 15 +++------ .../oauth/test_async_oauth_flow_sqlite3.py | 15 +++------ 21 files changed, 128 insertions(+), 115 deletions(-) diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 40c5cc315..b5c1b2fae 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -11,6 +11,7 @@ AsyncFailureArgs, ) from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from slack_bolt.oauth.internals import _build_default_install_page_html from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from slack_sdk.errors import SlackApiError @@ -151,7 +152,20 @@ def sqlite3( async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse: state = await self.issue_new_state(request) - return await self.build_authorize_url_redirection(request, state) + url = await self.build_authorize_url(state, request) + html = await self.build_install_page_html(url, request) + set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( + state + ) + return BoltResponse( + status=200, + body=html, + headers={ + "Content-Type": "text/html; charset=utf-8", + "Content-Length": len(bytes(html, "utf-8")), + "Set-Cookie": [set_cookie_value], + }, + ) # ---------------------- # Internal methods for Installation @@ -159,18 +173,11 @@ async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse: async def issue_new_state(self, request: AsyncBoltRequest) -> str: return await self.settings.state_store.async_issue() - async def build_authorize_url_redirection( - self, request: AsyncBoltRequest, state: str - ) -> BoltResponse: - return BoltResponse( - status=302, - headers={ - "Location": [self.settings.authorize_url_generator.generate(state)], - "Set-Cookie": [ - self.settings.state_utils.build_set_cookie_for_new_state(state) - ], - }, - ) + async def build_authorize_url(self, state: str, request: AsyncBoltRequest) -> str: + return self.settings.authorize_url_generator.generate(state) + + async def build_install_page_html(self, url: str, request: AsyncBoltRequest) -> str: + return _build_default_install_page_html(url) # ----------------------------- # Callback diff --git a/slack_bolt/oauth/internals.py b/slack_bolt/oauth/internals.py index 510f59bfb..6cfb53f4a 100644 --- a/slack_bolt/oauth/internals.py +++ b/slack_bolt/oauth/internals.py @@ -64,3 +64,22 @@ def _build_callback_failure_response( # type: ignore }, body=html, ) + + +def _build_default_install_page_html(url: str) -> str: + return f""" + + + + +

    Slack App Installation

    +

    + + +""" diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index 5b511aff7..c6b39023b 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -10,6 +10,7 @@ DefaultCallbackOptions, CallbackOptions, ) +from slack_bolt.oauth.internals import _build_default_install_page_html from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.request import BoltRequest @@ -151,7 +152,20 @@ def sqlite3( def handle_installation(self, request: BoltRequest) -> BoltResponse: state = self.issue_new_state(request) - return self.build_authorize_url_redirection(request, state) + url = self.build_authorize_url(state, request) + html = self.build_install_page_html(url, request) + set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( + state + ) + return BoltResponse( + status=200, + body=html, + headers={ + "Content-Type": "text/html; charset=utf-8", + "Content-Length": len(bytes(html, "utf-8")), + "Set-Cookie": [set_cookie_value], + }, + ) # ---------------------- # Internal methods for Installation @@ -159,18 +173,11 @@ def handle_installation(self, request: BoltRequest) -> BoltResponse: def issue_new_state(self, request: BoltRequest) -> str: return self.settings.state_store.issue() - def build_authorize_url_redirection( - self, request: BoltRequest, state: str - ) -> BoltResponse: - return BoltResponse( - status=302, - headers={ - "Location": [self.settings.authorize_url_generator.generate(state)], - "Set-Cookie": [ - self.settings.state_utils.build_set_cookie_for_new_state(state) - ], - }, - ) + def build_authorize_url(self, state: str, request: BoltRequest) -> str: + return self.settings.authorize_url_generator.generate(state) + + def build_install_page_html(self, url: str, request: BoltRequest) -> str: + return _build_default_install_page_html(url) # ----------------------------- # Callback diff --git a/tests/adapter_tests/test_aws_chalice.py b/tests/adapter_tests/test_aws_chalice.py index 9ee64dd49..80125027c 100644 --- a/tests/adapter_tests/test_aws_chalice.py +++ b/tests/adapter_tests/test_aws_chalice.py @@ -272,4 +272,7 @@ def install() -> Response: response: Dict[str, Any] = LocalGateway(chalice_app, Config()).handle_request( method="GET", path="/slack/install", body="", headers={} ) - assert response["statusCode"] == 302 + assert response["statusCode"] == 200 + assert response["headers"]["content-type"] == "text/html; charset=utf-8" + assert response["headers"]["content-length"] == "565" + assert response.get("body") is not None diff --git a/tests/adapter_tests/test_aws_lambda.py b/tests/adapter_tests/test_aws_lambda.py index 62793a837..4c8683c12 100644 --- a/tests/adapter_tests/test_aws_lambda.py +++ b/tests/adapter_tests/test_aws_lambda.py @@ -283,7 +283,10 @@ def test_oauth(self): "isBase64Encoded": False, } response = SlackRequestHandler(app).handle(event, self.context) - assert response["statusCode"] == 302 + assert response["statusCode"] == 200 + assert response["headers"]["content-type"] == "text/html; charset=utf-8" + assert response["headers"]["content-length"] == "565" + assert response.get("body") is not None event = { "body": "", @@ -293,4 +296,7 @@ def test_oauth(self): "isBase64Encoded": False, } response = SlackRequestHandler(app).handle(event, self.context) - assert response["statusCode"] == 302 + assert response["statusCode"] == 200 + assert response["headers"]["content-type"] == "text/html; charset=utf-8" + assert response["headers"]["content-length"] == "565" + assert response.get("body") is not None diff --git a/tests/adapter_tests/test_bottle_oauth.py b/tests/adapter_tests/test_bottle_oauth.py index b45c71631..1984bfab3 100644 --- a/tests/adapter_tests/test_bottle_oauth.py +++ b/tests/adapter_tests/test_bottle_oauth.py @@ -26,9 +26,6 @@ class TestBottle: def test_oauth(self): with boddle(method="GET", path="/slack/install"): response_body = install() - assert response_body == "" - assert response.status_code == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], - ) + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response_body diff --git a/tests/adapter_tests/test_cherrypy_oauth.py b/tests/adapter_tests/test_cherrypy_oauth.py index cbda0b06c..ce8524bc8 100644 --- a/tests/adapter_tests/test_cherrypy_oauth.py +++ b/tests/adapter_tests/test_cherrypy_oauth.py @@ -49,4 +49,4 @@ def teardown_class(cls): def test_oauth(self): cherrypy.request.process_request_body = False self.getPage("/slack/install", method="GET") - self.assertStatus("302 Found") + self.assertStatus("200 OK") diff --git a/tests/adapter_tests/test_django.py b/tests/adapter_tests/test_django.py index 108dc256d..21836fc50 100644 --- a/tests/adapter_tests/test_django.py +++ b/tests/adapter_tests/test_django.py @@ -170,4 +170,9 @@ def test_oauth(self): ) request = self.rf.get("/slack/install") response = SlackRequestHandler(app).handle(request) - assert response.status_code == 302 + assert response.status_code == 200 + assert response.get("content-type") == "text/html; charset=utf-8" + assert response.get("content-length") == "565" + assert "https://slack.com/oauth/v2/authorize?state=" in response.content.decode( + "utf-8" + ) diff --git a/tests/adapter_tests/test_falcon.py b/tests/adapter_tests/test_falcon.py index 8a00d90fe..621b767c5 100644 --- a/tests/adapter_tests/test_falcon.py +++ b/tests/adapter_tests/test_falcon.py @@ -179,8 +179,5 @@ def test_oauth(self): client = testing.TestClient(api) response = client.simulate_get("/slack/install") - assert response.status_code == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], - ) + assert response.status_code == 200 + assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/adapter_tests/test_fastapi.py b/tests/adapter_tests/test_fastapi.py index 6bc01c4ca..fa6c18feb 100644 --- a/tests/adapter_tests/test_fastapi.py +++ b/tests/adapter_tests/test_fastapi.py @@ -192,8 +192,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.get("/slack/install", allow_redirects=False) - assert response.status_code == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], - ) + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert response.headers.get("content-length") == "565" + assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/adapter_tests/test_flask.py b/tests/adapter_tests/test_flask.py index 6776b98f8..30d58fc35 100644 --- a/tests/adapter_tests/test_flask.py +++ b/tests/adapter_tests/test_flask.py @@ -185,4 +185,4 @@ def endpoint(): with flask_app.test_client() as client: rv = client.get("/slack/install") - assert rv.status_code == 302 + assert rv.status_code == 200 diff --git a/tests/adapter_tests/test_pyramid.py b/tests/adapter_tests/test_pyramid.py index 5e4180151..5c5d78cd8 100644 --- a/tests/adapter_tests/test_pyramid.py +++ b/tests/adapter_tests/test_pyramid.py @@ -175,4 +175,7 @@ def test_oauth(self): request.path = "/slack/install" request.method = "GET" response: Response = SlackRequestHandler(app).handle(request) - assert response.status_code == 302 + assert response.status_code == 200 + assert "https://slack.com/oauth/v2/authorize?state=" in response.body.decode( + "utf-8" + ) diff --git a/tests/adapter_tests/test_starlette.py b/tests/adapter_tests/test_starlette.py index f662e8840..a8842c43d 100644 --- a/tests/adapter_tests/test_starlette.py +++ b/tests/adapter_tests/test_starlette.py @@ -201,8 +201,7 @@ async def endpoint(req: Request): ) client = TestClient(api) response = client.get("/slack/install", allow_redirects=False) - assert response.status_code == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], - ) + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert response.headers.get("content-length") == "565" + assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/adapter_tests/test_tornado_oauth.py b/tests/adapter_tests/test_tornado_oauth.py index dd892f3bc..c42468f2a 100644 --- a/tests/adapter_tests/test_tornado_oauth.py +++ b/tests/adapter_tests/test_tornado_oauth.py @@ -37,6 +37,6 @@ async def test_oauth(self): ) try: response: HTTPResponse = await self.http_client.fetch(request) - assert response.code == 302 + assert response.code == 200 except tornado.httpclient.HTTPClientError as e: - assert e.code == 302 + assert e.code == 200 diff --git a/tests/adapter_tests_async/test_async_fastapi.py b/tests/adapter_tests_async/test_async_fastapi.py index 6c08011a4..580942a9c 100644 --- a/tests/adapter_tests_async/test_async_fastapi.py +++ b/tests/adapter_tests_async/test_async_fastapi.py @@ -192,8 +192,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.get("/slack/install", allow_redirects=False) - assert response.status_code == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], - ) + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert response.headers.get("content-length") == "565" + assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py index ef0438f1c..f9237026f 100644 --- a/tests/adapter_tests_async/test_async_sanic.py +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -199,8 +199,8 @@ async def endpoint(req: Request): _, response = await api.asgi_client.get( url="/slack/install", allow_redirects=False ) - assert response.status_code == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], - ) + assert response.status_code == 200 + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert response.headers.get("content-length") == "565" + assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/adapter_tests_async/test_async_starlette.py b/tests/adapter_tests_async/test_async_starlette.py index 2214e5c59..ae1903325 100644 --- a/tests/adapter_tests_async/test_async_starlette.py +++ b/tests/adapter_tests_async/test_async_starlette.py @@ -202,8 +202,8 @@ async def endpoint(req: Request): client = TestClient(api) response = client.get("/slack/install", allow_redirects=False) - assert response.status_code == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], - ) + assert response.status_code == 200 + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert response.headers.get("content-length") == "565" + assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/slack_bolt/oauth/test_oauth_flow.py b/tests/slack_bolt/oauth/test_oauth_flow.py index e228890b9..f3b624c54 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow.py +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -53,17 +53,10 @@ def test_handle_installation(self): ) req = BoltRequest(body="") resp = oauth_flow.handle_installation(req) - assert resp.status == 302 - url = resp.headers["location"][0] - assert ( - re.compile( - "https://slack.com/oauth/v2/authorize\\?state=[-0-9a-z]+." - "&client_id=111\\.222" - "&scope=chat:write,commands" - "&user_scope=" - ).match(url) - is not None - ) + assert resp.status == 200 + assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] + assert resp.headers.get("content-length") == ["565"] + assert "https://slack.com/oauth/v2/authorize?state=" in resp.body def test_handle_callback(self): oauth_flow = OAuthFlow( diff --git a/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py index 367349cc2..f9e3a6aae 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py +++ b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py @@ -41,17 +41,10 @@ def test_handle_installation(self): ) req = BoltRequest(body="") resp = oauth_flow.handle_installation(req) - assert resp.status == 302 - url = resp.headers["location"][0] - assert ( - re.compile( - "https://slack.com/oauth/v2/authorize\\?state=[-0-9a-z]+." - "&client_id=111\\.222" - "&scope=chat:write,commands" - "&user_scope=" - ).match(url) - is not None - ) + assert resp.status == 200 + assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] + assert resp.headers.get("content-length") == ["565"] + assert "https://slack.com/oauth/v2/authorize?state=" in resp.body def test_handle_callback(self): oauth_flow = OAuthFlow.sqlite3( diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index c70e3dfeb..2537f6e10 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -68,17 +68,10 @@ async def test_handle_installation(self): ) req = AsyncBoltRequest(body="") resp = await oauth_flow.handle_installation(req) - assert resp.status == 302 - url = resp.headers["location"][0] - assert ( - re.compile( - "https://slack.com/oauth/v2/authorize\\?state=[-0-9a-z]+." - "&client_id=111\\.222" - "&scope=chat:write,commands" - "&user_scope=" - ).match(url) - is not None - ) + assert resp.status == 200 + assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] + assert resp.headers.get("content-length") == ["565"] + assert "https://slack.com/oauth/v2/authorize?state=" in resp.body @pytest.mark.asyncio async def test_handle_callback(self): diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py index 4697f6e2a..93a054b0f 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py @@ -54,17 +54,10 @@ async def test_handle_installation(self): ) req = AsyncBoltRequest(body="") resp = await oauth_flow.handle_installation(req) - assert resp.status == 302 - url = resp.headers["location"][0] - assert ( - re.compile( - "https://slack.com/oauth/v2/authorize\\?state=[-0-9a-z]+." - "&client_id=111\\.222" - "&scope=chat:write,commands" - "&user_scope=" - ).match(url) - is not None - ) + assert resp.status == 200 + assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] + assert resp.headers.get("content-length") == ["565"] + assert "https://slack.com/oauth/v2/authorize?state=" in resp.body @pytest.mark.asyncio async def test_handle_callback(self): From 68e9c253cc936e0f03143cb4a2575affa0bf247e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 4 Nov 2020 13:54:31 +0900 Subject: [PATCH 168/865] Tweak test code --- tests/adapter_tests/test_aws_chalice.py | 2 +- tests/adapter_tests/test_aws_lambda.py | 2 +- tests/adapter_tests/test_bottle_oauth.py | 2 -- tests/adapter_tests/test_falcon.py | 1 - tests/adapter_tests/test_fastapi.py | 1 - tests/adapter_tests/test_starlette.py | 1 - tests/adapter_tests_async/test_async_fastapi.py | 1 - tests/adapter_tests_async/test_async_sanic.py | 2 -- tests/adapter_tests_async/test_async_starlette.py | 2 -- tests/slack_bolt/oauth/test_oauth_flow.py | 1 - tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py | 2 -- tests/slack_bolt_async/oauth/test_async_oauth_flow.py | 1 - tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py | 1 - 13 files changed, 2 insertions(+), 17 deletions(-) diff --git a/tests/adapter_tests/test_aws_chalice.py b/tests/adapter_tests/test_aws_chalice.py index 80125027c..a9f4912f7 100644 --- a/tests/adapter_tests/test_aws_chalice.py +++ b/tests/adapter_tests/test_aws_chalice.py @@ -275,4 +275,4 @@ def install() -> Response: assert response["statusCode"] == 200 assert response["headers"]["content-type"] == "text/html; charset=utf-8" assert response["headers"]["content-length"] == "565" - assert response.get("body") is not None + assert "https://slack.com/oauth/v2/authorize?state=" in response.get("body") diff --git a/tests/adapter_tests/test_aws_lambda.py b/tests/adapter_tests/test_aws_lambda.py index 4c8683c12..d2e638efd 100644 --- a/tests/adapter_tests/test_aws_lambda.py +++ b/tests/adapter_tests/test_aws_lambda.py @@ -299,4 +299,4 @@ def test_oauth(self): assert response["statusCode"] == 200 assert response["headers"]["content-type"] == "text/html; charset=utf-8" assert response["headers"]["content-length"] == "565" - assert response.get("body") is not None + assert "https://slack.com/oauth/v2/authorize?state=" in response.get("body") diff --git a/tests/adapter_tests/test_bottle_oauth.py b/tests/adapter_tests/test_bottle_oauth.py index 1984bfab3..37fd6b25d 100644 --- a/tests/adapter_tests/test_bottle_oauth.py +++ b/tests/adapter_tests/test_bottle_oauth.py @@ -1,5 +1,3 @@ -import re - from slack_bolt.adapter.bottle import SlackRequestHandler from slack_bolt.app import App from slack_bolt.oauth.oauth_settings import OAuthSettings diff --git a/tests/adapter_tests/test_falcon.py b/tests/adapter_tests/test_falcon.py index 621b767c5..e18374473 100644 --- a/tests/adapter_tests/test_falcon.py +++ b/tests/adapter_tests/test_falcon.py @@ -1,5 +1,4 @@ import json -import re from time import time from urllib.parse import quote diff --git a/tests/adapter_tests/test_fastapi.py b/tests/adapter_tests/test_fastapi.py index fa6c18feb..2de6ef069 100644 --- a/tests/adapter_tests/test_fastapi.py +++ b/tests/adapter_tests/test_fastapi.py @@ -1,5 +1,4 @@ import json -import re from time import time from urllib.parse import quote diff --git a/tests/adapter_tests/test_starlette.py b/tests/adapter_tests/test_starlette.py index a8842c43d..9ab513d26 100644 --- a/tests/adapter_tests/test_starlette.py +++ b/tests/adapter_tests/test_starlette.py @@ -1,5 +1,4 @@ import json -import re from time import time from urllib.parse import quote diff --git a/tests/adapter_tests_async/test_async_fastapi.py b/tests/adapter_tests_async/test_async_fastapi.py index 580942a9c..41b8a332e 100644 --- a/tests/adapter_tests_async/test_async_fastapi.py +++ b/tests/adapter_tests_async/test_async_fastapi.py @@ -1,5 +1,4 @@ import json -import re from time import time from urllib.parse import quote diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py index f9237026f..587f858b6 100644 --- a/tests/adapter_tests_async/test_async_sanic.py +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -1,6 +1,5 @@ import asyncio import json -import re from time import time from urllib.parse import quote @@ -200,7 +199,6 @@ async def endpoint(req: Request): url="/slack/install", allow_redirects=False ) assert response.status_code == 200 - assert response.status_code == 200 assert response.headers.get("content-type") == "text/html; charset=utf-8" assert response.headers.get("content-length") == "565" assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/adapter_tests_async/test_async_starlette.py b/tests/adapter_tests_async/test_async_starlette.py index ae1903325..17c81715f 100644 --- a/tests/adapter_tests_async/test_async_starlette.py +++ b/tests/adapter_tests_async/test_async_starlette.py @@ -1,5 +1,4 @@ import json -import re from time import time from urllib.parse import quote @@ -203,7 +202,6 @@ async def endpoint(req: Request): client = TestClient(api) response = client.get("/slack/install", allow_redirects=False) assert response.status_code == 200 - assert response.status_code == 200 assert response.headers.get("content-type") == "text/html; charset=utf-8" assert response.headers.get("content-length") == "565" assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/slack_bolt/oauth/test_oauth_flow.py b/tests/slack_bolt/oauth/test_oauth_flow.py index f3b624c54..5d390f7bf 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow.py +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -1,5 +1,4 @@ import json -import re from time import time from urllib.parse import quote diff --git a/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py index f9e3a6aae..00b3ad12b 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py +++ b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py @@ -1,5 +1,3 @@ -import re - from slack_sdk import WebClient from slack_bolt import BoltRequest, BoltResponse diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index 2537f6e10..85968ed0c 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -1,6 +1,5 @@ import asyncio import json -import re from time import time from urllib.parse import quote diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py index 93a054b0f..3c17ca49e 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py @@ -1,5 +1,4 @@ import asyncio -import re import pytest from slack_sdk.web.async_client import AsyncWebClient From c2795900947eb8bc8ac8b77860edef4850d130c4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 1 Nov 2020 11:10:37 +0900 Subject: [PATCH 169/865] Improve the app.installation_store field consistency --- slack_bolt/app/app.py | 24 +++++++++---- slack_bolt/app/async_app.py | 25 +++++++++---- slack_bolt/logger/messages.py | 4 +++ slack_bolt/oauth/async_internals.py | 45 ++++++++++++++++++++++++ slack_bolt/oauth/async_oauth_settings.py | 6 ++-- slack_bolt/oauth/internals.py | 43 +++++++++++++++++++++- slack_bolt/oauth/oauth_settings.py | 6 ++-- tests/scenario_tests/test_app.py | 34 ++++++++++++++++++ tests/scenario_tests_async/test_app.py | 45 +++++++++++++++++++++--- 9 files changed, 208 insertions(+), 24 deletions(-) create mode 100644 slack_bolt/oauth/async_internals.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index e0e61fc20..cc97b19ba 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -55,6 +55,7 @@ from slack_bolt.middleware.message_listener_matches import MessageListenerMatches from slack_bolt.middleware.url_verification import UrlVerification from slack_bolt.oauth import OAuthFlow +from slack_bolt.oauth.internals import select_consistent_installation_store from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse @@ -157,17 +158,28 @@ def __init__( if oauth_flow: self._oauth_flow = oauth_flow - if self._installation_store is None: - self._installation_store = self._oauth_flow.settings.installation_store + installation_store = select_consistent_installation_store( + client_id=self._oauth_flow.client_id, + app_store=self._installation_store, + oauth_flow_store=self._oauth_flow.settings.installation_store, + logger=self._framework_logger, + ) + self._installation_store = installation_store + self._oauth_flow.settings.installation_store = installation_store + if self._oauth_flow._client is None: self._oauth_flow._client = self._client if self._authorize is None: self._authorize = self._oauth_flow.settings.authorize elif oauth_settings is not None: - if self._installation_store: - # Consistently use a single installation_store - oauth_settings.installation_store = self._installation_store - + installation_store = select_consistent_installation_store( + client_id=oauth_settings.client_id, + app_store=self._installation_store, + oauth_flow_store=oauth_settings.installation_store, + logger=self._framework_logger, + ) + self._installation_store = installation_store + oauth_settings.installation_store = installation_store self._oauth_flow = OAuthFlow( client=self.client, logger=self.logger, settings=oauth_settings ) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 1051e0b4d..9e3db0cb4 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -7,6 +7,7 @@ from slack_bolt.middleware.message_listener_matches.async_message_listener_matches import ( AsyncMessageListenerMatches, ) +from slack_bolt.oauth.async_internals import select_consistent_installation_store from slack_bolt.workflows.step.async_step import AsyncWorkflowStep from slack_bolt.workflows.step.async_step_middleware import AsyncWorkflowStepMiddleware from slack_sdk.oauth.installation_store.async_installation_store import ( @@ -167,18 +168,28 @@ def __init__( if oauth_flow: self._async_oauth_flow = oauth_flow - if self._async_installation_store is None: - self._async_installation_store = ( - self._async_oauth_flow.settings.installation_store - ) + installation_store = select_consistent_installation_store( + client_id=self._async_oauth_flow.client_id, + app_store=self._async_installation_store, + oauth_flow_store=self._async_oauth_flow.settings.installation_store, + logger=self._framework_logger, + ) + self._async_installation_store = installation_store + self._async_oauth_flow.settings.installation_store = installation_store + if self._async_oauth_flow._async_client is None: self._async_oauth_flow._async_client = self._async_client if self._async_authorize is None: self._async_authorize = self._async_oauth_flow.settings.authorize elif oauth_settings is not None: - if self._async_installation_store: - # Consistently use a single installation_store - oauth_settings.installation_store = self._async_installation_store + installation_store = select_consistent_installation_store( + client_id=oauth_settings.client_id, + app_store=self._async_installation_store, + oauth_flow_store=oauth_settings.installation_store, + logger=self._framework_logger, + ) + self._async_installation_store = installation_store + oauth_settings.installation_store = installation_store self._async_oauth_flow = AsyncOAuthFlow( client=self._async_client, logger=self.logger, settings=oauth_settings diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 75abe3e39..29ee7bfe0 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -64,6 +64,10 @@ def warning_token_skipped() -> str: ) +def warning_installation_store_conflicts() -> str: + return "As you gave both `installation_store` and `oauth_settings`/`auth_flow`, the top level one is unused." + + def warning_unhandled_request(req: Union[BoltRequest, "AsyncBoltRequest"]) -> str: # type: ignore return f"Unhandled request ({req.body})" diff --git a/slack_bolt/oauth/async_internals.py b/slack_bolt/oauth/async_internals.py new file mode 100644 index 000000000..be46a9d94 --- /dev/null +++ b/slack_bolt/oauth/async_internals.py @@ -0,0 +1,45 @@ +from logging import Logger +from typing import Optional + +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) + +from ..logger.messages import warning_installation_store_conflicts + +# key: client_id, value: AsyncInstallationStore +default_installation_stores = {} + + +def get_or_create_default_installation_store(client_id: str) -> AsyncInstallationStore: + store = default_installation_stores.get(client_id) + if store is None: + store = FileInstallationStore(client_id=client_id) + default_installation_stores[client_id] = store + return store + + +def select_consistent_installation_store( + client_id: str, + app_store: Optional[AsyncInstallationStore], + oauth_flow_store: Optional[AsyncInstallationStore], + logger: Logger, +) -> Optional[AsyncInstallationStore]: + default = get_or_create_default_installation_store(client_id) + if app_store is not None: + if oauth_flow_store is not None: + if oauth_flow_store is default: + # only app_store is intentionally set in this case + return app_store + + # if both are intentionally set, prioritize app_store + if oauth_flow_store is not app_store: + logger.warning(warning_installation_store_conflicts()) + return oauth_flow_store + else: + # only app_store is available + return app_store + else: + # only oauth_flow_store is available + return oauth_flow_store diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index 01b2e43a5..89fb8e625 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -8,7 +8,6 @@ AuthorizeUrlGenerator, RedirectUriPageRenderer, ) -from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.installation_store.async_installation_store import ( AsyncInstallationStore, ) @@ -20,6 +19,7 @@ AsyncAuthorize, ) from slack_bolt.error import BoltError +from slack_bolt.oauth.async_internals import get_or_create_default_installation_store from slack_bolt.oauth.callback_options import CallbackOptions @@ -122,8 +122,8 @@ def __init__( authorization_url or "https://slack.com/oauth/v2/authorize" ) # Installation Management - self.installation_store = installation_store or FileInstallationStore( - client_id=client_id + self.installation_store = ( + installation_store or get_or_create_default_installation_store(client_id) ) self.authorize = AsyncInstallationStoreAuthorize( logger=logger, installation_store=self.installation_store, diff --git a/slack_bolt/oauth/internals.py b/slack_bolt/oauth/internals.py index 6cfb53f4a..bc237eb7a 100644 --- a/slack_bolt/oauth/internals.py +++ b/slack_bolt/oauth/internals.py @@ -1,11 +1,15 @@ from logging import Logger -from typing import Optional, Union +from typing import Optional +from typing import Union +from slack_sdk.oauth import InstallationStore from slack_sdk.oauth import OAuthStateUtils, RedirectUriPageRenderer +from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.installation_store import Installation from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse +from ..logger.messages import warning_installation_store_conflicts class CallbackResponseBuilder: @@ -83,3 +87,40 @@ def _build_default_install_page_html(url: str) -> str: """ + + +# key: client_id, value: InstallationStore +default_installation_stores = {} + + +def get_or_create_default_installation_store(client_id: str) -> InstallationStore: + store = default_installation_stores.get(client_id) + if store is None: + store = FileInstallationStore(client_id=client_id) + default_installation_stores[client_id] = store + return store + + +def select_consistent_installation_store( + client_id: str, + app_store: Optional[InstallationStore], + oauth_flow_store: Optional[InstallationStore], + logger: Logger, +) -> Optional[InstallationStore]: + default = get_or_create_default_installation_store(client_id) + if app_store is not None: + if oauth_flow_store is not None: + if oauth_flow_store is default: + # only app_store is intentionally set in this case + return app_store + + # if both are intentionally set, prioritize app_store + if oauth_flow_store is not app_store: + logger.warning(warning_installation_store_conflicts()) + return oauth_flow_store + else: + # only app_store is available + return app_store + else: + # only oauth_flow_store is available + return oauth_flow_store diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index 3cc82c834..089d7440a 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -10,11 +10,11 @@ AuthorizeUrlGenerator, RedirectUriPageRenderer, ) -from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore from slack_bolt.authorization.authorize import Authorize, InstallationStoreAuthorize from slack_bolt.error import BoltError +from slack_bolt.oauth.internals import get_or_create_default_installation_store from slack_bolt.oauth.callback_options import CallbackOptions @@ -116,8 +116,8 @@ def __init__( authorization_url or "https://slack.com/oauth/v2/authorize" ) # Installation Management - self.installation_store = installation_store or FileInstallationStore( - client_id=client_id + self.installation_store = ( + installation_store or get_or_create_default_installation_store(client_id) ) self.authorize = InstallationStoreAuthorize( logger=logger, installation_store=self.installation_store, diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index fcf7b29d6..3667a7a09 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -121,3 +121,37 @@ def authorize() -> AuthorizeResult: with pytest.raises(BoltError): App(signing_secret="valid", authorize=authorize, oauth_flow=oauth_flow) + + def test_installation_store_conflicts(self): + store1 = FileInstallationStore() + store2 = FileInstallationStore() + app = App( + signing_secret="valid", + oauth_settings=OAuthSettings( + client_id="111.222", client_secret="valid", installation_store=store1, + ), + installation_store=store2, + ) + assert app.installation_store is store1 + + app = App( + signing_secret="valid", + oauth_flow=OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=store1, + ) + ), + installation_store=store2, + ) + assert app.installation_store is store1 + + app = App( + signing_secret="valid", + oauth_flow=OAuthFlow( + settings=OAuthSettings(client_id="111.222", client_secret="valid",) + ), + installation_store=store1, + ) + assert app.installation_store is store1 diff --git a/tests/scenario_tests_async/test_app.py b/tests/scenario_tests_async/test_app.py index cd3b37865..f9e38f9ab 100644 --- a/tests/scenario_tests_async/test_app.py +++ b/tests/scenario_tests_async/test_app.py @@ -8,7 +8,6 @@ from slack_bolt.error import BoltError from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings -from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.utils import remove_os_env_temporarily, restore_os_env @@ -90,18 +89,22 @@ def test_valid_multi_auth_client_id_absence(self): with pytest.raises(BoltError): AsyncApp( signing_secret="valid", - oauth_settings=OAuthSettings(client_id=None, client_secret="valid"), + oauth_settings=AsyncOAuthSettings( + client_id=None, client_secret="valid" + ), ) def test_valid_multi_auth_secret_absence(self): with pytest.raises(BoltError): AsyncApp( signing_secret="valid", - oauth_settings=OAuthSettings(client_id="111.222", client_secret=None), + oauth_settings=AsyncOAuthSettings( + client_id="111.222", client_secret=None + ), ) def test_authorize_conflicts(self): - oauth_settings = OAuthSettings( + oauth_settings = AsyncOAuthSettings( client_id="111.222", client_secret="valid", installation_store=FileInstallationStore(), @@ -127,3 +130,37 @@ def authorize() -> AuthorizeResult: with pytest.raises(BoltError): AsyncApp(signing_secret="valid", authorize=authorize, oauth_flow=oauth_flow) + + def test_installation_store_conflicts(self): + store1 = FileInstallationStore() + store2 = FileInstallationStore() + app = AsyncApp( + signing_secret="valid", + oauth_settings=AsyncOAuthSettings( + client_id="111.222", client_secret="valid", installation_store=store1, + ), + installation_store=store2, + ) + assert app.installation_store is store1 + + app = AsyncApp( + signing_secret="valid", + oauth_flow=AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=store1, + ) + ), + installation_store=store2, + ) + assert app.installation_store is store1 + + app = AsyncApp( + signing_secret="valid", + oauth_flow=AsyncOAuthFlow( + settings=AsyncOAuthSettings(client_id="111.222", client_secret="valid",) + ), + installation_store=store1, + ) + assert app.installation_store is store1 From aa834e05606e69facd5944e2eab4250d18cf84ba Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 6 Nov 2020 12:28:08 +0900 Subject: [PATCH 170/865] Fix CI builds with pytype in Python 3.8+ --- scripts/install_all_and_run_tests.sh | 1 + scripts/run_pytype.sh | 6 ++++-- scripts/run_tests.sh | 1 + setup.py | 1 - 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index 2d5b4c14d..e861401c3 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -22,5 +22,6 @@ else pip install -e ".[adapter]" && \ black slack_bolt/ tests/ && \ pytest && \ + pip install -U pytype && \ pytype slack_bolt/ fi diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index f5424bbf0..bfdf066be 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -2,5 +2,7 @@ # ./scripts/run_pytype.sh script_dir=$(dirname $0) -cd ${script_dir}/.. -pip install -e ".[adapter]" && pytype slack_bolt/ +cd ${script_dir}/.. && \ + pip install -e ".[adapter]" && \ + pip install -U pytype && \ + pytype slack_bolt/ diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 7170abeb0..a5234a525 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -20,6 +20,7 @@ else black slack_bolt/ tests/ \ && pytest \ && pip install -e ".[adapter]" \ + && pip install -U pytype \ && pytype slack_bolt/ else black slack_bolt/ tests/ && pytest diff --git a/setup.py b/setup.py index 7d210ef96..fff675429 100755 --- a/setup.py +++ b/setup.py @@ -17,7 +17,6 @@ "pytest-asyncio<1", # for async "aiohttp>=3,<4", # for async "black==19.10b0", - "pytype", ] setuptools.setup( From 7da0f7c0644836fe3485f7d8d6cb8a78af7d0993 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 6 Nov 2020 10:22:32 +0900 Subject: [PATCH 171/865] Use Sequence type hints over List for args --- slack_bolt/adapter/aws_lambda/handler.py | 4 +- slack_bolt/adapter/aws_lambda/internals.py | 4 +- slack_bolt/app/app.py | 90 +++++++++--------- slack_bolt/app/async_app.py | 94 ++++++++++--------- slack_bolt/context/ack/ack.py | 10 +- slack_bolt/context/ack/async_ack.py | 10 +- slack_bolt/context/ack/internals.py | 12 +-- slack_bolt/context/respond/async_respond.py | 6 +- slack_bolt/context/respond/internals.py | 6 +- slack_bolt/context/respond/respond.py | 6 +- slack_bolt/context/say/async_say.py | 6 +- slack_bolt/context/say/say.py | 6 +- slack_bolt/kwargs_injection/async_utils.py | 4 +- slack_bolt/kwargs_injection/utils.py | 4 +- slack_bolt/listener/async_listener.py | 24 ++--- slack_bolt/listener/custom_listener.py | 16 ++-- slack_bolt/listener/listener.py | 8 +- .../async_listener_matcher.py | 4 +- .../custom_listener_matcher.py | 4 +- .../middleware/async_custom_middleware.py | 4 +- slack_bolt/middleware/custom_middleware.py | 4 +- slack_bolt/oauth/async_oauth_flow.py | 6 +- slack_bolt/oauth/async_oauth_settings.py | 10 +- slack_bolt/oauth/oauth_flow.py | 6 +- slack_bolt/oauth/oauth_settings.py | 10 +- slack_bolt/request/async_request.py | 11 +-- slack_bolt/request/internals.py | 16 ++-- slack_bolt/request/request.py | 11 +-- slack_bolt/response/response.py | 10 +- slack_bolt/util/utils.py | 4 +- slack_bolt/workflows/step/async_step.py | 16 ++-- slack_bolt/workflows/step/step.py | 20 ++-- .../step/utilities/async_configure.py | 4 +- .../workflows/step/utilities/configure.py | 4 +- 34 files changed, 234 insertions(+), 220 deletions(-) diff --git a/slack_bolt/adapter/aws_lambda/handler.py b/slack_bolt/adapter/aws_lambda/handler.py index c6e1e975a..885115cb3 100644 --- a/slack_bolt/adapter/aws_lambda/handler.py +++ b/slack_bolt/adapter/aws_lambda/handler.py @@ -1,6 +1,6 @@ import base64 import logging -from typing import List, Dict, Any +from typing import Dict, Any, Sequence from slack_bolt.adapter.aws_lambda.internals import _first_value from slack_bolt.adapter.aws_lambda.lazy_listener_runner import LambdaLazyListenerRunner @@ -78,7 +78,7 @@ def to_bolt_request(event) -> BoltRequest: body = event.get("body", "") if event["isBase64Encoded"]: body = base64.b64decode(body).decode("utf-8") - cookies: List[str] = event.get("cookies", []) + cookies: Sequence[str] = event.get("cookies", []) headers = event.get("headers", {}) headers["cookie"] = cookies return BoltRequest( diff --git a/slack_bolt/adapter/aws_lambda/internals.py b/slack_bolt/adapter/aws_lambda/internals.py index f5700b8a8..944d17e82 100644 --- a/slack_bolt/adapter/aws_lambda/internals.py +++ b/slack_bolt/adapter/aws_lambda/internals.py @@ -1,7 +1,7 @@ -from typing import Dict, List, Optional +from typing import Dict, Optional, Sequence -def _first_value(query: Dict[str, List[str]], name: str) -> Optional[str]: +def _first_value(query: Dict[str, Sequence[str]], name: str) -> Optional[str]: if query: values = query.get(name, []) if values and len(values) > 0: diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index cc97b19ba..16fd9463f 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -4,7 +4,7 @@ import os from concurrent.futures.thread import ThreadPoolExecutor from http.server import SimpleHTTPRequestHandler, HTTPServer -from typing import List, Union, Pattern, Callable, Dict, Optional +from typing import List, Union, Pattern, Callable, Dict, Optional, Sequence from slack_sdk.errors import SlackApiError from slack_sdk.oauth.installation_store import InstallationStore @@ -374,13 +374,13 @@ def step( self, callback_id: Union[str, Pattern, WorkflowStep], edit: Optional[ - Union[Callable[..., Optional[BoltResponse]], Listener, List[Callable]] + Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]] ] = None, save: Optional[ - Union[Callable[..., Optional[BoltResponse]], Listener, List[Callable]] + Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]] ] = None, execute: Optional[ - Union[Callable[..., Optional[BoltResponse]], Listener, List[Callable]] + Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]] ] = None, ): """Registers a new Workflow Step listener""" @@ -417,8 +417,8 @@ def error( def event( self, event: Union[str, Pattern, Dict[str, str]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new event listener. @@ -440,14 +440,14 @@ def __call__(*args, **kwargs): def message( self, keyword: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new message event listener. Check the #event method's docstring for details. """ - matchers = matchers if matchers else [] - middleware = middleware if middleware else [] + matchers = list(matchers) if matchers else [] + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -467,8 +467,8 @@ def __call__(*args, **kwargs): def command( self, command: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new slash command listener. @@ -493,8 +493,8 @@ def __call__(*args, **kwargs): def shortcut( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new shortcut listener. @@ -516,8 +516,8 @@ def __call__(*args, **kwargs): def global_shortcut( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new global shortcut listener.""" @@ -533,8 +533,8 @@ def __call__(*args, **kwargs): def message_shortcut( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new message shortcut listener.""" @@ -553,8 +553,8 @@ def __call__(*args, **kwargs): def action( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new action listener. @@ -576,8 +576,8 @@ def __call__(*args, **kwargs): def block_action( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new block_actions listener.""" @@ -593,8 +593,8 @@ def __call__(*args, **kwargs): def attachment_action( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new interactive_message listener.""" @@ -610,8 +610,8 @@ def __call__(*args, **kwargs): def dialog_submission( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new dialog_submission listener.""" @@ -627,8 +627,8 @@ def __call__(*args, **kwargs): def dialog_cancellation( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new dialog_cancellation listener.""" @@ -647,8 +647,8 @@ def __call__(*args, **kwargs): def view( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new view submission/closed event listener. @@ -670,8 +670,8 @@ def __call__(*args, **kwargs): def view_submission( self, constraints: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new view_submission listener.""" @@ -687,8 +687,8 @@ def __call__(*args, **kwargs): def view_closed( self, constraints: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new view_closed listener.""" @@ -707,8 +707,8 @@ def __call__(*args, **kwargs): def options( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new options listener. @@ -730,8 +730,8 @@ def __call__(*args, **kwargs): def block_suggestion( self, action_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new block_suggestion listener.""" @@ -747,8 +747,8 @@ def __call__(*args, **kwargs): def dialog_suggestion( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Registers a new dialog_submission listener.""" @@ -771,7 +771,7 @@ def _init_context(self, req: BoltRequest): @staticmethod def _to_listener_functions( kwargs: dict, - ) -> Optional[List[Callable[..., Optional[BoltResponse]]]]: + ) -> Optional[Sequence[Callable[..., Optional[BoltResponse]]]]: if kwargs: functions = [kwargs["ack"]] for sub in kwargs["lazy"]: @@ -781,10 +781,10 @@ def _to_listener_functions( def _register_listener( self, - functions: List[Callable[..., Optional[BoltResponse]]], + functions: Sequence[Callable[..., Optional[BoltResponse]]], primary_matcher: ListenerMatcher, - matchers: Optional[List[Callable[..., bool]]], - middleware: Optional[List[Union[Callable, Middleware]]], + matchers: Optional[Sequence[Callable[..., bool]]], + middleware: Optional[Sequence[Union[Callable, Middleware]]], auto_acknowledgement: bool = False, ) -> Optional[Callable[..., Optional[BoltResponse]]]: value_to_return = None @@ -893,7 +893,7 @@ def _send_bolt_response(self, bolt_resp: BoltResponse): def _send_response( self, status: int, - headers: Dict[str, List[str]], + headers: Dict[str, Sequence[str]], body: Union[str, dict] = "", ): self.send_response(status) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 9e3db0cb4..62adbc40e 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -1,7 +1,7 @@ import inspect import logging import os -from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable +from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable, Sequence from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner from slack_bolt.middleware.message_listener_matches.async_message_listener_matches import ( @@ -386,13 +386,19 @@ def step( self, callback_id: Union[str, Pattern, AsyncWorkflowStep], edit: Optional[ - Union[Callable[..., Optional[BoltResponse]], AsyncListener, List[Callable]] + Union[ + Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable] + ] ] = None, save: Optional[ - Union[Callable[..., Optional[BoltResponse]], AsyncListener, List[Callable]] + Union[ + Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable] + ] ] = None, execute: Optional[ - Union[Callable[..., Optional[BoltResponse]], AsyncListener, List[Callable]] + Union[ + Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable] + ] ] = None, ): """Registers a new Workflow Step listener""" @@ -429,8 +435,8 @@ def error( def event( self, event: Union[str, Pattern, Dict[str, str]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new event listener. @@ -452,12 +458,12 @@ def __call__(*args, **kwargs): def message( self, keyword: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Register a new message event listener.""" - matchers = matchers if matchers else [] - middleware = middleware if middleware else [] + matchers = list(matchers) if matchers else [] + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -477,8 +483,8 @@ def __call__(*args, **kwargs): def command( self, command: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new slash command listener. @@ -503,8 +509,8 @@ def __call__(*args, **kwargs): def shortcut( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new shortcut listener. @@ -526,8 +532,8 @@ def __call__(*args, **kwargs): def global_shortcut( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new global shortcut listener.""" @@ -543,8 +549,8 @@ def __call__(*args, **kwargs): def message_shortcut( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new message shortcut listener.""" @@ -563,8 +569,8 @@ def __call__(*args, **kwargs): def action( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new action listener. @@ -586,8 +592,8 @@ def __call__(*args, **kwargs): def block_action( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new block_actions listener.""" @@ -603,8 +609,8 @@ def __call__(*args, **kwargs): def attachment_action( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new interactive_message listener.""" @@ -620,8 +626,8 @@ def __call__(*args, **kwargs): def dialog_submission( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new dialog_submission listener.""" @@ -637,8 +643,8 @@ def __call__(*args, **kwargs): def dialog_cancellation( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new dialog_cancellation listener.""" @@ -657,8 +663,8 @@ def __call__(*args, **kwargs): def view( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new view submission/closed listener. @@ -680,8 +686,8 @@ def __call__(*args, **kwargs): def view_submission( self, constraints: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new view_submission listener.""" @@ -697,8 +703,8 @@ def __call__(*args, **kwargs): def view_closed( self, constraints: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new view_closed listener.""" @@ -717,8 +723,8 @@ def __call__(*args, **kwargs): def options( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new options listener. @@ -740,8 +746,8 @@ def __call__(*args, **kwargs): def block_suggestion( self, action_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new block_suggestion listener.""" @@ -757,8 +763,8 @@ def __call__(*args, **kwargs): def dialog_suggestion( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: """Registers a new dialog_submission listener.""" @@ -781,7 +787,7 @@ def _init_context(self, req: AsyncBoltRequest): @staticmethod def _to_listener_functions( kwargs: dict, - ) -> Optional[List[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + ) -> Optional[Sequence[Callable[..., Awaitable[Optional[BoltResponse]]]]]: if kwargs: functions = [kwargs["ack"]] for sub in kwargs["lazy"]: @@ -791,10 +797,10 @@ def _to_listener_functions( def _register_listener( self, - functions: List[Callable[..., Awaitable[Optional[BoltResponse]]]], + functions: Sequence[Callable[..., Awaitable[Optional[BoltResponse]]]], primary_matcher: AsyncListenerMatcher, - matchers: Optional[List[Callable[..., Awaitable[bool]]]], - middleware: Optional[List[Union[Callable, AsyncMiddleware]]], + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]], + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]], auto_acknowledgement: bool = False, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: value_to_return = None diff --git a/slack_bolt/context/ack/ack.py b/slack_bolt/context/ack/ack.py index bc9b7c82d..69cf928cc 100644 --- a/slack_bolt/context/ack/ack.py +++ b/slack_bolt/context/ack/ack.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union, Dict +from typing import Optional, Union, Dict, Sequence from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, Option, OptionGroup @@ -17,12 +17,12 @@ def __init__(self): def __call__( self, text: Union[str, dict] = "", # text: str or whole_response: dict - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion - options: Optional[List[Union[dict, Option]]] = None, - option_groups: Optional[List[Union[dict, OptionGroup]]] = None, + options: Optional[Sequence[Union[dict, Option]]] = None, + option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None, # view_submission response_action: Optional[str] = None, # errors / update / push / clear errors: Optional[Dict[str, str]] = None, diff --git a/slack_bolt/context/ack/async_ack.py b/slack_bolt/context/ack/async_ack.py index 380131e4e..3be06918a 100644 --- a/slack_bolt/context/ack/async_ack.py +++ b/slack_bolt/context/ack/async_ack.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union, Dict +from typing import Optional, Union, Dict, Sequence from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, Option, OptionGroup @@ -17,12 +17,12 @@ def __init__(self): async def __call__( self, text: Union[str, dict] = "", # text: str or whole_response: dict - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion - options: Optional[List[Union[dict, Option]]] = None, - option_groups: Optional[List[Union[dict, OptionGroup]]] = None, + options: Optional[Sequence[Union[dict, Option]]] = None, + option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None, # view_submission response_action: Optional[str] = None, # errors / update / push / clear errors: Optional[Dict[str, str]] = None, diff --git a/slack_bolt/context/ack/internals.py b/slack_bolt/context/ack/internals.py index c6ee73755..8d21b6559 100644 --- a/slack_bolt/context/ack/internals.py +++ b/slack_bolt/context/ack/internals.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Union, Any, Dict +from typing import Optional, Union, Any, Dict, Sequence from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, Option, OptionGroup @@ -12,15 +12,15 @@ def _set_response( self: Any, text_or_whole_response: Union[str, dict] = "", - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion - options: Optional[List[Union[dict, Option]]] = None, - option_groups: Optional[List[Union[dict, OptionGroup]]] = None, + options: Optional[Sequence[Union[dict, Option]]] = None, + option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None, # view_submission response_action: Optional[str] = None, - errors: Optional[Union[Dict[str, str], List[Dict[str, str]]]] = None, + errors: Optional[Union[Dict[str, str], Sequence[Dict[str, str]]]] = None, view: Optional[Union[dict, View]] = None, ) -> BoltResponse: if isinstance(text_or_whole_response, str): diff --git a/slack_bolt/context/respond/async_respond.py b/slack_bolt/context/respond/async_respond.py index 5a87075e4..77a4665ac 100644 --- a/slack_bolt/context/respond/async_respond.py +++ b/slack_bolt/context/respond/async_respond.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Union +from typing import Optional, Union, Sequence from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block @@ -16,8 +16,8 @@ def __init__(self, *, response_url: Optional[str]): async def __call__( self, text: Union[str, dict] = "", - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, response_type: Optional[str] = None, replace_original: Optional[bool] = None, delete_original: Optional[bool] = None, diff --git a/slack_bolt/context/respond/internals.py b/slack_bolt/context/respond/internals.py index d2e51d1b2..28cbbbec4 100644 --- a/slack_bolt/context/respond/internals.py +++ b/slack_bolt/context/respond/internals.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Dict, Union, Any +from typing import Optional, Dict, Union, Any, Sequence from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block @@ -8,8 +8,8 @@ def _build_message( text: str = "", - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, response_type: Optional[str] = None, replace_original: Optional[bool] = None, delete_original: Optional[bool] = None, diff --git a/slack_bolt/context/respond/respond.py b/slack_bolt/context/respond/respond.py index eb085a4ee..09e2727df 100644 --- a/slack_bolt/context/respond/respond.py +++ b/slack_bolt/context/respond/respond.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Union +from typing import Optional, Union, Sequence from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block @@ -16,8 +16,8 @@ def __init__(self, *, response_url: Optional[str]): def __call__( self, text: Union[str, dict] = "", - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, response_type: Optional[str] = None, replace_original: Optional[bool] = None, delete_original: Optional[bool] = None, diff --git a/slack_bolt/context/say/async_say.py b/slack_bolt/context/say/async_say.py index 8b9eae3df..ccbefd4f9 100644 --- a/slack_bolt/context/say/async_say.py +++ b/slack_bolt/context/say/async_say.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Union, Dict +from typing import Optional, Union, Dict, Sequence from slack_bolt.context.say.internals import _can_say from slack_sdk.models.attachments import Attachment @@ -20,8 +20,8 @@ def __init__( async def __call__( self, text: Union[str, dict] = "", - blocks: Optional[List[Union[Dict, Block]]] = None, - attachments: Optional[List[Union[Dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[Dict, Block]]] = None, + attachments: Optional[Sequence[Union[Dict, Attachment]]] = None, channel: Optional[str] = None, thread_ts: Optional[str] = None, **kwargs, diff --git a/slack_bolt/context/say/say.py b/slack_bolt/context/say/say.py index d8dfe145b..9c4181957 100644 --- a/slack_bolt/context/say/say.py +++ b/slack_bolt/context/say/say.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Union, Dict +from typing import Optional, Union, Dict, Sequence from slack_sdk import WebClient from slack_sdk.models.attachments import Attachment @@ -21,8 +21,8 @@ def __init__( def __call__( self, text: Union[str, dict] = "", - blocks: Optional[List[Union[Dict, Block]]] = None, - attachments: Optional[List[Union[Dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[Dict, Block]]] = None, + attachments: Optional[Sequence[Union[Dict, Attachment]]] = None, channel: Optional[str] = None, thread_ts: Optional[str] = None, **kwargs, diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index 2c8c8f68d..ae7773725 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -1,6 +1,6 @@ # pytype: skip-file import logging -from typing import Callable, Dict, Optional, List, Any +from typing import Callable, Dict, Optional, Any, Sequence from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse @@ -20,7 +20,7 @@ def build_async_required_kwargs( *, logger: logging.Logger, - required_arg_names: List[str], + required_arg_names: Sequence[str], request: AsyncBoltRequest, response: Optional[BoltResponse], next_func: Callable[[], None] = None, diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 6338e4ad2..0d936cb14 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -1,6 +1,6 @@ # pytype: skip-file import logging -from typing import Callable, Dict, Optional, List, Any +from typing import Callable, Dict, Optional, Any, Sequence from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse @@ -20,7 +20,7 @@ def build_required_kwargs( *, logger: logging.Logger, - required_arg_names: List[str], + required_arg_names: Sequence[str], request: BoltRequest, response: Optional[BoltResponse], next_func: Callable[[], None] = None, diff --git a/slack_bolt/listener/async_listener.py b/slack_bolt/listener/async_listener.py index ab5cdbd79..7222b80a8 100644 --- a/slack_bolt/listener/async_listener.py +++ b/slack_bolt/listener/async_listener.py @@ -1,5 +1,5 @@ from abc import abstractmethod, ABCMeta -from typing import List, Callable, Awaitable, Tuple, Optional +from typing import Callable, Awaitable, Tuple, Optional, Sequence from slack_bolt.listener_matcher.async_listener_matcher import AsyncListenerMatcher from slack_bolt.middleware.async_middleware import AsyncMiddleware @@ -9,10 +9,10 @@ class AsyncListener(metaclass=ABCMeta): - matchers: List[AsyncListenerMatcher] - middleware: List[AsyncMiddleware] + matchers: Sequence[AsyncListenerMatcher] + middleware: Sequence[AsyncMiddleware] ack_function: Callable[..., Awaitable[BoltResponse]] - lazy_functions: List[Callable[..., Awaitable[None]]] + lazy_functions: Sequence[Callable[..., Awaitable[None]]] auto_acknowledgement: bool async def async_matches( @@ -61,7 +61,7 @@ async def run_ack_function( import inspect from logging import Logger -from typing import Callable, List, Awaitable +from typing import Callable, Awaitable from slack_bolt.listener_matcher.async_listener_matcher import AsyncListenerMatcher from slack_bolt.logger import get_bolt_app_logger @@ -73,11 +73,11 @@ async def run_ack_function( class AsyncCustomListener(AsyncListener): app_name: str ack_function: Callable[..., Awaitable[Optional[BoltResponse]]] - lazy_functions: List[Callable[..., Awaitable[None]]] - matchers: List[AsyncListenerMatcher] - middleware: List[AsyncMiddleware] + lazy_functions: Sequence[Callable[..., Awaitable[None]]] + matchers: Sequence[AsyncListenerMatcher] + middleware: Sequence[AsyncMiddleware] auto_acknowledgement: bool - arg_names: List[str] + arg_names: Sequence[str] logger: Logger def __init__( @@ -85,9 +85,9 @@ def __init__( *, app_name: str, ack_function: Callable[..., Awaitable[Optional[BoltResponse]]], - lazy_functions: List[Callable[..., Awaitable[None]]], - matchers: List[AsyncListenerMatcher], - middleware: List[AsyncMiddleware], + lazy_functions: Sequence[Callable[..., Awaitable[None]]], + matchers: Sequence[AsyncListenerMatcher], + middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False, ): self.app_name = app_name diff --git a/slack_bolt/listener/custom_listener.py b/slack_bolt/listener/custom_listener.py index 45e552ff6..8e8a54e98 100644 --- a/slack_bolt/listener/custom_listener.py +++ b/slack_bolt/listener/custom_listener.py @@ -1,6 +1,6 @@ import inspect from logging import Logger -from typing import Callable, List, Optional +from typing import Callable, Optional, Sequence from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.listener_matcher import ListenerMatcher @@ -14,11 +14,11 @@ class CustomListener(Listener): app_name: str ack_function: Callable[..., Optional[BoltResponse]] - lazy_functions: List[Callable[..., None]] - matchers: List[ListenerMatcher] - middleware: List[Middleware] # type: ignore + lazy_functions: Sequence[Callable[..., None]] + matchers: Sequence[ListenerMatcher] + middleware: Sequence[Middleware] # type: ignore auto_acknowledgement: bool - arg_names: List[str] + arg_names: Sequence[str] logger: Logger def __init__( @@ -26,9 +26,9 @@ def __init__( *, app_name: str, ack_function: Callable[..., Optional[BoltResponse]], - lazy_functions: List[Callable[..., None]], - matchers: List[ListenerMatcher], - middleware: List[Middleware], # type: ignore + lazy_functions: Sequence[Callable[..., None]], + matchers: Sequence[ListenerMatcher], + middleware: Sequence[Middleware], # type: ignore auto_acknowledgement: bool = False, ): self.app_name = app_name diff --git a/slack_bolt/listener/listener.py b/slack_bolt/listener/listener.py index aa87f14ef..6f1d43c9a 100644 --- a/slack_bolt/listener/listener.py +++ b/slack_bolt/listener/listener.py @@ -1,5 +1,5 @@ from abc import abstractmethod, ABCMeta -from typing import List, Callable, Tuple +from typing import Callable, Tuple, Sequence from slack_bolt.listener_matcher import ListenerMatcher from slack_bolt.middleware import Middleware @@ -8,10 +8,10 @@ class Listener(metaclass=ABCMeta): - matchers: List[ListenerMatcher] - middleware: List[Middleware] # type: ignore + matchers: Sequence[ListenerMatcher] + middleware: Sequence[Middleware] # type: ignore ack_function: Callable[..., BoltResponse] - lazy_functions: List[Callable[..., None]] + lazy_functions: Sequence[Callable[..., None]] auto_acknowledgement: bool def matches(self, *, req: BoltRequest, resp: BoltResponse,) -> bool: diff --git a/slack_bolt/listener_matcher/async_listener_matcher.py b/slack_bolt/listener_matcher/async_listener_matcher.py index 904697026..29a107e3e 100644 --- a/slack_bolt/listener_matcher/async_listener_matcher.py +++ b/slack_bolt/listener_matcher/async_listener_matcher.py @@ -18,7 +18,7 @@ async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool import inspect from logging import Logger -from typing import Callable, Awaitable, List +from typing import Callable, Awaitable, Sequence from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs from slack_bolt.logger import get_bolt_app_logger @@ -29,7 +29,7 @@ async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool class AsyncCustomListenerMatcher(AsyncListenerMatcher): app_name: str func: Callable[..., Awaitable[bool]] - arg_names: List[str] + arg_names: Sequence[str] logger: Logger def __init__(self, *, app_name: str, func: Callable[..., Awaitable[bool]]): diff --git a/slack_bolt/listener_matcher/custom_listener_matcher.py b/slack_bolt/listener_matcher/custom_listener_matcher.py index 693dc6bb8..f96c4da9e 100644 --- a/slack_bolt/listener_matcher/custom_listener_matcher.py +++ b/slack_bolt/listener_matcher/custom_listener_matcher.py @@ -1,6 +1,6 @@ import inspect from logging import Logger -from typing import Callable, List +from typing import Callable, Sequence from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.logger import get_bolt_app_logger @@ -12,7 +12,7 @@ class CustomListenerMatcher(ListenerMatcher): app_name: str func: Callable[..., bool] - arg_names: List[str] + arg_names: Sequence[str] logger: Logger def __init__(self, *, app_name: str, func: Callable[..., bool]): diff --git a/slack_bolt/middleware/async_custom_middleware.py b/slack_bolt/middleware/async_custom_middleware.py index 26bcf48a9..220a0723a 100644 --- a/slack_bolt/middleware/async_custom_middleware.py +++ b/slack_bolt/middleware/async_custom_middleware.py @@ -1,6 +1,6 @@ import inspect from logging import Logger -from typing import Callable, Awaitable, List, Any +from typing import Callable, Awaitable, Any, Sequence from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs from slack_bolt.logger import get_bolt_app_logger @@ -12,7 +12,7 @@ class AsyncCustomMiddleware(AsyncMiddleware): app_name: str func: Callable[..., Awaitable[Any]] - arg_names: List[str] + arg_names: Sequence[str] logger: Logger def __init__(self, *, app_name: str, func: Callable[..., Awaitable[Any]]): diff --git a/slack_bolt/middleware/custom_middleware.py b/slack_bolt/middleware/custom_middleware.py index d8c6f82e0..61b71bac2 100644 --- a/slack_bolt/middleware/custom_middleware.py +++ b/slack_bolt/middleware/custom_middleware.py @@ -1,6 +1,6 @@ import inspect from logging import Logger -from typing import Callable, List, Any +from typing import Callable, Any, Sequence from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.logger import get_bolt_app_logger @@ -12,7 +12,7 @@ class CustomMiddleware(Middleware): app_name: str func: Callable[..., Any] - arg_names: List[str] + arg_names: Sequence[str] logger: Logger def __init__(self, *, app_name: str, func: Callable): diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index b5c1b2fae..1157dd632 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -1,7 +1,7 @@ import logging import os from logging import Logger -from typing import Optional, List, Dict, Callable, Awaitable +from typing import Optional, Dict, Callable, Awaitable, Sequence from slack_bolt.error import BoltError from slack_bolt.oauth.async_callback_options import ( @@ -92,8 +92,8 @@ def sqlite3( authorization_url: Optional[str] = None, client_id: Optional[str] = None, # required client_secret: Optional[str] = None, # required - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, + scopes: Optional[Sequence[str]] = None, + user_scopes: Optional[Sequence[str]] = None, redirect_uri: Optional[str] = None, # Handler configuration install_path: Optional[str] = None, diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index 89fb8e625..6b2431ef7 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -1,7 +1,7 @@ import logging import os from logging import Logger -from typing import List, Optional +from typing import Optional, Sequence from slack_sdk.oauth import ( OAuthStateUtils, @@ -27,8 +27,8 @@ class AsyncOAuthSettings: # OAuth flow parameters/credentials client_id: str client_secret: str - scopes: Optional[List[str]] - user_scopes: Optional[List[str]] + scopes: Optional[Sequence[str]] + user_scopes: Optional[Sequence[str]] redirect_uri: Optional[str] # Handler configuration install_path: str @@ -57,8 +57,8 @@ def __init__( # OAuth flow parameters/credentials client_id: Optional[str] = None, # required client_secret: Optional[str] = None, # required - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, + scopes: Optional[Sequence[str]] = None, + user_scopes: Optional[Sequence[str]] = None, redirect_uri: Optional[str] = None, # Handler configuration install_path: str = "/slack/install", diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index c6b39023b..8f4dbecb8 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -1,7 +1,7 @@ import logging import os from logging import Logger -from typing import Optional, List, Dict, Callable +from typing import Optional, Dict, Callable, Sequence from slack_bolt.error import BoltError from slack_bolt.oauth.callback_options import ( @@ -91,8 +91,8 @@ def sqlite3( # OAuth flow parameters/credentials client_id: Optional[str] = None, # required client_secret: Optional[str] = None, # required - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, + scopes: Optional[Sequence[str]] = None, + user_scopes: Optional[Sequence[str]] = None, redirect_uri: Optional[str] = None, # Handler configuration install_path: Optional[str] = None, diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index 089d7440a..3dd49e1ef 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -1,7 +1,7 @@ import logging import os from logging import Logger -from typing import List, Optional +from typing import Optional, Sequence from slack_sdk.oauth import ( OAuthStateStore, @@ -22,8 +22,8 @@ class OAuthSettings: # OAuth flow parameters/credentials client_id: str client_secret: str - scopes: Optional[List[str]] - user_scopes: Optional[List[str]] + scopes: Optional[Sequence[str]] + user_scopes: Optional[Sequence[str]] redirect_uri: Optional[str] # Handler configuration install_path: str @@ -52,8 +52,8 @@ def __init__( # OAuth flow parameters/credentials client_id: Optional[str] = None, # required client_secret: Optional[str] = None, # required - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, + scopes: Optional[Sequence[str]] = None, + user_scopes: Optional[Sequence[str]] = None, redirect_uri: Optional[str] = None, # Handler configuration install_path: str = "/slack/install", diff --git a/slack_bolt/request/async_request.py b/slack_bolt/request/async_request.py index 79a898086..032beaafd 100644 --- a/slack_bolt/request/async_request.py +++ b/slack_bolt/request/async_request.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, List, Union, Any +from typing import Dict, Optional, Union, Any, Sequence from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.request.async_internals import build_async_context @@ -13,8 +13,8 @@ class AsyncBoltRequest: raw_body: str body: Dict[str, Any] - query: Dict[str, List[str]] - headers: Dict[str, List[str]] + query: Dict[str, Sequence[str]] + headers: Dict[str, Sequence[str]] content_type: Optional[str] context: AsyncBoltContext lazy_only: bool @@ -24,9 +24,8 @@ def __init__( self, *, body: str, - query: Optional[Union[str, Dict[str, str], Dict[str, List[str]]]] = None, - # many framework use Dict[str, str] but the reality is Dict[str, List[str]] - headers: Optional[Dict[str, Union[str, List[str]]]] = None, + query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None, + headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, str]] = None, ): """Request to a Bolt app. diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 4a14a46d9..7a3ea872e 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -1,19 +1,19 @@ import json -from typing import Optional, Dict, Union, List, Any +from typing import Optional, Dict, Union, Any, Sequence from urllib.parse import parse_qsl, parse_qs from slack_bolt.context import BoltContext def parse_query( - query: Optional[Union[str, Dict[str, str], Dict[str, List[str]]]] -) -> Dict[str, List[str]]: + query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] +) -> Dict[str, Sequence[str]]: if query is None: return {} elif isinstance(query, str): return parse_qs(query) elif isinstance(query, dict) or hasattr(query, "items"): - result: Dict[str, List[str]] = {} + result: Dict[str, Sequence[str]] = {} for name, value in query.items(): if isinstance(value, list): result[name] = value @@ -128,7 +128,7 @@ def build_context(context: BoltContext, payload: Dict[str, Any],) -> BoltContext return context -def extract_content_type(headers: Dict[str, List[str]]) -> Optional[str]: +def extract_content_type(headers: Dict[str, Sequence[str]]) -> Optional[str]: content_type: Optional[str] = headers.get("content-type", [None])[0] if content_type: return content_type.split(";")[0] @@ -136,9 +136,9 @@ def extract_content_type(headers: Dict[str, List[str]]) -> Optional[str]: def build_normalized_headers( - headers: Optional[Dict[str, Union[str, List[str]]]] -) -> Dict[str, List[str]]: - normalized_headers: Dict[str, List[str]] = {} + headers: Optional[Dict[str, Union[str, Sequence[str]]]] +) -> Dict[str, Sequence[str]]: + normalized_headers: Dict[str, Sequence[str]] = {} if headers is not None: for key, value in headers.items(): normalized_name = key.lower() diff --git a/slack_bolt/request/request.py b/slack_bolt/request/request.py index 71da15316..df5b2b351 100644 --- a/slack_bolt/request/request.py +++ b/slack_bolt/request/request.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, List, Union, Any +from typing import Dict, Optional, Union, Any, Sequence from slack_bolt.context.context import BoltContext from slack_bolt.request.internals import ( @@ -12,8 +12,8 @@ class BoltRequest: raw_body: str - query: Dict[str, List[str]] - headers: Dict[str, List[str]] + query: Dict[str, Sequence[str]] + headers: Dict[str, Sequence[str]] content_type: Optional[str] body: Dict[str, Any] context: BoltContext @@ -24,9 +24,8 @@ def __init__( self, *, body: str, - query: Optional[Union[str, Dict[str, str], Dict[str, List[str]]]] = None, - # many framework use Dict[str, str] but the reality is Dict[str, List[str]] - headers: Optional[Dict[str, Union[str, List[str]]]] = None, + query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None, + headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, str]] = None, ): """Request to a Bolt app. diff --git a/slack_bolt/response/response.py b/slack_bolt/response/response.py index 1c6014516..a9b6fdb62 100644 --- a/slack_bolt/response/response.py +++ b/slack_bolt/response/response.py @@ -1,19 +1,19 @@ import json from http.cookies import SimpleCookie -from typing import Union, Dict, List, Optional +from typing import Union, Dict, Optional, Sequence class BoltResponse: status: int body: str - headers: Dict[str, List[str]] + headers: Dict[str, Sequence[str]] def __init__( self, *, status: int, body: Union[str, dict] = "", - headers: Optional[Dict[str, Union[str, List[str]]]] = None, + headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, ): """The response from a Bolt app. @@ -23,7 +23,7 @@ def __init__( """ self.status: int = status self.body: str = json.dumps(body) if isinstance(body, dict) else body - self.headers: Dict[str, List[str]] = {} + self.headers: Dict[str, Sequence[str]] = {} if headers is not None: for name, value in headers.items(): if value is None: @@ -47,7 +47,7 @@ def first_headers(self) -> Dict[str, str]: def first_headers_without_set_cookie(self) -> Dict[str, str]: return {k: list(v)[0] for k, v in self.headers.items() if k != "set-cookie"} - def cookies(self) -> List[SimpleCookie]: + def cookies(self) -> Sequence[SimpleCookie]: header_values = self.headers.get("set-cookie", []) return [self._to_simple_cookie(v) for v in header_values] diff --git a/slack_bolt/util/utils.py b/slack_bolt/util/utils.py index 2289f85d4..e31489f69 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -1,6 +1,6 @@ import copy import sys -from typing import Optional, Union, Dict, List, Any +from typing import Optional, Union, Dict, Any, Sequence from slack_sdk import WebClient from slack_sdk.models import JsonObject @@ -13,7 +13,7 @@ def create_web_client(token: Optional[str] = None) -> WebClient: return WebClient(token=token, user_agent_prefix=f"Bolt/{bolt_version}",) -def convert_to_dict_list(objects: List[Union[Dict, JsonObject]]) -> List[Dict]: +def convert_to_dict_list(objects: Sequence[Union[Dict, JsonObject]]) -> Sequence[Dict]: return [convert_to_dict(elm) for elm in objects] diff --git a/slack_bolt/workflows/step/async_step.py b/slack_bolt/workflows/step/async_step.py index cf1ee73a6..3044ca513 100644 --- a/slack_bolt/workflows/step/async_step.py +++ b/slack_bolt/workflows/step/async_step.py @@ -1,4 +1,4 @@ -from typing import Callable, Union, Optional, Awaitable, List +from typing import Callable, Union, Optional, Awaitable, Sequence from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -29,13 +29,13 @@ def __init__( *, callback_id: str, edit: Union[ - Callable[..., Awaitable[BoltResponse]], AsyncListener, List[Callable] + Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable] ], save: Union[ - Callable[..., Awaitable[BoltResponse]], AsyncListener, List[Callable] + Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable] ], execute: Union[ - Callable[..., Awaitable[BoltResponse]], AsyncListener, List[Callable] + Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable] ], app_name: Optional[str] = None, ): @@ -75,7 +75,9 @@ def _build_listener( raise ValueError(f"Invalid `{name}` listener") @classmethod - def _build_matchers(cls, name: str, callback_id: str) -> List[AsyncListenerMatcher]: + def _build_matchers( + cls, name: str, callback_id: str + ) -> Sequence[AsyncListenerMatcher]: if name == "edit": return [workflow_step_edit(callback_id, asyncio=True)] elif name == "save": @@ -86,7 +88,9 @@ def _build_matchers(cls, name: str, callback_id: str) -> List[AsyncListenerMatch raise ValueError(f"Invalid name {name}") @classmethod - def _build_middleware(cls, name: str, callback_id: str) -> List[AsyncMiddleware]: + def _build_middleware( + cls, name: str, callback_id: str + ) -> Sequence[AsyncMiddleware]: if name == "edit": return [_build_edit_listener_middleware(callback_id)] elif name == "save": diff --git a/slack_bolt/workflows/step/step.py b/slack_bolt/workflows/step/step.py index 2fe342c57..d9e562885 100644 --- a/slack_bolt/workflows/step/step.py +++ b/slack_bolt/workflows/step/step.py @@ -1,4 +1,4 @@ -from typing import Callable, Union, Optional, List +from typing import Callable, Union, Optional, Sequence from slack_bolt.context import BoltContext from slack_bolt.listener import Listener, CustomListener @@ -27,9 +27,15 @@ def __init__( self, *, callback_id: str, - edit: Union[Callable[..., Optional[BoltResponse]], Listener, List[Callable]], - save: Union[Callable[..., Optional[BoltResponse]], Listener, List[Callable]], - execute: Union[Callable[..., Optional[BoltResponse]], Listener, List[Callable]], + edit: Union[ + Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable] + ], + save: Union[ + Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable] + ], + execute: Union[ + Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable] + ], app_name: Optional[str] = None, ): self.callback_id = callback_id @@ -44,7 +50,7 @@ def _build_listener( callback_id: str, app_name: str, listener: Union[ - Callable[..., Optional[BoltResponse]], Listener, List[Callable] + Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable] ], name: str, ) -> Listener: @@ -74,7 +80,7 @@ def _build_listener( raise ValueError(f"Invalid `{name}` listener") @classmethod - def _build_matchers(cls, name: str, callback_id: str) -> List[ListenerMatcher]: + def _build_matchers(cls, name: str, callback_id: str) -> Sequence[ListenerMatcher]: if name == "edit": return [workflow_step_edit(callback_id)] elif name == "save": @@ -85,7 +91,7 @@ def _build_matchers(cls, name: str, callback_id: str) -> List[ListenerMatcher]: raise ValueError(f"Invalid name {name}") @classmethod - def _build_middleware(cls, name: str, callback_id: str) -> List[Middleware]: + def _build_middleware(cls, name: str, callback_id: str) -> Sequence[Middleware]: if name == "edit": return [_build_edit_listener_middleware(callback_id)] elif name == "save": diff --git a/slack_bolt/workflows/step/utilities/async_configure.py b/slack_bolt/workflows/step/utilities/async_configure.py index e8011d742..f6a73dfe4 100644 --- a/slack_bolt/workflows/step/utilities/async_configure.py +++ b/slack_bolt/workflows/step/utilities/async_configure.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union +from typing import Optional, Union, Sequence from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.models.blocks import Block @@ -11,7 +11,7 @@ def __init__(self, *, callback_id: str, client: AsyncWebClient, body: dict): self.body = body async def __call__( - self, *, blocks: Optional[List[Union[dict, Block]]] = None, + self, *, blocks: Optional[Sequence[Union[dict, Block]]] = None, ) -> None: await self.client.views_open( trigger_id=self.body["trigger_id"], diff --git a/slack_bolt/workflows/step/utilities/configure.py b/slack_bolt/workflows/step/utilities/configure.py index 8f1d29a90..702c94901 100644 --- a/slack_bolt/workflows/step/utilities/configure.py +++ b/slack_bolt/workflows/step/utilities/configure.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union +from typing import Optional, Union, Sequence from slack_sdk.web import WebClient from slack_sdk.models.blocks import Block @@ -11,7 +11,7 @@ def __init__(self, *, callback_id: str, client: WebClient, body: dict): self.body = body def __call__( - self, *, blocks: Optional[List[Union[dict, Block]]] = None, **kwargs + self, *, blocks: Optional[Sequence[Union[dict, Block]]] = None, **kwargs ) -> None: self.client.views_open( trigger_id=self.body["trigger_id"], From 81f9d0c5f9093f4078dd96e0f7bef4c2ebd80124 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 7 Nov 2020 08:24:02 +0900 Subject: [PATCH 172/865] version 1.0.0rc1 --- setup.py | 2 +- slack_bolt/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index fff675429..84af2afe0 100755 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.0.0rc1",], + install_requires=["slack_sdk>=3.0.0rc2",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 68b82ae82..6c93125ca 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "0.9.6b0" +__version__ = "1.0.0rc1" From 948a60c645e2a1f66e5c464e612baa59843b127e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 7 Nov 2020 23:37:50 +0900 Subject: [PATCH 173/865] version 1.0.0rc2 --- setup.py | 4 ++-- slack_bolt/version.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 84af2afe0..09f67a79c 100755 --- a/setup.py +++ b/setup.py @@ -28,12 +28,12 @@ description="The Bolt Framework for Python", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/slackapi/bolt-python", + url="https://github.com/slackapi/boseratlt-python", packages=setuptools.find_packages( exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.0.0rc2",], + install_requires=["slack_sdk>=3.0.0rc3",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 6c93125ca..5a662149f 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.0.0rc1" +__version__ = "1.0.0rc2" From 1c88ea2d7f3cfbd4141c47de2ce0856de4841063 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 7 Nov 2020 23:39:54 +0900 Subject: [PATCH 174/865] Fix an error in settings --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 09f67a79c..7b64860ab 100755 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ description="The Bolt Framework for Python", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/slackapi/boseratlt-python", + url="https://github.com/slackapi/bolt-python", packages=setuptools.find_packages( exclude=["examples", "integration_tests", "tests", "tests.*",] ), From dc288f63fd98252de0110cd9878c9aa7350dde82 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 9 Nov 2020 16:18:20 +0900 Subject: [PATCH 175/865] Fix an example Dockerfile --- examples/docker/aiohttp/Dockerfile | 2 +- examples/docker/fastapi-gunicorn/Dockerfile | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/docker/aiohttp/Dockerfile b/examples/docker/aiohttp/Dockerfile index c1fd13af5..a7b78ed12 100644 --- a/examples/docker/aiohttp/Dockerfile +++ b/examples/docker/aiohttp/Dockerfile @@ -8,7 +8,7 @@ WORKDIR /build/ RUN pip install -U pip && pip install -r requirements.txt FROM python:3.8.5-slim-buster as app -COPY --from=builder /src/ /app/ +COPY --from=builder /build/ /app/ COPY --from=builder /usr/local/lib/ /usr/local/lib/ WORKDIR /app/ COPY *.py /app/ diff --git a/examples/docker/fastapi-gunicorn/Dockerfile b/examples/docker/fastapi-gunicorn/Dockerfile index eacd2c57d..c8f8b94e3 100644 --- a/examples/docker/fastapi-gunicorn/Dockerfile +++ b/examples/docker/fastapi-gunicorn/Dockerfile @@ -9,4 +9,5 @@ RUN pip install -U pip && pip install -r requirements.txt # # docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e VARIABLE_NAME="api" -p 80:80 -it your-repo/hello-bolt -# \ No newline at end of file +# or +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e VARIABLE_NAME="api" -p 3000:80 -it your-repo/hello-bolt From ce1a00b62552ba225695eda20826e2399f9c0ec3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 10 Nov 2020 02:40:45 +0900 Subject: [PATCH 176/865] Remove an outdated comment --- slack_bolt/listener_matcher/builtins.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 3a34d855c..fce9031b1 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -212,8 +212,6 @@ def func(body: Dict[str, Any]) -> bool: return dialog_submission(constraints["callback_id"], asyncio) if action_type == "dialog_cancellation": return dialog_cancellation(constraints["callback_id"], asyncio) - - # Still in beta # https://api.slack.com/workflows/steps if action_type == "workflow_step_edit": return workflow_step_edit(constraints["callback_id"], asyncio) From eb746e11c97f4d8736d01402bf10f155893b9baa Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 10 Nov 2020 02:41:12 +0900 Subject: [PATCH 177/865] version 1.0.0 --- README.md | 2 +- setup.py | 2 +- slack_bolt/version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 240a72d8f..bf6636b21 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Bolt ![Bolt logo](docs/assets/bolt-logo.svg) for Python (beta) +# Bolt ![Bolt logo](docs/assets/bolt-logo.svg) for Python [![Python Version][python-version]][pypi-url] [![pypi package][pypi-image]][pypi-url] diff --git a/setup.py b/setup.py index 7b64860ab..143ac6549 100755 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.0.0rc3",], + install_requires=["slack_sdk>=3.0.0",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 5a662149f..5becc17c0 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.0.0rc2" +__version__ = "1.0.0" From 210d3a6e4f0e77dc612c493cb0befba9914f37a9 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 12 Nov 2020 18:34:55 +0900 Subject: [PATCH 178/865] Add unit tests for request_verification middleware --- .../request_verification/__init__.py | 0 .../test_request_verification.py | 48 +++++++++++++++ .../request_verification/__init__.py | 0 .../test_request_verification.py | 60 +++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 tests/slack_bolt/middleware/request_verification/__init__.py create mode 100644 tests/slack_bolt/middleware/request_verification/test_request_verification.py create mode 100644 tests/slack_bolt_async/middleware/request_verification/__init__.py create mode 100644 tests/slack_bolt_async/middleware/request_verification/test_request_verification.py diff --git a/tests/slack_bolt/middleware/request_verification/__init__.py b/tests/slack_bolt/middleware/request_verification/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/middleware/request_verification/test_request_verification.py b/tests/slack_bolt/middleware/request_verification/test_request_verification.py new file mode 100644 index 000000000..dca98e7b6 --- /dev/null +++ b/tests/slack_bolt/middleware/request_verification/test_request_verification.py @@ -0,0 +1,48 @@ +from time import time + +from slack_sdk.signature import SignatureVerifier + +from slack_bolt.middleware import RequestVerification +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +def next(): + return BoltResponse(status=200, body="next") + + +class TestRequestVerification: + signing_secret = "secret" + signature_verifier = SignatureVerifier(signing_secret) + + 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): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_valid(self): + middleware = RequestVerification(signing_secret=self.signing_secret) + timestamp = str(int(time())) + raw_body = "payload={}" + req = BoltRequest( + body=raw_body, headers=self.build_headers(timestamp, raw_body) + ) + resp = BoltResponse(status=404, body="default") + resp = middleware.process(req=req, resp=resp, next=next) + assert resp.status == 200 + assert resp.body == "next" + + def test_invalid(self): + middleware = RequestVerification(signing_secret=self.signing_secret) + req = BoltRequest(body="payload={}", headers={}) + resp = BoltResponse(status=404) + resp = middleware.process(req=req, resp=resp, next=next) + assert resp.status == 401 + assert resp.body == """{"error": "invalid request"}""" diff --git a/tests/slack_bolt_async/middleware/request_verification/__init__.py b/tests/slack_bolt_async/middleware/request_verification/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py b/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py new file mode 100644 index 000000000..783927572 --- /dev/null +++ b/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py @@ -0,0 +1,60 @@ +import asyncio +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier + +from slack_bolt.middleware.request_verification.async_request_verification import ( + AsyncRequestVerification, +) +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse + + +async def next(): + return BoltResponse(status=200, body="next") + + +class TestAsyncRequestVerification: + signing_secret = "secret" + signature_verifier = SignatureVerifier(signing_secret) + + 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): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + @pytest.fixture + def event_loop(self): + loop = asyncio.get_event_loop() + yield loop + loop.close() + + @pytest.mark.asyncio + async def test_valid(self): + middleware = AsyncRequestVerification(signing_secret="secret") + timestamp = str(int(time())) + raw_body = "payload={}" + req = AsyncBoltRequest( + body=raw_body, headers=self.build_headers(timestamp, raw_body) + ) + resp = BoltResponse(status=404) + resp = await middleware.async_process(req=req, resp=resp, next=next) + assert resp.status == 200 + assert resp.body == "next" + + @pytest.mark.asyncio + async def test_invalid(self): + middleware = AsyncRequestVerification(signing_secret="secret") + req = AsyncBoltRequest(body="payload={}", headers={}) + resp = BoltResponse(status=404) + resp = await middleware.async_process(req=req, resp=resp, next=next) + assert resp.status == 401 + assert resp.body == """{"error": "invalid request"}""" From 66992107ae51310b6a9ec9d438d83e48172e9639 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 18 Nov 2020 16:59:55 +0900 Subject: [PATCH 179/865] Fix a method name in docs --- docs/_basic/publishing_views.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_basic/publishing_views.md b/docs/_basic/publishing_views.md index 842ba74b9..fd0a7baf3 100644 --- a/docs/_basic/publishing_views.md +++ b/docs/_basic/publishing_views.md @@ -13,7 +13,7 @@ You can subscribe to the ```python @app.event("app_home_opened") -def open_modal(client, event, logger): +def update_home_tab(client, event, logger): try: # Call views.publish with the built-in client client.views_publish( From f05656b57d4f072b8f73d26989ee2684c04fd99e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 24 Nov 2020 07:27:45 +0900 Subject: [PATCH 180/865] Replace authed_users with authorizations in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bf6636b21..7dde2c3c7 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Most of the app's functionality will be inside listener functions (the `fn` para | Argument | Description | | :---: | :--- | -| `body` | Dictionary that contains the entire body of the request (superset of `payload`). Some accessory data is only available outside of the payload (such as `trigger_id` and `authed_users`). +| `body` | Dictionary that contains the entire body of the request (superset of `payload`). Some accessory data is only available outside of the payload (such as `trigger_id` and `authorizations`). | `payload` | Contents of the incoming event. The payload structure depends on the listener. For example, for an Events API event, `payload` will be the [event type structure](https://api.slack.com/events-api#event_type_structure). For a block action, it will be the action from within the `actions` list. The `payload` dictionary is also accessible via the alias corresponding to the listener (`message`, `event`, `action`, `shortcut`, `view`, `command`, or `options`). For example, if you were building a `message()` listener, you could use the `payload` and `message` arguments interchangably. **An easy way to understand what's in a payload is to log it**. | | `context` | Event context. This dictionary contains data about the event and app, such as the `botId`. Middleware can add additional context before the event is passed to listeners. | `ack` | Function that **must** be called to acknowledge that your app received the incoming event. `ack` exists for all actions, shortcuts, view submissions, slash command and options requests. `ack` returns a promise that resolves when complete. Read more in [Acknowledging events](https://slack.dev/bolt-python/concepts#acknowledge). From fa5a264a86ad506585931585b62bbb12a5a0a2a8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 20 Nov 2020 12:54:27 +0900 Subject: [PATCH 181/865] Fix Events API handling issue in shared channels --- slack_bolt/app/app.py | 15 +- slack_bolt/app/async_app.py | 17 +- .../async_multi_teams_authorization.py | 5 +- .../multi_teams_authorization.py | 8 +- slack_bolt/request/internals.py | 22 +- .../test_events_shared_channels.py | 514 ++++++++++++++++ .../test_events_shared_channels.py | 564 ++++++++++++++++++ 7 files changed, 1131 insertions(+), 14 deletions(-) create mode 100644 tests/scenario_tests/test_events_shared_channels.py create mode 100644 tests/scenario_tests_async/test_events_shared_channels.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 16fd9463f..8b1666edf 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -766,7 +766,20 @@ def __call__(*args, **kwargs): def _init_context(self, req: BoltRequest): req.context["logger"] = get_bolt_app_logger(self.name) req.context["token"] = self._token - req.context["client"] = self._client + if self._token is not None: + # This WebClient instance can be safely singleton + req.context["client"] = self._client + else: + # Set a new dedicated instance for this request + client_per_request: WebClient = WebClient( + token=None, # the token will be set later + base_url=self._client.base_url, + timeout=self._client.timeout, + ssl=self._client.ssl, + proxy=self._client.proxy, + headers=self._client.headers, + ) + req.context["client"] = client_per_request @staticmethod def _to_listener_functions( diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 62adbc40e..b804378bd 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -782,7 +782,22 @@ def __call__(*args, **kwargs): def _init_context(self, req: AsyncBoltRequest): req.context["logger"] = get_bolt_app_logger(self.name) req.context["token"] = self._token - req.context["client"] = self._async_client + if self._token is not None: + # This AsyncWebClient instance can be safely singleton + req.context["client"] = self._async_client + else: + # Set a new dedicated instance for this request + client_per_request: AsyncWebClient = AsyncWebClient( + token=None, # the token will be set later + base_url=self._async_client.base_url, + timeout=self._async_client.timeout, + ssl=self._async_client.ssl, + proxy=self._async_client.proxy, + session=self._async_client.session, + trust_env_in_session=self._async_client.trust_env_in_session, + headers=self._async_client.headers, + ) + req.context["client"] = client_per_request @staticmethod def _to_listener_functions( diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index 98e22aa4b..5f6f72cda 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -9,7 +9,6 @@ from .internals import _is_no_auth_test_call_required from ...authorization import AuthorizeResult from ...authorization.async_authorize import AsyncAuthorize -from ...util.async_utils import create_async_web_client class AsyncMultiTeamsAuthorization(AsyncAuthorization): @@ -54,7 +53,9 @@ async def async_process( req.context.set_authorize_result(auth_result) token = auth_result.bot_token or auth_result.user_token req.context["token"] = token - req.context["client"] = create_async_web_client(token) + # As AsyncApp#_init_context() generates a new AsyncWebClient for this request, + # it's safe to modify this instance. + req.context.client.token = token return await next() else: # Just in case diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index bfde5bb47..5a130d623 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -1,9 +1,10 @@ from typing import Callable, Optional +from slack_sdk.errors import SlackApiError + from slack_bolt.logger import get_bolt_logger from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from slack_sdk.errors import SlackApiError from .authorization import Authorization from .internals import ( _build_error_response, @@ -12,7 +13,6 @@ ) from ...authorization import AuthorizeResult from ...authorization.authorize import Authorize -from ...util.utils import create_web_client class MultiTeamsAuthorization(Authorization): @@ -55,7 +55,9 @@ def process( req.context.set_authorize_result(auth_result) token = auth_result.bot_token or auth_result.user_token req.context["token"] = token - req.context["client"] = create_web_client(token) + # As App#_init_context() generates a new WebClient for this request, + # it's safe to modify this instance. + req.context.client.token = token return next() else: # Just in case diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 7a3ea872e..da3332c5c 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -53,6 +53,10 @@ def extract_enterprise_id(payload: Dict[str, Any]) -> Optional[str]: return org elif "id" in org: return org.get("id") # type: ignore + if "authorizations" in payload and len(payload["authorizations"]) > 0: + # To make Events API handling functioning also for shared channels, + # we should use .authorizations[0].enterprise_id over .enterprise_id + return extract_enterprise_id(payload["authorizations"][0]) if "enterprise_id" in payload: return payload.get("enterprise_id") if "team" in payload and "enterprise_id" in payload["team"]: @@ -70,6 +74,10 @@ def extract_team_id(payload: Dict[str, Any]) -> Optional[str]: return team elif team and "id" in team: return team.get("id") + if "authorizations" in payload and len(payload["authorizations"]) > 0: + # To make Events API handling functioning also for shared channels, + # we should use .authorizations[0].team_id over .team_id + return extract_team_id(payload["authorizations"][0]) if "team_id" in payload: return payload.get("team_id") if "event" in payload: @@ -110,21 +118,21 @@ def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: return None -def build_context(context: BoltContext, payload: Dict[str, Any],) -> BoltContext: - enterprise_id = extract_enterprise_id(payload) +def build_context(context: BoltContext, body: Dict[str, Any]) -> BoltContext: + enterprise_id = extract_enterprise_id(body) if enterprise_id: context["enterprise_id"] = enterprise_id - team_id = extract_team_id(payload) + team_id = extract_team_id(body) if team_id: context["team_id"] = team_id - user_id = extract_user_id(payload) + user_id = extract_user_id(body) if user_id: context["user_id"] = user_id - channel_id = extract_channel_id(payload) + channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id - if "response_url" in payload: - context["response_url"] = payload["response_url"] + if "response_url" in body: + context["response_url"] = body["response_url"] return context diff --git a/tests/scenario_tests/test_events_shared_channels.py b/tests/scenario_tests/test_events_shared_channels.py new file mode 100644 index 000000000..702b515f3 --- /dev/null +++ b/tests/scenario_tests/test_events_shared_channels.py @@ -0,0 +1,514 @@ +import json +from time import time, sleep + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest, Say +from slack_bolt.authorization import AuthorizeResult +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 + +valid_token = "xoxb-valid" + + +def authorize(enterprise_id, team_id, client: WebClient): + assert enterprise_id == "E_INSTALLED" + assert team_id == "T_INSTALLED" + auth_test = client.auth_test(token=valid_token) + return AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test, bot_token=valid_token, + ) + + +class TestEventsSharedChannels: + signing_secret = "secret" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client: WebClient = WebClient( + token=None, 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + valid_event_body = { + "token": "verification_token", + "team_id": "T_INSTALLED", + "enterprise_id": "E_INSTALLED", + "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": "T_INSTALLED", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + def test_mock_server_is_running(self): + resp = self.web_client.api_test(token=valid_token) + assert resp != None + + def test_middleware(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + @app.event("app_mention") + def handle_app_mention(body, say: Say, payload, event): + assert body == self.valid_event_body + assert body["event"] == payload + assert payload == event + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(self.valid_event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_middleware_skip(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + def skip_middleware(req, resp, next): + # return next() + pass + + @app.event("app_mention", middleware=[skip_middleware]) + def handle_app_mention(body, logger, payload, event): + assert body["event"] == payload + assert payload == event + logger.info(payload) + + timestamp, body = str(int(time())), json.dumps(self.valid_event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + valid_reaction_added_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": {"type": "message", "channel": "C111", "ts": "1599529504.000400"}, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + def test_reaction_added(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + @app.event("reaction_added") + def handle_app_mention(body, say, payload, event): + assert body == self.valid_reaction_added_body + assert body["event"] == payload + assert payload == event + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(self.valid_reaction_added_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_stable_auto_ack(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + @app.event("reaction_added") + def handle_app_mention(): + raise Exception("Something wrong!") + + for _ in range(10): + timestamp, body = ( + str(int(time())), + json.dumps(self.valid_reaction_added_body), + ) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + + def test_self_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + @app.event("reaction_added") + def handle_app_mention(say): + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + # The listener should not be executed + assert self.mock_received_requests.get("/chat.postMessage") is None + + def test_self_member_join_left_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + join_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + @app.event("member_joined_channel") + def handle_member_joined_channel(say): + say("What's up?") + + @app.event("member_left_channel") + def handle_member_left_channel(say): + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 2 + + def test_member_join_left_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + join_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "U999", # not self + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "U999", # not self + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + @app.event("member_joined_channel") + def handle_app_mention(say): + say("What's up?") + + @app.event("member_left_channel") + def handle_app_mention(say): + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + + sleep(1) # wait a bit after auto ack() + # the listeners should not be executed + assert self.mock_received_requests["/chat.postMessage"] == 2 + + def test_uninstallation_and_revokes(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app._client = WebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event("app_uninstalled") + def handler1(say: Say): + say(channel="C111", text="What's up?") + + @app.event("tokens_revoked") + def handler2(say: Say): + say(channel="C111", text="What's up?") + + app_uninstalled_body = { + "token": "verification_token", + "team_id": "T_INSTALLED", + "enterprise_id": "E_INSTALLED", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + timestamp, body = str(int(time())), json.dumps(app_uninstalled_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + + tokens_revoked_body = { + "token": "verification_token", + "team_id": "T_INSTALLED", + "enterprise_id": "E_INSTALLED", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["UXXXXXXXX"], "bot": ["UXXXXXXXX"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + timestamp, body = str(int(time())), json.dumps(tokens_revoked_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + + # this should not be called when we have authorize + assert self.mock_received_requests.get("/auth.test") is None + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 2 diff --git a/tests/scenario_tests_async/test_events_shared_channels.py b/tests/scenario_tests_async/test_events_shared_channels.py new file mode 100644 index 000000000..0fb85f11d --- /dev/null +++ b/tests/scenario_tests_async/test_events_shared_channels.py @@ -0,0 +1,564 @@ +import asyncio +import json +from random import random +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.context.say.async_say import AsyncSay +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 + +valid_token = "xoxb-valid" + + +async def authorize(enterprise_id, team_id, client: AsyncWebClient): + assert enterprise_id == "E_INSTALLED" + assert team_id == "T_INSTALLED" + auth_test = await client.auth_test(token=valid_token) + return AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test, bot_token=valid_token, + ) + + +class TestAsyncEventsSharedChannels: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient(token=None, 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) + + 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_app_mention_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(app_mention_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_mock_server_is_running(self): + resp = await self.web_client.api_test(token=valid_token) + assert resp != None + + @pytest.mark.asyncio + async def test_app_mention(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_process_before_response(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + process_before_response=True, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + # no sleep here + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_middleware_skip(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("app_mention", middleware=[skip_middleware])(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + @pytest.mark.asyncio + async def test_simultaneous_requests(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("app_mention")(random_sleeper) + + request = self.build_valid_app_mention_request() + + times = 10 + tasks = [] + for i in range(times): + tasks.append(asyncio.ensure_future(app.async_dispatch(request))) + + await asyncio.sleep(5) + # Verifies all the tasks have been completed with 200 OK + assert sum([t.result().status for t in tasks if t.done()]) == 200 * times + + assert self.mock_received_requests["/auth.test"] == times + assert self.mock_received_requests["/chat.postMessage"] == times + + def build_valid_reaction_added_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(reaction_added_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_reaction_added(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("reaction_added")(whats_up) + + request = self.build_valid_reaction_added_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_stable_auto_ack(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("reaction_added")(always_failing) + + for _ in range(10): + request = self.build_valid_reaction_added_request() + response = await app.async_dispatch(request) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_self_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("reaction_added")(whats_up) + + self_event = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + timestamp, body = str(int(time())), json.dumps(self_event) + request = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + # The listener should not be executed + assert self.mock_received_requests.get("/chat.postMessage") is None + + @pytest.mark.asyncio + async def test_self_joined_left_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("reaction_added")(whats_up) + + join_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + @app.event("member_joined_channel") + async def handle_member_joined_channel(say): + await say("What's up?") + + @app.event("member_left_channel") + async def handle_member_left_channel(say): + await say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + await asyncio.sleep(1) # wait a bit after auto ack() + # The listeners should be executed + assert self.mock_received_requests.get("/chat.postMessage") == 2 + + @pytest.mark.asyncio + async def test_joined_left_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("reaction_added")(whats_up) + + join_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W111", # other user + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W111", # other user + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + @app.event("member_joined_channel") + async def handle_member_joined_channel(say): + await say("What's up?") + + @app.event("member_left_channel") + async def handle_member_left_channel(say): + await say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + await asyncio.sleep(1) # wait a bit after auto ack() + # The listeners should be executed + assert self.mock_received_requests.get("/chat.postMessage") == 2 + + @pytest.mark.asyncio + async def test_uninstallation_and_revokes(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app._client = AsyncWebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event("app_uninstalled") + async def handler1(say: AsyncSay): + await say(channel="C111", text="What's up?") + + @app.event("tokens_revoked") + async def handler2(say: AsyncSay): + await say(channel="C111", text="What's up?") + + app_uninstalled_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + timestamp, body = str(int(time())), json.dumps(app_uninstalled_body) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + tokens_revoked_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["UXXXXXXXX"], "bot": ["UXXXXXXXX"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + timestamp, body = str(int(time())), json.dumps(tokens_revoked_body) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + # AsyncApp doesn't call auth.test when booting + assert self.mock_received_requests.get("/auth.test") is None + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 2 + + +app_mention_body = { + "token": "verification_token", + "team_id": "T_INSTALLED", + "enterprise_id": "E_SOURCE", + "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": "T_INSTALLED", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], +} + +reaction_added_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": {"type": "message", "channel": "C111", "ts": "1599529504.000400"}, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], +} + + +async def random_sleeper(body, say, payload, event): + assert body == app_mention_body + assert body["event"] == payload + assert payload == event + seconds = random() + 2 # 2-3 seconds + await asyncio.sleep(seconds) + await say(f"Sending this message after sleeping for {seconds} seconds") + + +async def whats_up(body, say, payload, event): + assert body["event"] == payload + assert payload == event + await say("What's up?") + + +async def skip_middleware(req, resp, next): + # return next() + pass + + +async def always_failing(): + raise Exception("Something wrong!") From 5033ae4327cc624753a6344c0b9129dfda09c8f5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 23 Nov 2020 16:00:07 +0900 Subject: [PATCH 182/865] Add OAuth related arg validation and flexibility --- slack_bolt/app/async_app.py | 8 ++++ slack_bolt/logger/messages.py | 8 ++++ slack_bolt/oauth/async_oauth_flow.py | 5 +++ slack_bolt/oauth/async_oauth_settings.py | 10 +++-- slack_bolt/oauth/oauth_settings.py | 10 +++-- tests/slack_bolt/oauth/test_oauth_flow.py | 13 ++++++- .../oauth/test_async_oauth_flow.py | 37 +++++++++++++++++++ 7 files changed, 84 insertions(+), 7 deletions(-) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index b804378bd..e440bfafe 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -34,6 +34,8 @@ error_listener_function_must_be_coro_func, error_client_invalid_type_async, error_authorize_conflicts, + error_oauth_settings_invalid_type_async, + error_oauth_flow_invalid_type_async, ) from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -167,6 +169,9 @@ def __init__( oauth_settings = AsyncOAuthSettings() if oauth_flow: + if not isinstance(oauth_flow, AsyncOAuthFlow): + raise BoltError(error_oauth_flow_invalid_type_async()) + self._async_oauth_flow = oauth_flow installation_store = select_consistent_installation_store( client_id=self._async_oauth_flow.client_id, @@ -182,6 +187,9 @@ def __init__( if self._async_authorize is None: self._async_authorize = self._async_oauth_flow.settings.authorize elif oauth_settings is not None: + if not isinstance(oauth_settings, AsyncOAuthSettings): + raise BoltError(error_oauth_settings_invalid_type_async()) + installation_store = select_consistent_installation_store( client_id=oauth_settings.client_id, app_store=self._async_installation_store, diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 29ee7bfe0..291ddf1d3 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -26,6 +26,14 @@ def error_client_invalid_type_async() -> str: return "`client` must be a slack_sdk.web.async_client.AsyncWebClient" +def error_oauth_flow_invalid_type_async() -> str: + return "`oauth_flow` must be a slack_bolt.oauth.async_oauth_flow.AsyncOAuthFlow" + + +def error_oauth_settings_invalid_type_async() -> str: + return "`oauth_settings` must be a slack_bolt.oauth.async_oauth_settings.AsyncOAuthSettings" + + def error_auth_test_failure(error_response: SlackResponse) -> str: return f"`token` is invalid (auth.test result: {error_response})" diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 1157dd632..073a622e3 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -4,6 +4,7 @@ from typing import Optional, Dict, Callable, Awaitable, Sequence from slack_bolt.error import BoltError +from slack_bolt.logger.messages import error_oauth_settings_invalid_type_async from slack_bolt.oauth.async_callback_options import ( AsyncCallbackOptions, DefaultAsyncCallbackOptions, @@ -62,7 +63,11 @@ def __init__( """ self._async_client = client self._logger = logger + + if not isinstance(settings, AsyncOAuthSettings): + raise BoltError(error_oauth_settings_invalid_type_async()) self.settings = settings + self.settings.logger = self._logger self.client_id = self.settings.client_id diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index 6b2431ef7..f09df9a0d 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -1,7 +1,7 @@ import logging import os from logging import Logger -from typing import Optional, Sequence +from typing import Optional, Sequence, Union from slack_sdk.oauth import ( OAuthStateUtils, @@ -57,8 +57,8 @@ def __init__( # OAuth flow parameters/credentials client_id: Optional[str] = None, # required client_secret: Optional[str] = None, # required - scopes: Optional[Sequence[str]] = None, - user_scopes: Optional[Sequence[str]] = None, + scopes: Optional[Union[Sequence[str], str]] = None, + user_scopes: Optional[Union[Sequence[str], str]] = None, redirect_uri: Optional[str] = None, # Handler configuration install_path: str = "/slack/install", @@ -104,9 +104,13 @@ def __init__( raise BoltError("Both client_id and client_secret are required") self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + if isinstance(self.scopes, str): + self.scopes = self.scopes.split(",") self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( "," ) + if isinstance(self.user_scopes, str): + self.user_scopes = self.user_scopes.split(",") self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") # Handler configuration self.install_path = install_path or os.environ.get( diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index 3dd49e1ef..1462925c2 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -1,7 +1,7 @@ import logging import os from logging import Logger -from typing import Optional, Sequence +from typing import Optional, Sequence, Union from slack_sdk.oauth import ( OAuthStateStore, @@ -52,8 +52,8 @@ def __init__( # OAuth flow parameters/credentials client_id: Optional[str] = None, # required client_secret: Optional[str] = None, # required - scopes: Optional[Sequence[str]] = None, - user_scopes: Optional[Sequence[str]] = None, + scopes: Optional[Union[Sequence[str], str]] = None, + user_scopes: Optional[Union[Sequence[str], str]] = None, redirect_uri: Optional[str] = None, # Handler configuration install_path: str = "/slack/install", @@ -98,9 +98,13 @@ def __init__( raise BoltError("Both client_id and client_secret are required") self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + if isinstance(self.scopes, str): + self.scopes = self.scopes.split(",") self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( "," ) + if isinstance(self.user_scopes, str): + self.user_scopes = self.user_scopes.split(",") self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") # Handler configuration self.install_path = install_path or os.environ.get( diff --git a/tests/slack_bolt/oauth/test_oauth_flow.py b/tests/slack_bolt/oauth/test_oauth_flow.py index 5d390f7bf..a2797e5e0 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow.py +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -46,6 +46,7 @@ def test_handle_installation(self): client_id="111.222", client_secret="xxx", scopes=["chat:write", "commands"], + user_scopes=["search:read"], installation_store=FileInstallationStore(), state_store=FileOAuthStateStore(expiration_seconds=120), ) @@ -54,9 +55,19 @@ def test_handle_installation(self): resp = oauth_flow.handle_installation(req) assert resp.status == 200 assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] - assert resp.headers.get("content-length") == ["565"] + assert resp.headers.get("content-length") == ["576"] assert "https://slack.com/oauth/v2/authorize?state=" in resp.body + def test_scopes_as_str(self): + settings = OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes="chat:write,commands", + user_scopes="search:read", + ) + assert settings.scopes == ["chat:write", "commands"] + assert settings.user_scopes == ["search:read"] + def test_handle_callback(self): oauth_flow = OAuthFlow( client=WebClient(base_url=self.mock_api_server_base_url), diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index 85968ed0c..44f4e5ea7 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -11,6 +11,7 @@ from slack_bolt import BoltResponse from slack_bolt.app.async_app import AsyncApp +from slack_bolt.error import BoltError from slack_bolt.oauth.async_callback_options import ( AsyncFailureArgs, AsyncSuccessArgs, @@ -18,6 +19,7 @@ ) from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( cleanup_mock_web_api_server, @@ -46,6 +48,7 @@ async def test_instantiation(self): client_id="111.222", client_secret="xxx", scopes=["chat:write", "commands"], + user_scopes=["search:read"], installation_store=FileInstallationStore(), state_store=FileOAuthStateStore(expiration_seconds=120), ) @@ -54,6 +57,40 @@ async def test_instantiation(self): assert oauth_flow.logger is not None assert oauth_flow.client is not None + @pytest.mark.asyncio + async def test_scopes_as_str(self): + settings = AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes="chat:write,commands", + user_scopes="search:read", + ) + assert settings.scopes == ["chat:write", "commands"] + assert settings.user_scopes == ["search:read"] + + @pytest.mark.asyncio + async def test_instantiation_non_async_settings(self): + with pytest.raises(BoltError): + AsyncOAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes="chat:write,commands", + ) + ) + + @pytest.mark.asyncio + async def test_instantiation_non_async_settings_to_app(self): + with pytest.raises(BoltError): + AsyncApp( + signing_secret="xxx", + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes="chat:write,commands", + ), + ) + @pytest.mark.asyncio async def test_handle_installation(self): oauth_flow = AsyncOAuthFlow( From 37d994a1abec9afd5080ca159f31b06815ea6a9f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 23 Nov 2020 16:37:37 +0900 Subject: [PATCH 183/865] Apply internal adjustments for Socket Mode support --- slack_bolt/app/app.py | 4 - slack_bolt/app/async_app.py | 4 - slack_bolt/logger/messages.py | 8 - .../async_request_verification.py | 2 +- .../request_verification.py | 8 +- slack_bolt/request/async_request.py | 22 +- slack_bolt/request/internals.py | 8 + slack_bolt/request/request.py | 23 +- .../scenario_tests/test_events_socket_mode.py | 358 ++++++++++++++++ tests/scenario_tests_async/test_app.py | 6 - .../test_events_socket_mode.py | 401 ++++++++++++++++++ 11 files changed, 810 insertions(+), 34 deletions(-) create mode 100644 tests/scenario_tests/test_events_socket_mode.py create mode 100644 tests/scenario_tests_async/test_events_socket_mode.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 8b1666edf..4f59e78fa 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -30,7 +30,6 @@ from slack_bolt.listener_matcher.listener_matcher import ListenerMatcher from slack_bolt.logger import get_bolt_app_logger, get_bolt_logger from slack_bolt.logger.messages import ( - error_signing_secret_not_found, warning_client_prioritized_and_token_skipped, warning_token_skipped, error_auth_test_failure, @@ -106,9 +105,6 @@ def __init__( signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") token = token or os.environ.get("SLACK_BOT_TOKEN") - if signing_secret is None or signing_secret == "": - raise BoltError(error_signing_secret_not_found()) - self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] self._signing_secret: str = signing_secret diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index e440bfafe..b80106919 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -23,7 +23,6 @@ ) from slack_bolt.error import BoltError from slack_bolt.logger.messages import ( - error_signing_secret_not_found, warning_client_prioritized_and_token_skipped, warning_token_skipped, error_token_required, @@ -115,9 +114,6 @@ def __init__( signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") token = token or os.environ.get("SLACK_BOT_TOKEN") - if signing_secret is None or signing_secret == "": - raise BoltError(error_signing_secret_not_found()) - self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] self._signing_secret: str = signing_secret self._verification_token: Optional[str] = verification_token or os.environ.get( diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 291ddf1d3..e01f03005 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -10,14 +10,6 @@ # ------------------------------- -def error_signing_secret_not_found() -> str: - return ( - "Signing secret not found, so could not initialize the Bolt app." - "Copy your Signing Secret from the Basic Information page " - "and then store it in a new environment variable" - ) - - def error_client_invalid_type() -> str: return "`client` must be a slack_sdk.web.WebClient" diff --git a/slack_bolt/middleware/request_verification/async_request_verification.py b/slack_bolt/middleware/request_verification/async_request_verification.py index c62041b89..7fde73ddd 100644 --- a/slack_bolt/middleware/request_verification/async_request_verification.py +++ b/slack_bolt/middleware/request_verification/async_request_verification.py @@ -14,7 +14,7 @@ async def async_process( resp: BoltResponse, next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: - if self._can_skip(req.body): + if self._can_skip(req.mode, req.body): return await next() body = req.raw_body diff --git a/slack_bolt/middleware/request_verification/request_verification.py b/slack_bolt/middleware/request_verification/request_verification.py index 8c91e65f2..538441070 100644 --- a/slack_bolt/middleware/request_verification/request_verification.py +++ b/slack_bolt/middleware/request_verification/request_verification.py @@ -21,7 +21,7 @@ def __init__(self, signing_secret: str): def process( self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], ) -> BoltResponse: - if self._can_skip(req.body): + if self._can_skip(req.mode, req.body): return next() body = req.raw_body @@ -36,8 +36,10 @@ def process( # ----------------------------------------- @staticmethod - def _can_skip(body: Dict[str, Any]) -> bool: - return body is not None and body.get("ssl_check") == "1" + def _can_skip(mode: str, body: Dict[str, Any]) -> bool: + return mode == "socket_mode" or ( + body is not None and body.get("ssl_check") == "1" + ) @staticmethod def _build_error_response() -> BoltResponse: diff --git a/slack_bolt/request/async_request.py b/slack_bolt/request/async_request.py index 032beaafd..d1c03cbe0 100644 --- a/slack_bolt/request/async_request.py +++ b/slack_bolt/request/async_request.py @@ -1,12 +1,15 @@ from typing import Dict, Optional, Union, Any, Sequence from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.error import BoltError from slack_bolt.request.async_internals import build_async_context from slack_bolt.request.internals import ( parse_query, parse_body, build_normalized_headers, extract_content_type, + error_message_raw_body_required_in_http_mode, + error_message_unknown_request_body_type, ) @@ -19,27 +22,37 @@ class AsyncBoltRequest: context: AsyncBoltContext lazy_only: bool lazy_function_name: Optional[str] + mode: str # either "http" or "socket_mode" def __init__( self, *, - body: str, + body: Union[str, dict], query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, str]] = None, + mode: str = "http", # either "http" or "socket_mode" ): """Request to a Bolt app. - :param body: The raw request body (only plain text is supported) + :param body: The raw request body (only plain text is supported for "http" mode) :param query: The query string data in any data format. :param headers: The request headers. :param context: The context in this request. + :param mode: The mode used for this request. (either "http" or "socket_mode") """ - self.raw_body = body + if mode == "http" and not isinstance(body, str): + raise BoltError(error_message_raw_body_required_in_http_mode()) + self.raw_body = body if mode == "http" else "" self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) - self.body = parse_body(self.raw_body, self.content_type) + if isinstance(body, str): + self.body = parse_body(self.raw_body, self.content_type) + elif isinstance(body, dict): + self.body = body + else: + raise BoltError(error_message_unknown_request_body_type()) self.context = build_async_context( AsyncBoltContext(context if context else {}), self.body ) @@ -47,3 +60,4 @@ def __init__( self.lazy_function_name = self.headers.get( "x-slack-bolt-lazy-function-name", [None] )[0] + self.mode = mode diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index da3332c5c..634f218e3 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -159,3 +159,11 @@ def build_normalized_headers( f"Unsupported type ({type(value)}) of element in headers ({headers})" ) return normalized_headers # type: ignore + + +def error_message_raw_body_required_in_http_mode() -> str: + return "`body` must be a raw string data when running in the HTTP server mode" + + +def error_message_unknown_request_body_type() -> str: + return "`body` must be either str or dict" diff --git a/slack_bolt/request/request.py b/slack_bolt/request/request.py index df5b2b351..9a1129e84 100644 --- a/slack_bolt/request/request.py +++ b/slack_bolt/request/request.py @@ -1,12 +1,15 @@ from typing import Dict, Optional, Union, Any, Sequence from slack_bolt.context.context import BoltContext +from slack_bolt.error import BoltError from slack_bolt.request.internals import ( parse_query, parse_body, build_normalized_headers, build_context, extract_content_type, + error_message_raw_body_required_in_http_mode, + error_message_unknown_request_body_type, ) @@ -19,29 +22,41 @@ class BoltRequest: context: BoltContext lazy_only: bool lazy_function_name: Optional[str] + mode: str # either "http" or "socket_mode" def __init__( self, *, - body: str, + body: Union[str, dict], query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, str]] = None, + mode: str = "http", # either "http" or "socket_mode" ): """Request to a Bolt app. - :param body: The raw request body (only plain text is supported) + :param body: The raw request body (only plain text is supported for "http" mode) :param query: The query string data in any data format. :param headers: The request headers. :param context: The context in this request. + :param mode: The mode used for this request. (either "http" or "socket_mode") """ - self.raw_body = body + if mode == "http" and not isinstance(body, str): + raise BoltError(error_message_raw_body_required_in_http_mode()) + self.raw_body = body if mode == "http" else "" self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) - self.body = parse_body(self.raw_body, self.content_type) + if isinstance(body, str): + self.body = parse_body(self.raw_body, self.content_type) + elif isinstance(body, dict): + self.body = body + else: + raise BoltError(error_message_unknown_request_body_type()) + self.context = build_context(BoltContext(context if context else {}), self.body) self.lazy_only = self.headers.get("x-slack-bolt-lazy-only", [False])[0] self.lazy_function_name = self.headers.get( "x-slack-bolt-lazy-function-name", [None] )[0] + self.mode = mode diff --git a/tests/scenario_tests/test_events_socket_mode.py b/tests/scenario_tests/test_events_socket_mode.py new file mode 100644 index 000000000..5a02f9624 --- /dev/null +++ b/tests/scenario_tests/test_events_socket_mode.py @@ -0,0 +1,358 @@ +from time import sleep + +import pytest +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest, Say +from slack_bolt.error import BoltError +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 TestEventsSocketMode: + 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) + + valid_event_body = { + "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"], + } + + def test_mock_server_is_running(self): + resp = self.web_client.api_test() + assert resp != None + + def test_body_validation(self): + with pytest.raises(BoltError): + BoltRequest(body={"foo": "bar"}, mode="http") + + def test_middleware(self): + app = App(client=self.web_client) + + @app.event("app_mention") + def handle_app_mention(body, say, payload, event): + assert body == self.valid_event_body + assert body["event"] == payload + assert payload == event + say("What's up?") + + request: BoltRequest = BoltRequest( + body=self.valid_event_body, mode="socket_mode" + ) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_middleware_skip(self): + app = App(client=self.web_client) + + def skip_middleware(req, resp, next): + # return next() + pass + + @app.event("app_mention", middleware=[skip_middleware]) + def handle_app_mention(body, logger, payload, event): + assert body["event"] == payload + assert payload == event + logger.info(payload) + + request: BoltRequest = BoltRequest( + body=self.valid_event_body, mode="socket_mode" + ) + response = app.dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + valid_reaction_added_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": {"type": "message", "channel": "C111", "ts": "1599529504.000400"}, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + def test_reaction_added(self): + app = App(client=self.web_client) + + @app.event("reaction_added") + def handle_app_mention(body, say, payload, event): + assert body == self.valid_reaction_added_body + assert body["event"] == payload + assert payload == event + say("What's up?") + + request: BoltRequest = BoltRequest( + body=self.valid_reaction_added_body, mode="socket_mode" + ) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_stable_auto_ack(self): + app = App(client=self.web_client) + + @app.event("reaction_added") + def handle_app_mention(): + raise Exception("Something wrong!") + + for _ in range(10): + request: BoltRequest = BoltRequest( + body=self.valid_reaction_added_body, mode="socket_mode" + ) + response = app.dispatch(request) + assert response.status == 200 + + def test_self_events(self): + app = App(client=self.web_client) + + event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("reaction_added") + def handle_app_mention(say): + say("What's up?") + + request: BoltRequest = BoltRequest(body=event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + # The listener should not be executed + assert self.mock_received_requests.get("/chat.postMessage") is None + + def test_self_member_join_left_events(self): + app = App(client=self.web_client) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + def handle_member_joined_channel(say): + say("What's up?") + + @app.event("member_left_channel") + def handle_member_left_channel(say): + say("What's up?") + + request: BoltRequest = BoltRequest(body=join_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + request: BoltRequest = BoltRequest(body=left_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 2 + + def test_member_join_left_events(self): + app = App(client=self.web_client) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "U999", # not self + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "U999", # not self + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + def handle_app_mention(say): + say("What's up?") + + @app.event("member_left_channel") + def handle_app_mention(say): + say("What's up?") + + request: BoltRequest = BoltRequest(body=join_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + request: BoltRequest = BoltRequest(body=left_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + + sleep(1) # wait a bit after auto ack() + # the listeners should not be executed + assert self.mock_received_requests["/chat.postMessage"] == 2 + + def test_uninstallation_and_revokes(self): + app = App(client=self.web_client) + app._client = WebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event("app_uninstalled") + def handler1(say: Say): + say(channel="C111", text="What's up?") + + @app.event("tokens_revoked") + def handler2(say: Say): + say(channel="C111", text="What's up?") + + app_uninstalled_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + request: BoltRequest = BoltRequest( + body=app_uninstalled_body, mode="socket_mode" + ) + response = app.dispatch(request) + assert response.status == 200 + + tokens_revoked_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["UXXXXXXXX"], "bot": ["UXXXXXXXX"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + request: BoltRequest = BoltRequest(body=tokens_revoked_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 2 diff --git a/tests/scenario_tests_async/test_app.py b/tests/scenario_tests_async/test_app.py index f9e38f9ab..6f9d0a8a8 100644 --- a/tests/scenario_tests_async/test_app.py +++ b/tests/scenario_tests_async/test_app.py @@ -18,12 +18,6 @@ def setup_method(self): def teardown_method(self): restore_os_env(self.old_os_env) - def test_signing_secret_absence(self): - with pytest.raises(BoltError): - AsyncApp(signing_secret=None, token="xoxb-xxx") - with pytest.raises(BoltError): - AsyncApp(signing_secret="", token="xoxb-xxx") - def non_coro_func(self, ack): ack() diff --git a/tests/scenario_tests_async/test_events_socket_mode.py b/tests/scenario_tests_async/test_events_socket_mode.py new file mode 100644 index 000000000..d668f963c --- /dev/null +++ b/tests/scenario_tests_async/test_events_socket_mode.py @@ -0,0 +1,401 @@ +import asyncio +from random import random + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.say.async_say import AsyncSay +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 TestAsyncEvents: + 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) + + def build_valid_app_mention_request(self) -> AsyncBoltRequest: + return AsyncBoltRequest(body=app_mention_body, mode="socket_mode") + + @pytest.mark.asyncio + async def test_mock_server_is_running(self): + resp = await self.web_client.api_test() + assert resp != None + + @pytest.mark.asyncio + async def test_app_mention(self): + app = AsyncApp(client=self.web_client) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_process_before_response(self): + app = AsyncApp(client=self.web_client, process_before_response=True,) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + # no sleep here + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_middleware_skip(self): + app = AsyncApp(client=self.web_client) + app.event("app_mention", middleware=[skip_middleware])(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 404 + assert self.mock_received_requests["/auth.test"] == 1 + + @pytest.mark.asyncio + async def test_simultaneous_requests(self): + app = AsyncApp(client=self.web_client) + app.event("app_mention")(random_sleeper) + + request = self.build_valid_app_mention_request() + + times = 10 + tasks = [] + for i in range(times): + tasks.append(asyncio.ensure_future(app.async_dispatch(request))) + + await asyncio.sleep(5) + # Verifies all the tasks have been completed with 200 OK + assert sum([t.result().status for t in tasks if t.done()]) == 200 * times + + assert self.mock_received_requests["/auth.test"] == times + assert self.mock_received_requests["/chat.postMessage"] == times + + def build_valid_reaction_added_request(self) -> AsyncBoltRequest: + return AsyncBoltRequest(body=reaction_added_body, mode="socket_mode") + + @pytest.mark.asyncio + async def test_reaction_added(self): + app = AsyncApp(client=self.web_client) + app.event("reaction_added")(whats_up) + + request = self.build_valid_reaction_added_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_stable_auto_ack(self): + app = AsyncApp(client=self.web_client) + app.event("reaction_added")(always_failing) + + for _ in range(10): + request = self.build_valid_reaction_added_request() + response = await app.async_dispatch(request) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_self_events(self): + app = AsyncApp(client=self.web_client) + app.event("reaction_added")(whats_up) + + self_event = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + request = AsyncBoltRequest(body=self_event, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + # The listener should not be executed + assert self.mock_received_requests.get("/chat.postMessage") is None + + @pytest.mark.asyncio + async def test_self_joined_left_events(self): + app = AsyncApp(client=self.web_client) + app.event("reaction_added")(whats_up) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + async def handle_member_joined_channel(say): + await say("What's up?") + + @app.event("member_left_channel") + async def handle_member_left_channel(say): + await say("What's up?") + + request = AsyncBoltRequest(body=join_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + request = AsyncBoltRequest(body=left_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + + await asyncio.sleep(1) # wait a bit after auto ack() + # The listeners should be executed + assert self.mock_received_requests.get("/chat.postMessage") == 2 + + @pytest.mark.asyncio + async def test_joined_left_events(self): + app = AsyncApp(client=self.web_client) + app.event("reaction_added")(whats_up) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W111", # other user + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W111", # other user + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + async def handle_member_joined_channel(say): + await say("What's up?") + + @app.event("member_left_channel") + async def handle_member_left_channel(say): + await say("What's up?") + + request = AsyncBoltRequest(body=join_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + request = AsyncBoltRequest(body=left_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + + await asyncio.sleep(1) # wait a bit after auto ack() + # The listeners should be executed + assert self.mock_received_requests.get("/chat.postMessage") == 2 + + @pytest.mark.asyncio + async def test_uninstallation_and_revokes(self): + app = AsyncApp(client=self.web_client) + app._client = AsyncWebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event("app_uninstalled") + async def handler1(say: AsyncSay): + await say(channel="C111", text="What's up?") + + @app.event("tokens_revoked") + async def handler2(say: AsyncSay): + await say(channel="C111", text="What's up?") + + app_uninstalled_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + request: AsyncBoltRequest = AsyncBoltRequest( + body=app_uninstalled_body, mode="socket_mode" + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + tokens_revoked_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["UXXXXXXXX"], "bot": ["UXXXXXXXX"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + request: AsyncBoltRequest = AsyncBoltRequest( + body=tokens_revoked_body, mode="socket_mode" + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + # AsyncApp doesn't call auth.test when booting + assert self.mock_received_requests.get("/auth.test") is None + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 2 + + +app_mention_body = { + "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"], +} + +reaction_added_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": {"type": "message", "channel": "C111", "ts": "1599529504.000400"}, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} + + +async def random_sleeper(body, say, payload, event): + assert body == app_mention_body + assert body["event"] == payload + assert payload == event + seconds = random() + 2 # 2-3 seconds + await asyncio.sleep(seconds) + await say(f"Sending this message after sleeping for {seconds} seconds") + + +async def whats_up(body, say, payload, event): + assert body["event"] == payload + assert payload == event + await say("What's up?") + + +async def skip_middleware(req, resp, next): + # return next() + pass + + +async def always_failing(): + raise Exception("Something wrong!") From 82dd4e257a2763839d045514e4f873c9a09687c4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 24 Nov 2020 18:08:44 +0900 Subject: [PATCH 184/865] version 1.0.1 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 5becc17c0..5c4105cd3 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.0.1" From 023a610733d34984f7127c928fe6d64ff10b3b54 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 28 Nov 2020 13:49:17 +0900 Subject: [PATCH 185/865] Update code snippets in docs to be easier-to-use --- docs/_advanced/lazy_listener.md | 4 ++-- docs/_basic/listening_modals.md | 12 ++++++------ examples/aws_lambda/lazy_aws_lambda.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/_advanced/lazy_listener.md b/docs/_advanced/lazy_listener.md index c201743a2..f685c6ba6 100644 --- a/docs/_advanced/lazy_listener.md +++ b/docs/_advanced/lazy_listener.md @@ -19,8 +19,8 @@ Lazy listeners can be a solution for this issue. Rather than acting as a decorat ```python def respond_to_slack_within_3_seconds(body, ack): - if "text" in body: - ack(":x: Usage: /start-process (description here)") + if body.get("text") is None: + ack(f":x: Usage: /start-process (description here)") else: ack(f"Accepted! (task: {body['text']})") diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md index d8c3803ed..48aa70ba4 100644 --- a/docs/_basic/listening_modals.md +++ b/docs/_basic/listening_modals.md @@ -17,15 +17,15 @@ Read more about view submissions in our 0: ack(response_action="errors", errors=errors) return @@ -38,7 +38,7 @@ def handle_submission(ack, body, client, view): msg = "" try: # Save to DB - msg = f"Your submission of {val} was successful" + msg = f"Your submission of {hopes_and_dreams} was successful" except Exception as e: # Handle error msg = "There was an error with your submission" diff --git a/examples/aws_lambda/lazy_aws_lambda.py b/examples/aws_lambda/lazy_aws_lambda.py index c4bd25498..5e99942e9 100644 --- a/examples/aws_lambda/lazy_aws_lambda.py +++ b/examples/aws_lambda/lazy_aws_lambda.py @@ -25,7 +25,7 @@ def log_request(logger, body, next): def respond_to_slack_within_3_seconds(body, ack): - if body.get("text", None) is None: + if body.get("text") is None: ack(f":x: Usage: {command} (description here)") else: title = body["text"] From 9ab7dd52c81c390b6abc23320b795722092ab1d9 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 29 Nov 2020 08:02:04 +0900 Subject: [PATCH 186/865] Add GH template for blank issues --- .github/issue_template.md | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/issue_template.md diff --git a/.github/issue_template.md b/.github/issue_template.md new file mode 100644 index 000000000..186797b8f --- /dev/null +++ b/.github/issue_template.md @@ -0,0 +1,41 @@ +(Describe your issue and goal here) + +### Reproducible in: + +```bash +pip freeze | grep slack +python --version +sw_vers && uname -v # or `ver` +``` + +#### The `slack_bolt` version + +(Paste the output of `pip freeze | grep slack`) + +#### Python runtime version + +(Paste the output of `python --version`) + +#### OS info + +(Paste the output of `sw_vers && uname -v` on macOS/Linux or `ver` on Windows OS) + +#### Steps to reproduce: + +(Share the commands to run, source code, and project settings (e.g., setup.py)) + +1. +2. +3. + +### Expected result: + +(Tell what you expected to happen) + +### Actual result: + +(Tell what actually happened with logs, screenshots) + +## Requirements + +Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. From 67e0286d756ba92510315d044303f43b03380b52 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 29 Nov 2020 10:43:42 +0900 Subject: [PATCH 187/865] Tweak lazy listener function docs --- docs/_advanced/lazy_listener.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/_advanced/lazy_listener.md b/docs/_advanced/lazy_listener.md index f685c6ba6..84aa25952 100644 --- a/docs/_advanced/lazy_listener.md +++ b/docs/_advanced/lazy_listener.md @@ -19,7 +19,8 @@ Lazy listeners can be a solution for this issue. Rather than acting as a decorat ```python def respond_to_slack_within_3_seconds(body, ack): - if body.get("text") is None: + text = body.get("text") + if text is None or len(text) == 0: ack(f":x: Usage: /start-process (description here)") else: ack(f"Accepted! (task: {body['text']})") @@ -43,7 +44,7 @@ app.command("/start-process")(
    -This example deploys the code to [AWS Lambda](https://aws.amazon.com/lambda/). There are more examples within the [`sample` folder](https://github.com/slackapi/bolt-python/tree/main/adapter). +This example deploys the code to [AWS Lambda](https://aws.amazon.com/lambda/). There are more examples within the [`examples` folder](https://github.com/slackapi/bolt-python/tree/main/examples/aws_lambda). ```bash pip install slack_bolt @@ -63,11 +64,14 @@ lambda deploy --config-file config.yaml --requirements requirements.txt ```python from slack_bolt import App +from slack_bolt.adapter.aws_lambda import SlackRequestHandler + # process_before_response must be True when running on FaaS app = App(process_before_response=True) def respond_to_slack_within_3_seconds(body, ack): - if "text" in body: + text = body.get("text") + if text is None or len(text) == 0: ack(":x: Usage: /start-process (description here)") else: ack(f"Accepted! (task: {body['text']})") @@ -82,7 +86,6 @@ app.command("/start-process")( lazy=[run_long_process] # unable to call `ack()` / can have multiple functions ) -from slack_bolt.adapter.aws_lambda import SlackRequestHandler def handler(event, context): slack_handler = SlackRequestHandler(app=app) return slack_handler.handle(event, context) From 79eecae80783eefee9517a2a5b2350c5a506b18d Mon Sep 17 00:00:00 2001 From: Kory Date: Sun, 29 Nov 2020 11:35:47 -0500 Subject: [PATCH 188/865] Update oauth_app.py Updating oauth_app.py to reflect the actual database table names that are created. --- examples/sqlalchemy/oauth_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/sqlalchemy/oauth_app.py b/examples/sqlalchemy/oauth_app.py index 3c01b78bf..40e1309ec 100644 --- a/examples/sqlalchemy/oauth_app.py +++ b/examples/sqlalchemy/oauth_app.py @@ -32,7 +32,7 @@ ) try: - engine.execute("select count(*) from bots") + engine.execute("select count(*) from slack_bots") except Exception as e: installation_store.metadata.create_all(engine) oauth_state_store.metadata.create_all(engine) From 00dc97ce36c8f4710e0da43d08b05e64ee6f3687 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 1 Dec 2020 07:06:29 +0900 Subject: [PATCH 189/865] Improve code snippet in docs --- docs/_basic/publishing_views.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_basic/publishing_views.md b/docs/_basic/publishing_views.md index fd0a7baf3..09f678e06 100644 --- a/docs/_basic/publishing_views.md +++ b/docs/_basic/publishing_views.md @@ -42,5 +42,5 @@ def update_home_tab(client, event, logger): ) except Exception as e: - logger.error(f"Error opening modal: {e}") + logger.error(f"Error publishing home tab: {e}") ``` From cb21b23079a97354e5d25ba0ccb99c8c4cc2090d Mon Sep 17 00:00:00 2001 From: Steve Gill Date: Tue, 1 Dec 2020 03:03:23 -0800 Subject: [PATCH 190/865] Add org-level installation support (#148) * added initial org app support * renamed org_dashboard_grant_access to enterprise_url * started implementing support for find_installation * Complete authorize implementation * Apply other required changes * Improve the user_token retrieval * Add default team_Id Co-authored-by: Kazuhiro Sera --- examples/oauth_sqlite3_app_org_level.py | 68 +++++ setup.py | 2 +- slack_bolt/app/app.py | 1 + slack_bolt/app/async_app.py | 1 + slack_bolt/authorization/async_authorize.py | 89 +++++- slack_bolt/authorization/authorize.py | 85 +++++- slack_bolt/context/base_context.py | 8 +- .../middleware/authorization/internals.py | 14 +- slack_bolt/oauth/internals.py | 5 +- slack_bolt/oauth/oauth_flow.py | 12 + slack_bolt/request/internals.py | 10 + tests/scenario_tests/test_events_org_apps.py | 259 +++++++++++++++++ .../test_events_org_apps.py | 271 ++++++++++++++++++ .../authorization/test_authorize.py | 92 +++++- tests/slack_bolt/request/test_internals.py | 12 + .../authorization/test_async_authorize.py | 94 +++++- 16 files changed, 975 insertions(+), 48 deletions(-) create mode 100644 examples/oauth_sqlite3_app_org_level.py create mode 100644 tests/scenario_tests/test_events_org_apps.py create mode 100644 tests/scenario_tests_async/test_events_org_apps.py diff --git a/examples/oauth_sqlite3_app_org_level.py b/examples/oauth_sqlite3_app_org_level.py new file mode 100644 index 000000000..34117e268 --- /dev/null +++ b/examples/oauth_sqlite3_app_org_level.py @@ -0,0 +1,68 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App, BoltContext +from slack_bolt.oauth import OAuthFlow +from slack_sdk import WebClient + + +app = App(oauth_flow=OAuthFlow.sqlite3(database="./slackapp.db")) + + +@app.use +def dump(context, next, logger): + logger.info(context) + next() + + +@app.use +def call_apis_with_team_id(context: BoltContext, client: WebClient, next): + # client.users_list() + client.bots_info(bot=context.bot_id) + next() + + +@app.event("app_mention") +def handle_app_mentions(body, say, logger): + logger.info(body) + say("What's up?") + + +@app.command("/org-level-command") +def command(ack): + ack("I got it!") + + +@app.shortcut("org-level-shortcut") +def shortcut(ack): + ack() + + +@app.event("team_access_granted") +def team_access_granted(event): + pass + + +@app.event("team_access_revoked") +def team_access_revoked(event): + pass + + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write +# python oauth_app.py diff --git a/setup.py b/setup.py index 143ac6549..12813f30c 100755 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.0.0",], + install_requires=["slack_sdk==3.1.0b2",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 4f59e78fa..5b2dba6b2 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -774,6 +774,7 @@ def _init_context(self, req: BoltRequest): ssl=self._client.ssl, proxy=self._client.proxy, headers=self._client.headers, + team_id=req.context.team_id, ) req.context["client"] = client_per_request diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index b80106919..591d1ac95 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -800,6 +800,7 @@ def _init_context(self, req: AsyncBoltRequest): session=self._async_client.session, trust_env_in_session=self._async_client.trust_env_in_session, headers=self._async_client.headers, + team_id=req.context.team_id, ) req.context["client"] = client_per_request diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index 02a61ca07..8199ab299 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -3,7 +3,7 @@ from typing import Optional, Callable, Awaitable, Dict, Any from slack_sdk.errors import SlackApiError -from slack_sdk.oauth.installation_store import Bot +from slack_sdk.oauth.installation_store import Bot, Installation from slack_sdk.oauth.installation_store.async_installation_store import ( AsyncInstallationStore, ) @@ -92,6 +92,7 @@ async def __call__( class AsyncInstallationStoreAuthorize(AsyncAuthorize): authorize_result_cache: Dict[str, AuthorizeResult] = {} + find_installation_available: Optional[bool] def __init__( self, @@ -103,6 +104,7 @@ def __init__( self.logger = logger self.installation_store = installation_store self.cache_enabled = cache_enabled + self.find_installation_available = None async def __call__( self, @@ -112,27 +114,74 @@ async def __call__( team_id: str, user_id: Optional[str], ) -> Optional[AuthorizeResult]: - bot: Optional[Bot] = await self.installation_store.async_find_bot( - enterprise_id=enterprise_id, team_id=team_id, - ) - if bot is None: - self.logger.debug( - f"No installation data found " - f"for enterprise_id: {enterprise_id} team_id: {team_id}" + + if self.find_installation_available is None: + self.find_installation_available = hasattr( + self.installation_store, "async_find_installation" ) + + bot_token: Optional[str] = None + user_token: Optional[str] = None + + if self.find_installation_available: + # since v1.1, this is the default way + try: + installation: Optional[ + Installation + ] = await self.installation_store.async_find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + if installation is None: + self._debug_log_for_not_found(enterprise_id, team_id) + return None + + if installation.user_id != user_id: + # try to fetch the request user's installation + # to reflect the user's access token if exists + user_installation = await self.installation_store.async_find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) + if user_installation is not None: + installation = user_installation + + bot_token, user_token = installation.bot_token, installation.user_token + except NotImplementedError as _: + self.find_installation_available = False + + if not self.find_installation_available: + # Use find_bot to get bot value (legacy) + bot: Optional[Bot] = await self.installation_store.async_find_bot( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + if bot is None: + self._debug_log_for_not_found(enterprise_id, team_id) + return None + bot_token, user_token = bot.bot_token, None + + token: Optional[str] = bot_token or user_token + if token is None: return None - if self.cache_enabled and bot.bot_token in self.authorize_result_cache: - return self.authorize_result_cache[bot.bot_token] + # Check cache to see if the bot object already exists + if self.cache_enabled and token in self.authorize_result_cache: + return self.authorize_result_cache[token] + try: - auth_result = await context.client.auth_test(token=bot.bot_token) + auth_test_api_response = await context.client.auth_test(token=token) authorize_result = AuthorizeResult.from_auth_test_response( - auth_test_response=auth_result, - bot_token=bot.bot_token, - user_token=None, # Not yet supported + auth_test_response=auth_test_api_response, + bot_token=bot_token, + user_token=user_token, ) if self.cache_enabled: - self.authorize_result_cache[bot.bot_token] = authorize_result + self.authorize_result_cache[token] = authorize_result return authorize_result except SlackApiError as err: self.logger.debug( @@ -140,3 +189,13 @@ async def __call__( f"is no longer valid. (response: {err.response})" ) return None + + # ------------------------------------------------ + + def _debug_log_for_not_found( + self, enterprise_id: Optional[str], team_id: Optional[str] + ): + self.logger.debug( + "No installation data found " + f"for enterprise_id: {enterprise_id} team_id: {team_id}" + ) diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 0fea0eaab..2393d99f2 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -5,6 +5,7 @@ from slack_sdk.errors import SlackApiError from slack_sdk.oauth import InstallationStore from slack_sdk.oauth.installation_store import Bot +from slack_sdk.oauth.installation_store.models.installation import Installation from slack_bolt.authorization.authorize_args import AuthorizeArgs from slack_bolt.authorization.authorize_result import AuthorizeResult @@ -90,6 +91,7 @@ def __call__( class InstallationStoreAuthorize(Authorize): authorize_result_cache: Dict[str, AuthorizeResult] = {} + find_installation_available: bool def __init__( self, @@ -101,6 +103,9 @@ def __init__( self.logger = logger self.installation_store = installation_store self.cache_enabled = cache_enabled + self.find_installation_available = hasattr( + installation_store, "find_installation" + ) def __call__( self, @@ -110,27 +115,69 @@ def __call__( team_id: str, user_id: Optional[str], ) -> Optional[AuthorizeResult]: - bot: Optional[Bot] = self.installation_store.find_bot( - enterprise_id=enterprise_id, team_id=team_id, - ) - if bot is None: - self.logger.debug( - f"No installation data found " - f"for enterprise_id: {enterprise_id} team_id: {team_id}" + + bot_token: Optional[str] = None + user_token: Optional[str] = None + + if self.find_installation_available: + # since v1.1, this is the default way + try: + installation: Optional[ + Installation + ] = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + if installation is None: + self._debug_log_for_not_found(enterprise_id, team_id) + return None + + if installation.user_id != user_id: + # try to fetch the request user's installation + # to reflect the user's access token if exists + user_installation = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) + if user_installation is not None: + installation = user_installation + + bot_token, user_token = installation.bot_token, installation.user_token + except NotImplementedError as _: + self.find_installation_available = False + + if not self.find_installation_available: + # Use find_bot to get bot value (legacy) + bot: Optional[Bot] = self.installation_store.find_bot( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, ) + if bot is None: + self._debug_log_for_not_found(enterprise_id, team_id) + return None + bot_token, user_token = bot.bot_token, None + + token: Optional[str] = bot_token or user_token + if token is None: return None - if self.cache_enabled and bot.bot_token in self.authorize_result_cache: - return self.authorize_result_cache[bot.bot_token] + # Check cache to see if the bot object already exists + if self.cache_enabled and token in self.authorize_result_cache: + return self.authorize_result_cache[token] + try: - auth_result = context.client.auth_test(token=bot.bot_token) + auth_test_api_response = context.client.auth_test(token=token) authorize_result = AuthorizeResult.from_auth_test_response( - auth_test_response=auth_result, - bot_token=bot.bot_token, - user_token=None, # Not yet supported + auth_test_response=auth_test_api_response, + bot_token=bot_token, + user_token=user_token, ) if self.cache_enabled: - self.authorize_result_cache[bot.bot_token] = authorize_result + self.authorize_result_cache[token] = authorize_result return authorize_result except SlackApiError as err: self.logger.debug( @@ -138,3 +185,13 @@ def __call__( f"is no longer valid. (response: {err.response})" ) return None + + # ------------------------------------------------ + + def _debug_log_for_not_found( + self, enterprise_id: Optional[str], team_id: Optional[str] + ): + self.logger.debug( + "No installation data found " + f"for enterprise_id: {enterprise_id} team_id: {team_id}" + ) diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 39468b922..44e20a460 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -18,8 +18,12 @@ def enterprise_id(self) -> Optional[str]: return self.get("enterprise_id") @property - def team_id(self) -> str: - return self["team_id"] + def is_enterprise_install(self) -> Optional[bool]: + return self.get("is_enterprise_install") + + @property + def team_id(self) -> Optional[str]: + return self.get("team_id") @property def user_id(self) -> Optional[str]: diff --git a/slack_bolt/middleware/authorization/internals.py b/slack_bolt/middleware/authorization/internals.py index 90f65af61..cede00c42 100644 --- a/slack_bolt/middleware/authorization/internals.py +++ b/slack_bolt/middleware/authorization/internals.py @@ -29,21 +29,15 @@ def _is_ssl_check(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ) -def _is_uninstallation_event(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore - return ( - req is not None - and req.body is not None - and req.body.get("type") == "event_callback" - and req.body.get("event", {}).get("type") == "app_uninstalled" - ) +no_auth_test_events = ["app_uninstalled", "tokens_revoked", "team_access_revoked"] -def _is_tokens_revoked_event(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore +def _is_no_auth_test_events(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore return ( req is not None and req.body is not None and req.body.get("type") == "event_callback" - and req.body.get("event", {}).get("type") == "tokens_revoked" + and req.body.get("event", {}).get("type") in no_auth_test_events ) @@ -52,7 +46,7 @@ def _is_no_auth_required(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: def _is_no_auth_test_call_required(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore - return _is_uninstallation_event(req) or _is_tokens_revoked_event(req) + return _is_no_auth_test_events(req) def _build_error_response() -> BoltResponse: diff --git a/slack_bolt/oauth/internals.py b/slack_bolt/oauth/internals.py index bc237eb7a..e551f682f 100644 --- a/slack_bolt/oauth/internals.py +++ b/slack_bolt/oauth/internals.py @@ -33,7 +33,10 @@ def _build_callback_success_response( # type: ignore self._logger.debug(debug_message) html = self._redirect_uri_page_renderer.render_success_page( - app_id=installation.app_id, team_id=installation.team_id, + app_id=installation.app_id, + team_id=installation.team_id, + is_enterprise_install=installation.is_enterprise_install, + enterprise_url=installation.enterprise_url, ) return BoltResponse( status=200, diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index 8f4dbecb8..705975087 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -288,6 +288,9 @@ def run_installation(self, code: str) -> Optional[Installation]: installed_enterprise: Dict[str, str] = oauth_response.get( "enterprise" ) or {} + is_enterprise_install: bool = oauth_response.get( + "is_enterprise_install" + ) or False installed_team: Dict[str, str] = oauth_response.get("team") or {} installer: Dict[str, str] = oauth_response.get("authed_user") or {} incoming_webhook: Dict[str, str] = oauth_response.get( @@ -297,14 +300,20 @@ def run_installation(self, code: str) -> Optional[Installation]: bot_token: Optional[str] = oauth_response.get("access_token") # NOTE: oauth.v2.access doesn't include bot_id in response bot_id: Optional[str] = None + enterprise_url: Optional[str] = None if bot_token is not None: auth_test = self.client.auth_test(token=bot_token) bot_id = auth_test["bot_id"] + if is_enterprise_install is True: + enterprise_url = auth_test.get("url") return Installation( app_id=oauth_response.get("app_id"), enterprise_id=installed_enterprise.get("id"), + enterprise_name=installed_enterprise.get("name"), + enterprise_url=enterprise_url, team_id=installed_team.get("id"), + team_name=installed_team.get("name"), bot_token=bot_token, bot_id=bot_id, bot_user_id=oauth_response.get("bot_user_id"), @@ -313,10 +322,13 @@ def run_installation(self, code: str) -> Optional[Installation]: user_token=installer.get("access_token"), user_scopes=installer.get("scope"), # comma-separated string incoming_webhook_url=incoming_webhook.get("url"), + incoming_webhook_channel=incoming_webhook.get("channel"), incoming_webhook_channel_id=incoming_webhook.get("channel_id"), incoming_webhook_configuration_url=incoming_webhook.get( "configuration_url", None ), + is_enterprise_install=is_enterprise_install, + token_type=oauth_response.get("token_type"), ) except SlackApiError as e: diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 634f218e3..b9a42b914 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -46,6 +46,15 @@ def parse_body(body: str, content_type: Optional[str]) -> Dict[str, Any]: return dict(parse_qsl(body)) +def extract_is_enterprise_install(payload: Dict[str, Any]) -> Optional[bool]: + if "is_enterprise_install" in payload: + is_enterprise_install = payload.get("is_enterprise_install") + return is_enterprise_install is not None and ( + is_enterprise_install is True or is_enterprise_install == "true" + ) + return False + + def extract_enterprise_id(payload: Dict[str, Any]) -> Optional[str]: if "enterprise" in payload: org = payload.get("enterprise") @@ -119,6 +128,7 @@ def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: def build_context(context: BoltContext, body: Dict[str, Any]) -> BoltContext: + context["is_enterprise_install"] = extract_is_enterprise_install(body) enterprise_id = extract_enterprise_id(body) if enterprise_id: context["enterprise_id"] = enterprise_id diff --git a/tests/scenario_tests/test_events_org_apps.py b/tests/scenario_tests/test_events_org_apps.py new file mode 100644 index 000000000..01dbe4be0 --- /dev/null +++ b/tests/scenario_tests/test_events_org_apps.py @@ -0,0 +1,259 @@ +import json +from time import time, sleep +from typing import Optional + +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Installation +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest +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 + +valid_token = "xoxb-valid" + + +class OrgAppInstallationStore(InstallationStore): + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False + ) -> Optional[Installation]: + assert enterprise_id == "E111" + assert team_id is None + return Installation( + enterprise_id="E111", + team_id=None, + user_id=user_id, + bot_token=valid_token, + bot_id="B111", + ) + + +class Result: + def __init__(self): + self.called = False + + +class TestEventsOrgApps: + signing_secret = "secret" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client: WebClient = WebClient( + token=None, 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_team_access_granted(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "team_access_granted", + "team_ids": ["T111", "T222"], + "event_ts": "111.222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + result = Result() + + @app.event("team_access_granted") + def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert result.called is True + + def test_team_access_revoked(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "team_access_revoked", + "team_ids": ["T111", "T222"], + "event_ts": "1606805732.987656", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805732, + } + + result = Result() + + @app.event("team_access_revoked") + def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert self.mock_received_requests.get("/auth.test") is None + sleep(1) # wait a bit after auto ack() + assert result.called is True + + def test_app_home_opened(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "app_home_opened", + "user": "W111", + "channel": "D111", + "tab": "messages", + "event_ts": "1606810927.510671", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606810927, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": None, + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, + } + + result = Result() + + @app.event("app_home_opened") + def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert result.called is True + + def test_message(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "0186b75a-2ad4-4f36-8ccc-18608b0ac5d1", + "type": "message", + "text": "<@W222>", + "user": "W111", + "ts": "1606810819.000800", + "team": "T111", + "channel": "C111", + "event_ts": "1606810819.000800", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606810819, + "authed_users": [], + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": None, + "user_id": "W222", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", + } + + result = Result() + + @app.event("message") + def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert result.called is True diff --git a/tests/scenario_tests_async/test_events_org_apps.py b/tests/scenario_tests_async/test_events_org_apps.py new file mode 100644 index 000000000..04182d937 --- /dev/null +++ b/tests/scenario_tests_async/test_events_org_apps.py @@ -0,0 +1,271 @@ +import asyncio +import json +from time import time +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store import Installation +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +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 + +valid_token = "xoxb-valid" + + +class OrgAppInstallationStore(AsyncInstallationStore): + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False + ) -> Optional[Installation]: + assert enterprise_id == "E111" + assert team_id is None + return Installation( + enterprise_id="E111", + team_id=None, + user_id=user_id, + bot_token=valid_token, + bot_id="B111", + ) + + +class Result: + def __init__(self): + self.called = False + + +class TestAsyncOrgApps: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient(token=None, 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) + + 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + @pytest.mark.asyncio + async def test_team_access_granted(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "team_access_granted", + "team_ids": ["T111", "T222"], + "event_ts": "111.222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + result = Result() + + @app.event("team_access_granted") + async def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert result.called is True + + @pytest.mark.asyncio + async def test_team_access_revoked(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "team_access_revoked", + "team_ids": ["T111", "T222"], + "event_ts": "1606805732.987656", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805732, + } + + result = Result() + + @app.event("team_access_revoked") + async def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert self.mock_received_requests.get("/auth.test") is None + await asyncio.sleep(1) # wait a bit after auto ack() + assert result.called is True + + @pytest.mark.asyncio + async def test_app_home_opened(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "app_home_opened", + "user": "W111", + "channel": "D111", + "tab": "messages", + "event_ts": "1606810927.510671", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606810927, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": None, + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, + } + + result = Result() + + @app.event("app_home_opened") + async def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert result.called is True + + @pytest.mark.asyncio + async def test_message(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "0186b75a-2ad4-4f36-8ccc-18608b0ac5d1", + "type": "message", + "text": "<@W222>", + "user": "W111", + "ts": "1606810819.000800", + "team": "T111", + "channel": "C111", + "event_ts": "1606810819.000800", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606810819, + "authed_users": [], + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": None, + "user_id": "W222", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", + } + + result = Result() + + @app.event("message") + async def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert result.called is True diff --git a/tests/slack_bolt/authorization/test_authorize.py b/tests/slack_bolt/authorization/test_authorize.py index 600c3c626..fff72f575 100644 --- a/tests/slack_bolt/authorization/test_authorize.py +++ b/tests/slack_bolt/authorization/test_authorize.py @@ -24,11 +24,64 @@ def setup_method(self): def teardown_method(self): cleanup_mock_web_api_server(self) + def test_installation_store_legacy(self): + installation_store = LegacyMemoryInstallationStore() + authorize = InstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + assert authorize.find_installation_available is True + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert authorize.find_installation_available is False + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 1 + + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 2 + + def test_installation_store_cached_legacy(self): + installation_store = LegacyMemoryInstallationStore() + authorize = InstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + cache_enabled=True, + ) + assert authorize.find_installation_available is True + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert authorize.find_installation_available is False + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 1 + + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 1 # cached + def test_installation_store(self): installation_store = MemoryInstallationStore() authorize = InstallationStoreAuthorize( logger=installation_store.logger, installation_store=installation_store ) + assert authorize.find_installation_available is True context = BoltContext() context["client"] = WebClient(base_url=self.mock_api_server_base_url) result = authorize( @@ -36,6 +89,7 @@ def test_installation_store(self): ) assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" assert self.mock_received_requests["/auth.test"] == 1 result = authorize( @@ -43,6 +97,7 @@ def test_installation_store(self): ) assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" assert self.mock_received_requests["/auth.test"] == 2 def test_installation_store_cached(self): @@ -52,6 +107,7 @@ def test_installation_store_cached(self): installation_store=installation_store, cache_enabled=True, ) + assert authorize.find_installation_available is True context = BoltContext() context["client"] = WebClient(base_url=self.mock_api_server_base_url) result = authorize( @@ -59,6 +115,7 @@ def test_installation_store_cached(self): ) assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" assert self.mock_received_requests["/auth.test"] == 1 result = authorize( @@ -66,10 +123,11 @@ def test_installation_store_cached(self): ) assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" assert self.mock_received_requests["/auth.test"] == 1 # cached -class MemoryInstallationStore(InstallationStore): +class LegacyMemoryInstallationStore(InstallationStore): @property def logger(self) -> Logger: return logging.getLogger(__name__) @@ -78,15 +136,43 @@ def save(self, installation: Installation): pass def find_bot( - self, *, enterprise_id: Optional[str], team_id: Optional[str] + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, ) -> Optional[Bot]: return Bot( app_id="A111", enterprise_id="E111", team_id="T0G9PQBBK", - bot_token="xoxb-valid", + bot_token="xoxb-valid-1", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class MemoryInstallationStore(LegacyMemoryInstallationStore): + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", bot_id="B", bot_user_id="W", bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], installed_at=datetime.datetime.now().timestamp(), ) diff --git a/tests/slack_bolt/request/test_internals.py b/tests/slack_bolt/request/test_internals.py index fce00681c..496f690a6 100644 --- a/tests/slack_bolt/request/test_internals.py +++ b/tests/slack_bolt/request/test_internals.py @@ -6,6 +6,7 @@ extract_team_id, extract_enterprise_id, parse_query, + extract_is_enterprise_install, ) @@ -69,6 +70,17 @@ def test_enterprise_id_extraction(self): team_id = extract_enterprise_id(req) assert team_id == "E111" + def test_is_enterprise_install_extraction(self): + for req in self.requests: + should_be_false = extract_is_enterprise_install(req) + assert should_be_false is False + assert extract_is_enterprise_install({"is_enterprise_install": True}) is True + assert extract_is_enterprise_install({"is_enterprise_install": False}) is False + assert extract_is_enterprise_install({"is_enterprise_install": "true"}) is True + assert ( + extract_is_enterprise_install({"is_enterprise_install": "false"}) is False + ) + def test_parse_query(self): expected = {"foo": ["bar"], "baz": ["123"]} diff --git a/tests/slack_bolt_async/authorization/test_async_authorize.py b/tests/slack_bolt_async/authorization/test_async_authorize.py index 30b2d2d92..5df1cd0c2 100644 --- a/tests/slack_bolt_async/authorization/test_async_authorize.py +++ b/tests/slack_bolt_async/authorization/test_async_authorize.py @@ -36,19 +36,76 @@ def event_loop(self): finally: restore_os_env(old_os_env) + @pytest.mark.asyncio + async def test_installation_store_legacy(self): + installation_store = LegacyMemoryInstallationStore() + authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + assert authorize.find_installation_available is None + context = AsyncBoltContext() + context["client"] = self.client + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert authorize.find_installation_available is False + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 1 + + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 2 + + @pytest.mark.asyncio + async def test_installation_store_cached_legacy(self): + installation_store = LegacyMemoryInstallationStore() + authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + cache_enabled=True, + ) + assert authorize.find_installation_available is None + context = AsyncBoltContext() + context["client"] = self.client + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert authorize.find_installation_available is False + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 1 + + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 1 # cached + @pytest.mark.asyncio async def test_installation_store(self): installation_store = MemoryInstallationStore() authorize = AsyncInstallationStoreAuthorize( logger=installation_store.logger, installation_store=installation_store ) + assert authorize.find_installation_available is None context = AsyncBoltContext() context["client"] = self.client result = await authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" ) + assert authorize.find_installation_available is True assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" assert self.mock_received_requests["/auth.test"] == 1 result = await authorize( @@ -56,6 +113,7 @@ async def test_installation_store(self): ) assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" assert self.mock_received_requests["/auth.test"] == 2 @pytest.mark.asyncio @@ -66,13 +124,16 @@ async def test_installation_store_cached(self): installation_store=installation_store, cache_enabled=True, ) + assert authorize.find_installation_available is None context = AsyncBoltContext() context["client"] = self.client result = await authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" ) + assert authorize.find_installation_available is True assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" assert self.mock_received_requests["/auth.test"] == 1 result = await authorize( @@ -80,10 +141,11 @@ async def test_installation_store_cached(self): ) assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" assert self.mock_received_requests["/auth.test"] == 1 # cached -class MemoryInstallationStore(AsyncInstallationStore): +class LegacyMemoryInstallationStore(AsyncInstallationStore): @property def logger(self) -> Logger: return logging.getLogger(__name__) @@ -92,7 +154,11 @@ async def async_save(self, installation: Installation): pass async def async_find_bot( - self, *, enterprise_id: Optional[str], team_id: Optional[str] + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, ) -> Optional[Bot]: return Bot( app_id="A111", @@ -104,3 +170,27 @@ async def async_find_bot( bot_scopes=["commands", "chat:write"], installed_at=datetime.datetime.now().timestamp(), ) + + +class MemoryInstallationStore(LegacyMemoryInstallationStore): + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) From c35cbdfaa175e7fb7ae1fa77d2c7cdad0b95db2f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 1 Dec 2020 20:31:41 +0900 Subject: [PATCH 191/865] version 1.1.0rc1 --- setup.py | 4 ++-- slack_bolt/version.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 12813f30c..2c31545dd 100755 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ "pytest-cov>=2,<3", "pytest-asyncio<1", # for async "aiohttp>=3,<4", # for async - "black==19.10b0", + "black==19.10b0", # TODO: upgrading the version ] setuptools.setup( @@ -33,7 +33,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk==3.1.0b2",], + install_requires=["slack_sdk>=3.1.0rc1,<3.2",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 5c4105cd3..3b4ec64b0 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.0.1" +__version__ = "1.1.0rc1" From 72306d20cf19025e81746b3d55a86be0a3622be1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 2 Dec 2020 12:00:17 +0900 Subject: [PATCH 192/865] version 1.1.0 --- setup.py | 2 +- slack_bolt/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 2c31545dd..b049c0102 100755 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.1.0rc1,<3.2",], + install_requires=["slack_sdk>=3.1.0,<3.2",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 3b4ec64b0..6849410aa 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.1.0rc1" +__version__ = "1.1.0" From 9ce2c350a82314037ca8557d0dea00e0c45b1ab0 Mon Sep 17 00:00:00 2001 From: Steve Gill Date: Thu, 26 Nov 2020 16:55:15 -0800 Subject: [PATCH 193/865] adding docs for org apps --- docs/_basic/authenticating_oauth.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md index 0de17ffe9..e31acfe52 100644 --- a/docs/_basic/authenticating_oauth.md +++ b/docs/_basic/authenticating_oauth.md @@ -13,6 +13,8 @@ Bolt for Python will create a **Redirect URL** `slack/oauth_redirect`, which Sla Bolt for Python will also create a `slack/install` route, where you can find an **Add to Slack** button for your app to perform direct installs of your app. If you need any additional authorizations (user tokens) from users inside a team when your app is already installed or a reason to dynamically generate an install URL, you can pass your own custom URL generator to `oauth_settings` as `authorize_url_generator`. +Bolt for Python automatically includes support for [org wide installations](https://api.slack.com/enterprise/apps). Org wide installations can be enabled in your app configuration settings under **Org Level Apps**. + To learn more about the OAuth installation flow with Slack, [read the API documentation](https://api.slack.com/authentication/oauth-v2).
    From cba6a708c1dd254f584b5ce6952212360184732d Mon Sep 17 00:00:00 2001 From: Steve Gill Date: Tue, 1 Dec 2020 15:09:21 -0800 Subject: [PATCH 194/865] Added minimum bolt-python version for org-apps --- docs/_basic/authenticating_oauth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md index e31acfe52..48b903c14 100644 --- a/docs/_basic/authenticating_oauth.md +++ b/docs/_basic/authenticating_oauth.md @@ -13,7 +13,7 @@ Bolt for Python will create a **Redirect URL** `slack/oauth_redirect`, which Sla Bolt for Python will also create a `slack/install` route, where you can find an **Add to Slack** button for your app to perform direct installs of your app. If you need any additional authorizations (user tokens) from users inside a team when your app is already installed or a reason to dynamically generate an install URL, you can pass your own custom URL generator to `oauth_settings` as `authorize_url_generator`. -Bolt for Python automatically includes support for [org wide installations](https://api.slack.com/enterprise/apps). Org wide installations can be enabled in your app configuration settings under **Org Level Apps**. +Bolt for Python automatically includes support for [org wide installations](https://api.slack.com/enterprise/apps) in version `1.1.0+`. Org wide installations can be enabled in your app configuration settings under **Org Level Apps**. To learn more about the OAuth installation flow with Slack, [read the API documentation](https://api.slack.com/authentication/oauth-v2). From b10b4a80ed7a38dec4fbeeadb4d384bbcce6b249 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 2 Dec 2020 20:27:30 +0900 Subject: [PATCH 195/865] Add CI build action --- .github/workflows/ci-build.yml | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/ci-build.yml diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml new file mode 100644 index 000000000..21ff8a609 --- /dev/null +++ b/.github/workflows/ci-build.yml @@ -0,0 +1,49 @@ +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions +name: CI Build + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.6', '3.7', '3.8', '3.9'] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python setup.py install + pip install -U pip + pip install "pytest>=5,<6" "pytest-cov>=2,<3" + - name: Run tests without aiohttp + run: | + pytest tests/slack_bolt/ + pytest tests/scenario_tests/ + - name: Run tests for adapters + run: | + pip install -e ".[adapter]" + pytest tests/adapter_tests/ + - name: Run async tests + run: | + pip install -e ".[async]" + pip install "pytest-asyncio<1" + pytest tests/slack_bolt_async/ + pytest tests/scenario_tests_async/ + pytest tests/adapter_tests_async/ + - name: Run pytype verification & codecov + run: | + python_version=`python -V` + if [ ${python_version:7:3} == "3.8" ]; then + pip install "pytype" && pytype slack_bolt/ + pytest --cov=slack_bolt/ && bash <(curl -s https://codecov.io/bash) + fi From 4bd526145a12ec1ad7b5a0507d4c62c4e261ecb9 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 2 Dec 2020 21:51:40 +0900 Subject: [PATCH 196/865] Update CI builds --- .github/workflows/ci-build.yml | 15 ++++++++++----- README.md | 6 +++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 21ff8a609..74f9ed8bc 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -35,11 +35,16 @@ jobs: pytest tests/adapter_tests/ - name: Run async tests run: | - pip install -e ".[async]" - pip install "pytest-asyncio<1" - pytest tests/slack_bolt_async/ - pytest tests/scenario_tests_async/ - pytest tests/adapter_tests_async/ + # As the tests here are often slow on GitHub Actions, + # we pick up only one Python version for it. + python_version=`python -V` + if [ ${python_version:7:3} == "3.9" ]; then + pip install -e ".[async]" + pip install "pytest-asyncio<1" + pytest tests/slack_bolt_async/ + pytest tests/scenario_tests_async/ + pytest tests/adapter_tests_async/ + fi - name: Run pytype verification & codecov run: | python_version=`python -V` diff --git a/README.md b/README.md index 7dde2c3c7..d046cb59d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Python Version][python-version]][pypi-url] [![pypi package][pypi-image]][pypi-url] -[![Build Status][travis-image]][travis-url] +[![Build Status][build-image]][build-url] [![Codecov][codecov-image]][codecov-url] A Python framework to build Slack apps in a flash with the latest platform features. Read the [getting started guide](https://slack.dev/bolt-python/tutorial/getting-started) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. @@ -156,8 +156,8 @@ If you otherwise get stuck, we're here to help. The following are the best ways [pypi-image]: https://badge.fury.io/py/slack-bolt.svg [pypi-url]: https://pypi.org/project/slack-bolt/ -[travis-image]: https://travis-ci.org/slackapi/bolt-python.svg?branch=main -[travis-url]: https://travis-ci.org/slackapi/bolt-python +[build-image]: https://github.com/slackapi/bolt-python/workflows/CI%20Build/badge.svg +[build-url]: https://github.com/slackapi/bolt-python/actions?query=workflow%3A%22CI+Build%22 [codecov-image]: https://codecov.io/gh/slackapi/bolt-python/branch/main/graph/badge.svg [codecov-url]: https://codecov.io/gh/slackapi/bolt-python [python-version]: https://img.shields.io/pypi/pyversions/slack-bolt.svg From 8586d9e862fed63d11349f1a014acf471a9aa5e8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 2 Dec 2020 21:58:51 +0900 Subject: [PATCH 197/865] Fix build settings --- .github/workflows/ci-build.yml | 2 ++ .travis.yml | 31 ------------------------------- 2 files changed, 2 insertions(+), 31 deletions(-) delete mode 100644 .travis.yml diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 74f9ed8bc..79f3e98f1 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -49,6 +49,8 @@ jobs: run: | python_version=`python -V` if [ ${python_version:7:3} == "3.8" ]; then + pip install -e ".[async]" + pip install -e ".[adapter]" pip install "pytype" && pytype slack_bolt/ pytest --cov=slack_bolt/ && bash <(curl -s https://codecov.io/bash) fi diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 3f45f8b89..000000000 --- a/.travis.yml +++ /dev/null @@ -1,31 +0,0 @@ -language: - python -python: - - "3.6" - - "3.7" - - "3.8" - - "3.9" -install: - - python setup.py install - - pip install -U pip - # https://discuss.python.org/t/announcement-pip-20-2-release/4863 - - pip config set global.use-feature 2020-resolver - - pip install "pytest>=5,<6" "pytest-cov>=2,<3" -script: - # testing without aiohttp - - travis_retry pytest tests/slack_bolt/ - - travis_retry pytest tests/scenario_tests/ - # testing for adapters - - pip install -e ".[adapter]" - - travis_retry pytest tests/adapter_tests/ - # testing with aiohttp - - pip install -e ".[async]" - - pip install "pytest-asyncio<1" - - travis_retry pytest tests/slack_bolt_async/ - - travis_retry pytest tests/scenario_tests_async/ - - travis_retry pytest tests/adapter_tests_async/ - # run all tests just in case - - travis_retry python setup.py test - # Run pytype only for Python 3.8 - - if [ ${TRAVIS_PYTHON_VERSION:0:3} == "3.8" ]; then pip install "pytype" && pytype slack_bolt/; fi - - if [ ${TRAVIS_PYTHON_VERSION:0:3} == "3.8" ]; then pytest --cov=slack_bolt/ && bash <(curl -s https://codecov.io/bash); fi From c8b28df4d311f04309c6f29938db2adc618fa4c1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 2 Dec 2020 22:36:46 +0900 Subject: [PATCH 198/865] Switch async test execution to python 3.6 --- .github/workflows/ci-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 79f3e98f1..0c4ca9d6f 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -38,7 +38,7 @@ jobs: # As the tests here are often slow on GitHub Actions, # we pick up only one Python version for it. python_version=`python -V` - if [ ${python_version:7:3} == "3.9" ]; then + if [ ${python_version:7:3} == "3.6" ]; then pip install -e ".[async]" pip install "pytest-asyncio<1" pytest tests/slack_bolt_async/ From f438c03430c24b63fdc86c684a28fe7f0d5f4a99 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 2 Dec 2020 16:21:16 +0900 Subject: [PATCH 199/865] Fix #165 by making AsyncApp compatible with aiohttp-devtools --- examples/aiohttp_devtools/async_app.py | 37 ++++++++++++++++++++++ examples/aiohttp_devtools/requirements.txt | 2 ++ slack_bolt/app/async_app.py | 32 ++++++++++++++++--- slack_bolt/app/async_server.py | 32 ++++++++++--------- tests/mock_web_api_server.py | 2 +- 5 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 examples/aiohttp_devtools/async_app.py create mode 100644 examples/aiohttp_devtools/requirements.txt diff --git a/examples/aiohttp_devtools/async_app.py b/examples/aiohttp_devtools/async_app.py new file mode 100644 index 000000000..605b90e31 --- /dev/null +++ b/examples/aiohttp_devtools/async_app.py @@ -0,0 +1,37 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "../..") +# ------------------------------------------------ + +import logging + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt.async_app import AsyncApp + +app = AsyncApp() + + +@app.event("app_mention") +async def event_test(body, say, logger): + logger.info(body) + await say("What's up?") + + +@app.command("/hello-bolt-python") +# or app.command(re.compile(r"/hello-.+"))(test_command) +async def command(ack, body): + user_id = body["user_id"] + await ack(f"Hi <@{user_id}>!") + + +def app_factory(): + return app.web_app() + + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# adev runserver --port 3000 --app-factory app_factory async_app.py diff --git a/examples/aiohttp_devtools/requirements.txt b/examples/aiohttp_devtools/requirements.txt new file mode 100644 index 000000000..2e4b095ca --- /dev/null +++ b/examples/aiohttp_devtools/requirements.txt @@ -0,0 +1,2 @@ +aiohttp>=3,<4 +aiohttp-devtools>=0.13,<0.14 \ No newline at end of file diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 591d1ac95..140d2b619 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -3,6 +3,9 @@ import os from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable, Sequence +from aiohttp import web + +from slack_bolt.app.async_server import AsyncSlackAppServer from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner from slack_bolt.middleware.message_listener_matches.async_message_listener_matches import ( AsyncMessageListenerMatches, @@ -225,6 +228,8 @@ def __init__( self._init_middleware_list_done = False self._init_async_middleware_list() + self._server: Optional[AsyncSlackAppServer] = None + def _init_async_middleware_list(self): if self._init_middleware_list_done: return @@ -282,6 +287,28 @@ def listener_runner(self) -> AsyncioListenerRunner: # ------------------------- # standalone server + from .async_server import AsyncSlackAppServer + + def server( + self, port: int = 3000, path: str = "/slack/events" + ) -> AsyncSlackAppServer: + """Configure a web server using AIOHTTP. + + :param port: The port to listen on (Default: 3000) + :param path: The path to handle request from Slack (Default: /slack/events) + :return: None + """ + if ( + self._server is None + or self._server.port != port + or self._server.path != path + ): + self._server = AsyncSlackAppServer(port=port, path=path, app=self,) + return self._server + + def web_app(self, path: str = "/slack/events") -> web.Application: + return self.server(path=path).web_app + def start(self, port: int = 3000, path: str = "/slack/events") -> None: """Start a web server using AIOHTTP. @@ -289,10 +316,7 @@ def start(self, port: int = 3000, path: str = "/slack/events") -> None: :param path: The path to handle request from Slack (Default: /slack/events) :return: None """ - from .async_server import AsyncSlackAppServer - - self.server = AsyncSlackAppServer(port=port, path=path, app=self,) - self.server.start() + self.server(port=port, path=path).start() # ------------------------- # main dispatcher diff --git a/slack_bolt/app/async_server.py b/slack_bolt/app/async_server.py index f4272e710..a00938a83 100644 --- a/slack_bolt/app/async_server.py +++ b/slack_bolt/app/async_server.py @@ -7,20 +7,24 @@ class AsyncSlackAppServer: + port: int + path: str + bolt_app: "AsyncApp" + web_app: web.Application + def __init__( - self, port: int, path: str, app, # AsyncApp + self, port: int, path: str, app: "AsyncApp", ): """Standalone AIOHTTP Web Server Refer to AIOHTTP documents for details. https://docs.aiohttp.org/en/stable/web.html """ - self._port = port - self._endpoint_path = path - self._bolt_app: "AsyncApp" = app - + self.port = port + self.path = path + self.bolt_app: "AsyncApp" = app self.web_app = web.Application() - self._bolt_oauth_flow = self._bolt_app.oauth_flow + self._bolt_oauth_flow = self.bolt_app.oauth_flow if self._bolt_oauth_flow: self.web_app.add_routes( [ @@ -31,13 +35,11 @@ def __init__( self._bolt_oauth_flow.redirect_uri_path, self.handle_get_requests, ), - web.post(self._endpoint_path, self.handle_post_requests), + web.post(self.path, self.handle_post_requests), ] ) else: - self.web_app.add_routes( - [web.post(self._endpoint_path, self.handle_post_requests)] - ) + self.web_app.add_routes([web.post(self.path, self.handle_post_requests)]) async def handle_get_requests(self, request: web.Request) -> web.Response: oauth_flow = self._bolt_oauth_flow @@ -56,11 +58,11 @@ async def handle_get_requests(self, request: web.Request) -> web.Response: return web.Response(status=404) async def handle_post_requests(self, request: web.Request) -> web.Response: - if self._endpoint_path != request.path: + if self.path != request.path: return web.Response(status=404) bolt_req = await to_bolt_request(request) - bolt_resp: BoltResponse = await self._bolt_app.async_dispatch(bolt_req) + bolt_resp: BoltResponse = await self.bolt_app.async_dispatch(bolt_req) return await to_aiohttp_response(bolt_resp) def start(self) -> None: @@ -68,9 +70,9 @@ def start(self) -> None: :return: None """ - if self._bolt_app.logger.level > logging.INFO: + if self.bolt_app.logger.level > logging.INFO: print("⚡️ Bolt app is running!") else: - self._bolt_app.logger.info("⚡️ Bolt app is running!") + self.bolt_app.logger.info("⚡️ Bolt app is running!") - web.run_app(self.web_app, host="0.0.0.0", port=self._port) + web.run_app(self.web_app, host="0.0.0.0", port=self.port) diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index 41c1d09c5..db204f00b 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -168,7 +168,7 @@ def run(self): self.server = HTTPServer(("localhost", 8888), self.handler) self.test.mock_received_requests = self.handler.received_requests self.test.server_url = "http://localhost:8888" - self.test.host, self.test._port = self.server.socket.getsockname() + self.test.host, self.test.port = self.server.socket.getsockname() self.test.server_started.set() # threading.Event() self.test = None From 94015f6e4d1c32c54fbb20dc1caee58ea22260f6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 2 Dec 2020 19:33:08 +0900 Subject: [PATCH 200/865] Add ignore comments for pytype --- slack_bolt/app/async_server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/slack_bolt/app/async_server.py b/slack_bolt/app/async_server.py index a00938a83..cb84c9142 100644 --- a/slack_bolt/app/async_server.py +++ b/slack_bolt/app/async_server.py @@ -9,11 +9,11 @@ class AsyncSlackAppServer: port: int path: str - bolt_app: "AsyncApp" + bolt_app: "AsyncApp" # type:ignore web_app: web.Application - def __init__( - self, port: int, path: str, app: "AsyncApp", + def __init__( # type:ignore + self, port: int, path: str, app: "AsyncApp", # type:ignore ): """Standalone AIOHTTP Web Server From a00a3e9ec29f77502a0e9c43cab53d7910d8e06d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 3 Dec 2020 13:22:01 +0900 Subject: [PATCH 201/865] Update the CI build settings --- .github/workflows/ci-build.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 0c4ca9d6f..617c8d1c3 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -35,9 +35,9 @@ jobs: pytest tests/adapter_tests/ - name: Run async tests run: | - # As the tests here are often slow on GitHub Actions, - # we pick up only one Python version for it. python_version=`python -V` + # TODO: As Python 3.6 works the most stably on GitHub Actions, + # we use the version for code coverage and async tests if [ ${python_version:7:3} == "3.6" ]; then pip install -e ".[async]" pip install "pytest-asyncio<1" @@ -48,7 +48,9 @@ jobs: - name: Run pytype verification & codecov run: | python_version=`python -V` - if [ ${python_version:7:3} == "3.8" ]; then + # TODO: As Python 3.6 works the most stably on GitHub Actions, + # we use the version for code coverage and async tests + if [ ${python_version:7:3} == "3.6" ]; then pip install -e ".[async]" pip install -e ".[adapter]" pip install "pytype" && pytype slack_bolt/ From ba3ca958af61f747411c9e832a4bf49f00541011 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 3 Dec 2020 13:27:41 +0900 Subject: [PATCH 202/865] Change the Python version to run pytype --- .github/workflows/ci-build.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 617c8d1c3..7c2cc7873 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -45,7 +45,16 @@ jobs: pytest tests/scenario_tests_async/ pytest tests/adapter_tests_async/ fi - - name: Run pytype verification & codecov + - name: Run pytype verification + run: | + python_version=`python -V` + if [ ${python_version:7:3} == "3.8" ]; then + pip install -e ".[async]" + pip install -e ".[adapter]" + pip install "pytype" && pytype slack_bolt/ + fi + + - name: Run all tests for codecov run: | python_version=`python -V` # TODO: As Python 3.6 works the most stably on GitHub Actions, @@ -53,6 +62,5 @@ jobs: if [ ${python_version:7:3} == "3.6" ]; then pip install -e ".[async]" pip install -e ".[adapter]" - pip install "pytype" && pytype slack_bolt/ pytest --cov=slack_bolt/ && bash <(curl -s https://codecov.io/bash) fi From e1942cdbb6fd1469a63f2eb708efeda65dec969a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 3 Dec 2020 17:42:09 +0900 Subject: [PATCH 203/865] Improve request parser for better compatibility with Socket Mode --- slack_bolt/request/internals.py | 30 ++++++------- tests/slack_bolt/request/test_internals.py | 51 +++++++++++++++++++++- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index b9a42b914..95e2d0664 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -36,9 +36,9 @@ def parse_body(body: str, content_type: Optional[str]) -> Dict[str, Any]: ) or body.startswith("{"): return json.loads(body) else: - if "payload" in body: + if "payload" in body: # This is not JSON format yet params = dict(parse_qsl(body)) - if "payload" in params: + if params.get("payload") is not None: return json.loads(params.get("payload")) else: return {} @@ -56,48 +56,48 @@ def extract_is_enterprise_install(payload: Dict[str, Any]) -> Optional[bool]: def extract_enterprise_id(payload: Dict[str, Any]) -> Optional[str]: - if "enterprise" in payload: + if payload.get("enterprise") is not None: org = payload.get("enterprise") if isinstance(org, str): return org elif "id" in org: return org.get("id") # type: ignore - if "authorizations" in payload and len(payload["authorizations"]) > 0: + if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0: # To make Events API handling functioning also for shared channels, # we should use .authorizations[0].enterprise_id over .enterprise_id return extract_enterprise_id(payload["authorizations"][0]) if "enterprise_id" in payload: return payload.get("enterprise_id") - if "team" in payload and "enterprise_id" in payload["team"]: + if payload.get("team") is not None and "enterprise_id" in payload["team"]: # In the case where the type is view_submission return payload["team"].get("enterprise_id") - if "event" in payload: + if payload.get("event") is not None: return extract_enterprise_id(payload["event"]) return None def extract_team_id(payload: Dict[str, Any]) -> Optional[str]: - if "team" in payload: + if payload.get("team") is not None: team = payload.get("team") if isinstance(team, str): return team elif team and "id" in team: return team.get("id") - if "authorizations" in payload and len(payload["authorizations"]) > 0: + if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0: # To make Events API handling functioning also for shared channels, # we should use .authorizations[0].team_id over .team_id return extract_team_id(payload["authorizations"][0]) if "team_id" in payload: return payload.get("team_id") - if "event" in payload: + if payload.get("event") is not None: return extract_team_id(payload["event"]) - if "user" in payload: + if payload.get("user") is not None: return payload.get("user")["team_id"] return None def extract_user_id(payload: Dict[str, Any]) -> Optional[str]: - if "user" in payload: + if payload.get("user") is not None: user = payload.get("user") if isinstance(user, str): return user @@ -105,13 +105,13 @@ def extract_user_id(payload: Dict[str, Any]) -> Optional[str]: return user.get("id") # type: ignore if "user_id" in payload: return payload.get("user_id") - if "event" in payload: + if payload.get("event") is not None: return extract_user_id(payload["event"]) return None def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: - if "channel" in payload: + if payload.get("channel") is not None: channel = payload.get("channel") if isinstance(channel, str): return channel @@ -119,9 +119,9 @@ def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: return channel.get("id") # type: ignore if "channel_id" in payload: return payload.get("channel_id") - if "event" in payload: + if payload.get("event") is not None: return extract_channel_id(payload["event"]) - if "item" in payload: + if payload.get("item") is not None: # reaction_added: body["event"]["item"] return extract_channel_id(payload["item"]) return None diff --git a/tests/slack_bolt/request/test_internals.py b/tests/slack_bolt/request/test_internals.py index 496f690a6..d30849561 100644 --- a/tests/slack_bolt/request/test_internals.py +++ b/tests/slack_bolt/request/test_internals.py @@ -50,6 +50,35 @@ def teardown_method(self): }, ] + enterprise_no_channel_requests = [ + { + "type": "shortcut", + "token": "xxx", + "action_ts": "1606983924.521157", + "team": {"id": "T111", "domain": "ddd"}, + "user": {"id": "U111", "username": "use", "team_id": "T111"}, + "is_enterprise_install": False, + "enterprise": {"id": "E111", "domain": "eee"}, + "callback_id": "run-socket-mode", + "trigger_id": "111.222.xxx", + }, + ] + + no_enterprise_no_channel_requests = [ + { + "type": "shortcut", + "token": "xxx", + "action_ts": "1606983924.521157", + "team": {"id": "T111", "domain": "ddd"}, + "user": {"id": "U111", "username": "use", "team_id": "T111"}, + "is_enterprise_install": False, + # This may be "null" in Socket Mode + "enterprise": None, + "callback_id": "run-socket-mode", + "trigger_id": "111.222.xxx", + }, + ] + def test_channel_id_extraction(self): for req in self.requests: channel_id = extract_channel_id(req) @@ -59,16 +88,34 @@ def test_user_id_extraction(self): for req in self.requests: user_id = extract_user_id(req) assert user_id == "U111" + for req in self.enterprise_no_channel_requests: + user_id = extract_user_id(req) + assert user_id == "U111" + for req in self.no_enterprise_no_channel_requests: + user_id = extract_user_id(req) + assert user_id == "U111" def test_team_id_extraction(self): for req in self.requests: team_id = extract_team_id(req) assert team_id == "T111" + for req in self.enterprise_no_channel_requests: + team_id = extract_team_id(req) + assert team_id == "T111" + for req in self.no_enterprise_no_channel_requests: + team_id = extract_team_id(req) + assert team_id == "T111" def test_enterprise_id_extraction(self): for req in self.requests: - team_id = extract_enterprise_id(req) - assert team_id == "E111" + enterprise_id = extract_enterprise_id(req) + assert enterprise_id == "E111" + for req in self.enterprise_no_channel_requests: + enterprise_id = extract_enterprise_id(req) + assert enterprise_id == "E111" + for req in self.no_enterprise_no_channel_requests: + enterprise_id = extract_enterprise_id(req) + assert enterprise_id is None def test_is_enterprise_install_extraction(self): for req in self.requests: From 4f38c0227b0b2f5af488ecad08aabbdedef47893 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 3 Dec 2020 19:29:39 +0900 Subject: [PATCH 204/865] version 1.1.1 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 6849410aa..a82b376d2 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.1.1" From 1d0268e1c060502036f39efeb85820d82005aa14 Mon Sep 17 00:00:00 2001 From: Jinpeng LI Date: Sun, 6 Dec 2020 02:06:08 +0800 Subject: [PATCH 205/865] Fix example compatible with slack-bolt v1.1.1 - update SlackBot model; - update SlackInstallation model; - refactor find_bot; - implement find_installation; --- examples/django/slackapp/models.py | 61 ++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/examples/django/slackapp/models.py b/examples/django/slackapp/models.py index 8b84beaac..6069ce345 100644 --- a/examples/django/slackapp/models.py +++ b/examples/django/slackapp/models.py @@ -8,11 +8,14 @@ class SlackBot(models.Model): client_id = models.TextField(null=False) app_id = models.TextField(null=False) enterprise_id = models.TextField(null=True) + enterprise_name = models.TextField(null=True) team_id = models.TextField(null=True) + team_name = models.TextField(null=True) bot_token = models.TextField(null=True) bot_id = models.TextField(null=True) bot_user_id = models.TextField(null=True) bot_scopes = models.TextField(null=True) + is_enterprise_install = models.BooleanField(null=True) installed_at = models.DateTimeField(null=False) class Meta: @@ -27,7 +30,10 @@ class SlackInstallation(models.Model): client_id = models.TextField(null=False) app_id = models.TextField(null=False) enterprise_id = models.TextField(null=True) + enterprise_name = models.TextField(null=True) + enterprise_url = models.TextField(null=True) team_id = models.TextField(null=True) + team_name = models.TextField(null=True) bot_token = models.TextField(null=True) bot_id = models.TextField(null=True) bot_user_id = models.TextField(null=True) @@ -36,8 +42,11 @@ class SlackInstallation(models.Model): user_token = models.TextField(null=True) user_scopes = models.TextField(null=True) incoming_webhook_url = models.TextField(null=True) + incoming_webhook_channel = models.TextField(null=True) incoming_webhook_channel_id = models.TextField(null=True) incoming_webhook_configuration_url = models.TextField(null=True) + is_enterprise_install = models.BooleanField(null=True) + token_type = models.TextField(null=True) installed_at = models.DateTimeField(null=False) class Meta: @@ -95,11 +104,16 @@ def save(self, installation: Installation): SlackBot(**b).save() def find_bot( - self, *, enterprise_id: Optional[str], team_id: Optional[str] + self, *, enterprise_id: Optional[str], team_id: Optional[str], + is_enterprise_install: Optional[bool] = False ) -> Optional[Bot]: + e_id = enterprise_id or None + t_id = team_id or None + if is_enterprise_install: + t_id = None rows = ( - SlackBot.objects.filter(enterprise_id=enterprise_id) - .filter(team_id=team_id) + SlackBot.objects.filter(enterprise_id=e_id) + .filter(team_id=t_id) .order_by(F("installed_at").desc())[:1] ) if len(rows) > 0: @@ -116,6 +130,47 @@ def find_bot( ) return None + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + e_id = enterprise_id or None + t_id = team_id or None + if is_enterprise_install: + t_id = None + if user_id is None: + rows = (SlackInstallation.objects.filter( + enterprise_id=e_id).filter(team_id=t_id).order_by( + F("installed_at").desc())[:1]) + else: + rows = (SlackInstallation.objects.filter( + enterprise_id=e_id).filter(team_id=t_id).filter( + user_id=user_id).order_by(F("installed_at").desc())[:1]) + + if len(rows) > 0: + i = rows[0] + return Installation( + app_id=i.app_id, + enterprise_id=i.enterprise_id, + team_id=i.team_id, + bot_token=i.bot_token, + bot_id=i.bot_id, + bot_user_id=i.bot_user_id, + bot_scopes=i.bot_scopes, + user_id=i.user_id, + user_token=i.user_token, + user_scopes=i.user_scopes, + incoming_webhook_url=i.incoming_webhook_url, + incoming_webhook_channel_id=i.incoming_webhook_channel_id, + incoming_webhook_configuration_url=i.incoming_webhook_configuration_url, + installed_at=i.installed_at.timestamp(), + ) + return None + class DjangoOAuthStateStore(OAuthStateStore): expiration_seconds: int From 0603e0ff3a16b1261cd4b3de860449bd5a4a7454 Mon Sep 17 00:00:00 2001 From: Jinpeng LI Date: Sun, 6 Dec 2020 02:06:50 +0800 Subject: [PATCH 206/865] regenerate migrations --- .../slackapp/migrations/0001_initial.py | 122 +++++++----------- 1 file changed, 48 insertions(+), 74 deletions(-) diff --git a/examples/django/slackapp/migrations/0001_initial.py b/examples/django/slackapp/migrations/0001_initial.py index 84665c287..5d256685d 100644 --- a/examples/django/slackapp/migrations/0001_initial.py +++ b/examples/django/slackapp/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1.1 on 2020-09-26 13:49 +# Generated by Django 3.1.4 on 2020-12-04 13:07 from django.db import migrations, models @@ -7,95 +7,69 @@ class Migration(migrations.Migration): initial = True - dependencies = [] + dependencies = [ + ] operations = [ migrations.CreateModel( - name="SlackBot", + name='SlackBot', fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("client_id", models.TextField()), - ("app_id", models.TextField()), - ("enterprise_id", models.TextField(null=True)), - ("team_id", models.TextField(null=True)), - ("bot_token", models.TextField(null=True)), - ("bot_id", models.TextField(null=True)), - ("bot_user_id", models.TextField(null=True)), - ("bot_scopes", models.TextField(null=True)), - ("installed_at", models.DateTimeField()), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('client_id', models.TextField()), + ('app_id', models.TextField()), + ('enterprise_id', models.TextField(null=True)), + ('enterprise_name', models.TextField(null=True)), + ('team_id', models.TextField(null=True)), + ('team_name', models.TextField(null=True)), + ('bot_token', models.TextField(null=True)), + ('bot_id', models.TextField(null=True)), + ('bot_user_id', models.TextField(null=True)), + ('bot_scopes', models.TextField(null=True)), + ('is_enterprise_install', models.BooleanField(null=True)), + ('installed_at', models.DateTimeField()), ], ), migrations.CreateModel( - name="SlackInstallation", + name='SlackInstallation', fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("client_id", models.TextField()), - ("app_id", models.TextField()), - ("enterprise_id", models.TextField(null=True)), - ("team_id", models.TextField(null=True)), - ("bot_token", models.TextField(null=True)), - ("bot_id", models.TextField(null=True)), - ("bot_user_id", models.TextField(null=True)), - ("bot_scopes", models.TextField(null=True)), - ("user_id", models.TextField()), - ("user_token", models.TextField(null=True)), - ("user_scopes", models.TextField(null=True)), - ("incoming_webhook_url", models.TextField(null=True)), - ("incoming_webhook_channel_id", models.TextField(null=True)), - ("incoming_webhook_configuration_url", models.TextField(null=True)), - ("installed_at", models.DateTimeField()), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('client_id', models.TextField()), + ('app_id', models.TextField()), + ('enterprise_id', models.TextField(null=True)), + ('enterprise_name', models.TextField(null=True)), + ('enterprise_url', models.TextField(null=True)), + ('team_id', models.TextField(null=True)), + ('team_name', models.TextField(null=True)), + ('bot_token', models.TextField(null=True)), + ('bot_id', models.TextField(null=True)), + ('bot_user_id', models.TextField(null=True)), + ('bot_scopes', models.TextField(null=True)), + ('user_id', models.TextField()), + ('user_token', models.TextField(null=True)), + ('user_scopes', models.TextField(null=True)), + ('incoming_webhook_url', models.TextField(null=True)), + ('incoming_webhook_channel', models.TextField(null=True)), + ('incoming_webhook_channel_id', models.TextField(null=True)), + ('incoming_webhook_configuration_url', models.TextField(null=True)), + ('is_enterprise_install', models.BooleanField(null=True)), + ('token_type', models.TextField(null=True)), + ('installed_at', models.DateTimeField()), ], ), migrations.CreateModel( - name="SlackOAuthState", + name='SlackOAuthState', fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("state", models.TextField()), - ("expire_at", models.DateTimeField()), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('state', models.TextField()), + ('expire_at', models.DateTimeField()), ], ), migrations.AddIndex( - model_name="slackinstallation", - index=models.Index( - fields=[ - "client_id", - "enterprise_id", - "team_id", - "user_id", - "installed_at", - ], - name="slackapp_sl_client__9b0d3f_idx", - ), + model_name='slackinstallation', + index=models.Index(fields=['client_id', 'enterprise_id', 'team_id', 'user_id', 'installed_at'], name='bolt_slacki_client__62c411_idx'), ), migrations.AddIndex( - model_name="slackbot", - index=models.Index( - fields=["client_id", "enterprise_id", "team_id", "installed_at"], - name="slackapp_sl_client__d220d6_idx", - ), + model_name='slackbot', + index=models.Index(fields=['client_id', 'enterprise_id', 'team_id', 'installed_at'], name='bolt_slackb_client__be066b_idx'), ), ] From f3f1455d6707bad4a583033b63522da181777bc0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 6 Dec 2020 00:22:31 +0900 Subject: [PATCH 207/865] Add v1.0 compatible mode to #148 Org-wide App support --- scripts/run_tests.sh | 2 +- .../aws_lambda/lambda_s3_oauth_flow.py | 4 +- slack_bolt/app/app.py | 4 ++ slack_bolt/app/async_app.py | 3 + slack_bolt/authorization/async_authorize.py | 11 +++- slack_bolt/authorization/authorize.py | 12 +++- slack_bolt/oauth/async_oauth_flow.py | 2 + slack_bolt/oauth/async_oauth_settings.py | 8 ++- slack_bolt/oauth/oauth_flow.py | 2 + slack_bolt/oauth/oauth_settings.py | 8 ++- .../authorization/test_authorize.py | 57 ++++++++++++++++++ .../authorization/test_async_authorize.py | 59 +++++++++++++++++++ 12 files changed, 162 insertions(+), 10 deletions(-) diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index a5234a525..a5ffb0da9 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -20,7 +20,7 @@ else black slack_bolt/ tests/ \ && pytest \ && pip install -e ".[adapter]" \ - && pip install -U pytype \ + && pip install -U pip setuptools wheel pytype \ && pytype slack_bolt/ else black slack_bolt/ tests/ && pytest diff --git a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py index 1282e76f5..f6c8ba4d7 100644 --- a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py +++ b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py @@ -62,7 +62,9 @@ def __init__( # the settings may already have pre-defined authorize. # In this case, the /slack/events endpoint doesn't work along with the OAuth flow. settings.authorize = InstallationStoreAuthorize( - logger=logger, installation_store=settings.installation_store + logger=logger, + installation_store=settings.installation_store, + bot_only=settings.installation_store_bot_only, ) OAuthFlow.__init__(self, client=client, logger=logger, settings=settings) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 5b2dba6b2..ce7c019a3 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -79,6 +79,8 @@ def __init__( # for multi-workspace apps authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[InstallationStore] = None, + # for v1.0.x compatibility + installation_store_bot_only: bool = False, # for the OAuth flow oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, @@ -96,6 +98,7 @@ def __init__( :param authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. :param installation_store: The module offering save/find operations of installation data + :param installation_store_bot_only: Use InstallationStore#find_bot if True (Default: False) :param oauth_settings: The settings related to Slack app installation flow (OAuth flow) :param oauth_flow: Manually instantiated slack_bolt.oauth.OAuthFlow. This is always prioritized over oauth_settings. @@ -140,6 +143,7 @@ def __init__( self._authorize = InstallationStoreAuthorize( installation_store=self._installation_store, logger=self._framework_logger, + bot_only=installation_store_bot_only, ) self._oauth_flow: Optional[OAuthFlow] = None diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 140d2b619..e3d8e84be 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -90,6 +90,7 @@ def __init__( client: Optional[AsyncWebClient] = None, # for multi-workspace apps installation_store: Optional[AsyncInstallationStore] = None, + installation_store_bot_only: bool = False, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, # for the OAuth flow oauth_settings: Optional[AsyncOAuthSettings] = None, @@ -106,6 +107,7 @@ def __init__( :param token: The bot access token required only for single-workspace app. :param client: The singleton slack_sdk.web.async_client.AsyncWebClient instance for this app. :param installation_store: The module offering save/find operations of installation data + :param installation_store_bot_only: Use InstallationStore#find_bot if True (Default: False) :param authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. :param oauth_settings: The settings related to Slack app installation flow (OAuth flow) @@ -155,6 +157,7 @@ def __init__( self._async_authorize = AsyncInstallationStoreAuthorize( installation_store=self._async_installation_store, logger=self._framework_logger, + bot_only=installation_store_bot_only, ) self._async_oauth_flow: Optional[AsyncOAuthFlow] = None diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index 8199ab299..a78700ced 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -91,7 +91,7 @@ async def __call__( class AsyncInstallationStoreAuthorize(AsyncAuthorize): - authorize_result_cache: Dict[str, AuthorizeResult] = {} + authorize_result_cache: Dict[str, AuthorizeResult] find_installation_available: Optional[bool] def __init__( @@ -99,11 +99,16 @@ def __init__( *, logger: Logger, installation_store: AsyncInstallationStore, + # For v1.0.x compatibility and people who still want its simplicity + # use only InstallationStore#find_bot(enterprise_id, team_id) + bot_only: bool = False, cache_enabled: bool = False, ): self.logger = logger self.installation_store = installation_store + self.bot_only = bot_only self.cache_enabled = cache_enabled + self.authorize_result_cache = {} self.find_installation_available = None async def __call__( @@ -123,7 +128,7 @@ async def __call__( bot_token: Optional[str] = None user_token: Optional[str] = None - if self.find_installation_available: + if not self.bot_only and self.find_installation_available: # since v1.1, this is the default way try: installation: Optional[ @@ -153,7 +158,7 @@ async def __call__( except NotImplementedError as _: self.find_installation_available = False - if not self.find_installation_available: + if self.bot_only or not self.find_installation_available: # Use find_bot to get bot value (legacy) bot: Optional[Bot] = await self.installation_store.async_find_bot( enterprise_id=enterprise_id, diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 2393d99f2..7e51ab97b 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -90,7 +90,8 @@ def __call__( class InstallationStoreAuthorize(Authorize): - authorize_result_cache: Dict[str, AuthorizeResult] = {} + authorize_result_cache: Dict[str, AuthorizeResult] + bot_only: bool find_installation_available: bool def __init__( @@ -98,11 +99,16 @@ def __init__( *, logger: Logger, installation_store: InstallationStore, + # For v1.0.x compatibility and people who still want its simplicity + # use only InstallationStore#find_bot(enterprise_id, team_id) + bot_only: bool = False, cache_enabled: bool = False, ): self.logger = logger self.installation_store = installation_store + self.bot_only = bot_only self.cache_enabled = cache_enabled + self.authorize_result_cache = {} self.find_installation_available = hasattr( installation_store, "find_installation" ) @@ -119,7 +125,7 @@ def __call__( bot_token: Optional[str] = None user_token: Optional[str] = None - if self.find_installation_available: + if not self.bot_only and self.find_installation_available: # since v1.1, this is the default way try: installation: Optional[ @@ -149,7 +155,7 @@ def __call__( except NotImplementedError as _: self.find_installation_available = False - if not self.find_installation_available: + if self.bot_only or not self.find_installation_available: # Use find_bot to get bot value (legacy) bot: Optional[Bot] = self.installation_store.find_bot( enterprise_id=enterprise_id, diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 073a622e3..d860d8dc7 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -110,6 +110,7 @@ def sqlite3( # state parameter related configurations state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + installation_store_bot_only: bool = False, client: Optional[AsyncWebClient] = None, logger: Optional[Logger] = None, ) -> "AsyncOAuthFlow": @@ -140,6 +141,7 @@ def sqlite3( installation_store=SQLite3InstallationStore( database=database, client_id=client_id, logger=logger, ), + installation_store_bot_only=installation_store_bot_only, # state parameter related configurations state_store=SQLite3OAuthStateStore( database=database, diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index f09df9a0d..8e36baf2d 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -39,6 +39,7 @@ class AsyncOAuthSettings: authorization_url: str # default: https://slack.com/oauth/v2/authorize # Installation Management installation_store: AsyncInstallationStore + installation_store_bot_only: bool authorize: AsyncAuthorize # state parameter related configurations state_store: AsyncOAuthStateStore @@ -69,6 +70,7 @@ def __init__( authorization_url: Optional[str] = None, # Installation Management installation_store: Optional[AsyncInstallationStore] = None, + installation_store_bot_only: bool = False, # state parameter related configurations state_store: Optional[AsyncOAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, @@ -90,6 +92,7 @@ def __init__( :param failure_url: Set a complete URL if you want to redirect end-users when an installation fails. :param authorization_url: Set a URL if you want to customize the URL https://slack.com/oauth/v2/authorize :param installation_store: Specify the instance of InstallationStore (Default: FileInstallationStore) + :param installation_store_bot_only: Use AsyncInstallationStore#find_bot if True (Default: False) :param state_store: Specify the instance of InstallationStore (Default: FileOAuthStateStore) :param state_cookie_name: The cookie name that is set for installers' browser. (Default: slack-app-oauth-state) :param state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) @@ -129,8 +132,11 @@ def __init__( self.installation_store = ( installation_store or get_or_create_default_installation_store(client_id) ) + self.installation_store_bot_only = installation_store_bot_only self.authorize = AsyncInstallationStoreAuthorize( - logger=logger, installation_store=self.installation_store, + logger=logger, + installation_store=self.installation_store, + bot_only=self.installation_store_bot_only, ) # state parameter related configurations self.state_store = state_store or FileOAuthStateStore( diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index 705975087..d68b9d73c 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -105,6 +105,7 @@ def sqlite3( # state parameter related configurations state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + installation_store_bot_only: bool = False, client: Optional[WebClient] = None, logger: Optional[Logger] = None, ) -> "OAuthFlow": @@ -135,6 +136,7 @@ def sqlite3( installation_store=SQLite3InstallationStore( database=database, client_id=client_id, logger=logger, ), + installation_store_bot_only=installation_store_bot_only, # state parameter related configurations state_store=SQLite3OAuthStateStore( database=database, diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index 1462925c2..66ff03260 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -34,6 +34,7 @@ class OAuthSettings: authorization_url: str # default: https://slack.com/oauth/v2/authorize # Installation Management installation_store: InstallationStore + installation_store_bot_only: bool authorize: Authorize # state parameter related configurations state_store: OAuthStateStore @@ -64,6 +65,7 @@ def __init__( authorization_url: Optional[str] = None, # Installation Management installation_store: Optional[InstallationStore] = None, + installation_store_bot_only: bool = False, # state parameter related configurations state_store: Optional[OAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, @@ -85,6 +87,7 @@ def __init__( :param failure_url: Set a complete URL if you want to redirect end-users when an installation fails. :param authorization_url: Set a URL if you want to customize the URL https://slack.com/oauth/v2/authorize :param installation_store: Specify the instance of InstallationStore (Default: FileInstallationStore) + :param installation_store_bot_only: Use InstallationStore#find_bot if True (Default: False) :param state_store: Specify the instance of InstallationStore (Default: FileOAuthStateStore) :param state_cookie_name: The cookie name that is set for installers' browser. (Default: slack-app-oauth-state) :param state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) @@ -123,8 +126,11 @@ def __init__( self.installation_store = ( installation_store or get_or_create_default_installation_store(client_id) ) + self.installation_store_bot_only = installation_store_bot_only self.authorize = InstallationStoreAuthorize( - logger=logger, installation_store=self.installation_store, + logger=logger, + installation_store=self.installation_store, + bot_only=self.installation_store_bot_only, ) # state parameter related configurations self.state_store = state_store or FileOAuthStateStore( diff --git a/tests/slack_bolt/authorization/test_authorize.py b/tests/slack_bolt/authorization/test_authorize.py index fff72f575..72c419880 100644 --- a/tests/slack_bolt/authorization/test_authorize.py +++ b/tests/slack_bolt/authorization/test_authorize.py @@ -76,6 +76,63 @@ def test_installation_store_cached_legacy(self): assert result.user_token is None assert self.mock_received_requests["/auth.test"] == 1 # cached + def test_installation_store_bot_only(self): + installation_store = MemoryInstallationStore() + authorize = InstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + bot_only=True, + ) + assert authorize.find_installation_available is True + assert authorize.bot_only is True + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert authorize.find_installation_available is True + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 1 + + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 2 + + def test_installation_store_cached_bot_only(self): + installation_store = MemoryInstallationStore() + authorize = InstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + cache_enabled=True, + bot_only=True, + ) + assert authorize.find_installation_available is True + assert authorize.bot_only is True + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert authorize.find_installation_available is True + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 1 + + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 1 # cached + def test_installation_store(self): installation_store = MemoryInstallationStore() authorize = InstallationStoreAuthorize( diff --git a/tests/slack_bolt_async/authorization/test_async_authorize.py b/tests/slack_bolt_async/authorization/test_async_authorize.py index 5df1cd0c2..263b6a591 100644 --- a/tests/slack_bolt_async/authorization/test_async_authorize.py +++ b/tests/slack_bolt_async/authorization/test_async_authorize.py @@ -90,6 +90,65 @@ async def test_installation_store_cached_legacy(self): assert result.user_token is None assert self.mock_received_requests["/auth.test"] == 1 # cached + @pytest.mark.asyncio + async def test_installation_store_bot_only(self): + installation_store = MemoryInstallationStore() + authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + bot_only=True, + ) + assert authorize.find_installation_available is None + assert authorize.bot_only is True + context = AsyncBoltContext() + context["client"] = self.client + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert authorize.find_installation_available is True + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 1 + + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 2 + + @pytest.mark.asyncio + async def test_installation_store_cached_bot_only(self): + installation_store = MemoryInstallationStore() + authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + cache_enabled=True, + bot_only=True, + ) + assert authorize.find_installation_available is None + assert authorize.bot_only is True + context = AsyncBoltContext() + context["client"] = self.client + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert authorize.find_installation_available is True + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 1 + + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert self.mock_received_requests["/auth.test"] == 1 # cached + @pytest.mark.asyncio async def test_installation_store(self): installation_store = MemoryInstallationStore() From 0220e89e3b726d9a5aa8eafa364afe8b0cdc472e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 6 Dec 2020 00:45:48 +0900 Subject: [PATCH 208/865] Make the tests more explicit --- tests/slack_bolt/authorization/test_authorize.py | 16 ++++++++++++++-- .../authorization/test_async_authorize.py | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/slack_bolt/authorization/test_authorize.py b/tests/slack_bolt/authorization/test_authorize.py index 72c419880..792949faf 100644 --- a/tests/slack_bolt/authorization/test_authorize.py +++ b/tests/slack_bolt/authorization/test_authorize.py @@ -77,7 +77,7 @@ def test_installation_store_cached_legacy(self): assert self.mock_received_requests["/auth.test"] == 1 # cached def test_installation_store_bot_only(self): - installation_store = MemoryInstallationStore() + installation_store = BotOnlyMemoryInstallationStore() authorize = InstallationStoreAuthorize( logger=installation_store.logger, installation_store=installation_store, @@ -105,7 +105,7 @@ def test_installation_store_bot_only(self): assert self.mock_received_requests["/auth.test"] == 2 def test_installation_store_cached_bot_only(self): - installation_store = MemoryInstallationStore() + installation_store = BotOnlyMemoryInstallationStore() authorize = InstallationStoreAuthorize( logger=installation_store.logger, installation_store=installation_store, @@ -233,3 +233,15 @@ def find_installation( user_scopes=["search:read"], installed_at=datetime.datetime.now().timestamp(), ) + + +class BotOnlyMemoryInstallationStore(LegacyMemoryInstallationStore): + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + raise ValueError diff --git a/tests/slack_bolt_async/authorization/test_async_authorize.py b/tests/slack_bolt_async/authorization/test_async_authorize.py index 263b6a591..f8a3cc98c 100644 --- a/tests/slack_bolt_async/authorization/test_async_authorize.py +++ b/tests/slack_bolt_async/authorization/test_async_authorize.py @@ -92,7 +92,7 @@ async def test_installation_store_cached_legacy(self): @pytest.mark.asyncio async def test_installation_store_bot_only(self): - installation_store = MemoryInstallationStore() + installation_store = BotOnlyMemoryInstallationStore() authorize = AsyncInstallationStoreAuthorize( logger=installation_store.logger, installation_store=installation_store, @@ -121,7 +121,7 @@ async def test_installation_store_bot_only(self): @pytest.mark.asyncio async def test_installation_store_cached_bot_only(self): - installation_store = MemoryInstallationStore() + installation_store = BotOnlyMemoryInstallationStore() authorize = AsyncInstallationStoreAuthorize( logger=installation_store.logger, installation_store=installation_store, @@ -253,3 +253,15 @@ async def async_find_installation( user_scopes=["search:read"], installed_at=datetime.datetime.now().timestamp(), ) + + +class BotOnlyMemoryInstallationStore(LegacyMemoryInstallationStore): + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + raise ValueError From ef42ea9c97251d018c8f009daac7de607109a126 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 6 Dec 2020 10:48:47 +0900 Subject: [PATCH 209/865] Fix initialization in App/AsyncApp constructor --- slack_bolt/app/app.py | 11 +++++++++++ slack_bolt/app/async_app.py | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index ce7c019a3..e962e3326 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -130,6 +130,10 @@ def __init__( else: self._client = create_web_client(token) # NOTE: the token here can be None + # -------------------------------------- + # Authorize & OAuthFlow initialization + # -------------------------------------- + self._authorize: Optional[Authorize] = None if authorize is not None: if oauth_settings is not None or oauth_flow is not None: @@ -192,6 +196,13 @@ def __init__( self._token = None self._framework_logger.warning(warning_token_skipped()) + # after setting bot_only here, __init__ cannot replace authorize function + self._authorize.bot_only = installation_store_bot_only + + # -------------------------------------- + # Middleware Initialization + # -------------------------------------- + self._middleware_list: List[Union[Callable, Middleware]] = [] self._listeners: List[Listener] = [] diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index e3d8e84be..d88360a73 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -141,6 +141,10 @@ def __init__( # NOTE: the token here can be None self._async_client = create_async_web_client(token) + # -------------------------------------- + # Authorize & OAuthFlow initialization + # -------------------------------------- + self._async_authorize: Optional[AsyncAuthorize] = None if authorize is not None: if oauth_settings is not None or oauth_flow is not None: @@ -214,6 +218,13 @@ def __init__( self._token = None self._framework_logger.warning(warning_token_skipped()) + # after setting bot_only here, __init__ cannot replace authorize function + self._async_authorize.bot_only = installation_store_bot_only + + # -------------------------------------- + # Middleware Initialization + # -------------------------------------- + self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] self._async_listeners: List[AsyncListener] = [] From e922a6d0c710da06e3ce94ae42b49342a7b59d3e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 6 Dec 2020 14:49:44 +0900 Subject: [PATCH 210/865] Add existence check --- slack_bolt/app/app.py | 3 ++- slack_bolt/app/async_app.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index e962e3326..a1911af50 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -197,7 +197,8 @@ def __init__( self._framework_logger.warning(warning_token_skipped()) # after setting bot_only here, __init__ cannot replace authorize function - self._authorize.bot_only = installation_store_bot_only + if self._authorize is not None: + self._authorize.bot_only = installation_store_bot_only # -------------------------------------- # Middleware Initialization diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index d88360a73..7978eaf4c 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -219,7 +219,8 @@ def __init__( self._framework_logger.warning(warning_token_skipped()) # after setting bot_only here, __init__ cannot replace authorize function - self._async_authorize.bot_only = installation_store_bot_only + if self._async_authorize is not None: + self._async_authorize.bot_only = installation_store_bot_only # -------------------------------------- # Middleware Initialization From 2393f6b4cb7bf6f6ec2e6aa11f59288277a2d1bc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 7 Dec 2020 11:01:52 +0900 Subject: [PATCH 211/865] Simplify CI builds --- .github/workflows/ci-build.yml | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 7c2cc7873..ee5ac1722 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -33,19 +33,7 @@ jobs: run: | pip install -e ".[adapter]" pytest tests/adapter_tests/ - - name: Run async tests - run: | - python_version=`python -V` - # TODO: As Python 3.6 works the most stably on GitHub Actions, - # we use the version for code coverage and async tests - if [ ${python_version:7:3} == "3.6" ]; then - pip install -e ".[async]" - pip install "pytest-asyncio<1" - pytest tests/slack_bolt_async/ - pytest tests/scenario_tests_async/ - pytest tests/adapter_tests_async/ - fi - - name: Run pytype verification + - name: Run pytype verification (3.8 only) run: | python_version=`python -V` if [ ${python_version:7:3} == "3.8" ]; then @@ -53,8 +41,7 @@ jobs: pip install -e ".[adapter]" pip install "pytype" && pytype slack_bolt/ fi - - - name: Run all tests for codecov + - name: Run all tests for codecov (3.6 only) run: | python_version=`python -V` # TODO: As Python 3.6 works the most stably on GitHub Actions, From 256e47e6ce9df62fa30286766d9cd35a69149e4c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 6 Dec 2020 14:37:17 +0900 Subject: [PATCH 212/865] Fix #170 by removing emoji from the boot message --- slack_bolt/app/app.py | 6 +++--- slack_bolt/app/async_server.py | 5 +++-- slack_bolt/util/utils.py | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index a1911af50..7636f7f5d 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -58,7 +58,7 @@ from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import create_web_client +from slack_bolt.util.utils import create_web_client, get_boot_message from slack_bolt.workflows.step import WorkflowStep, WorkflowStepMiddleware @@ -942,9 +942,9 @@ def start(self) -> None: :return: None """ if self._bolt_app.logger.level > logging.INFO: - print("⚡️ Bolt app is running! (development server)") + print(get_boot_message(development_server=True)) else: - self._bolt_app.logger.info("⚡️ Bolt app is running! (development server)") + self._bolt_app.logger.info(get_boot_message(development_server=True)) try: self._server.serve_forever(0.05) diff --git a/slack_bolt/app/async_server.py b/slack_bolt/app/async_server.py index cb84c9142..6ae669098 100644 --- a/slack_bolt/app/async_server.py +++ b/slack_bolt/app/async_server.py @@ -4,6 +4,7 @@ from slack_bolt.adapter.aiohttp import to_bolt_request, to_aiohttp_response from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import get_boot_message class AsyncSlackAppServer: @@ -71,8 +72,8 @@ def start(self) -> None: :return: None """ if self.bolt_app.logger.level > logging.INFO: - print("⚡️ Bolt app is running!") + print(get_boot_message()) else: - self.bolt_app.logger.info("⚡️ Bolt app is running!") + self.bolt_app.logger.info(get_boot_message()) web.run_app(self.web_app, host="0.0.0.0", port=self.port) diff --git a/slack_bolt/util/utils.py b/slack_bolt/util/utils.py index e31489f69..4cdc39190 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -39,3 +39,18 @@ def create_copy(original: Any) -> Any: return copy.copy(original) else: return copy.deepcopy(original) + + +def get_boot_message(development_server: bool = False) -> str: + if sys.platform == "win32": + # Some Windows environments may fail to parse this str value + # and result in UnicodeEncodeError + if development_server: + return "Bolt app is running! (development server)" + else: + return "Bolt app is running!" + + if development_server: + return "⚡️ Bolt app is running! (development server)" + else: + return "⚡️ Bolt app is running!" From ca09f618bf299e51ecaa4ceb90b37af2809301b0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 8 Dec 2020 14:52:46 +0900 Subject: [PATCH 213/865] Fix CI build settings for properly running codecov --- .github/workflows/ci-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index ee5ac1722..b1dad657c 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -48,6 +48,6 @@ jobs: # we use the version for code coverage and async tests if [ ${python_version:7:3} == "3.6" ]; then pip install -e ".[async]" - pip install -e ".[adapter]" + pip install -e ".[testing]" pytest --cov=slack_bolt/ && bash <(curl -s https://codecov.io/bash) fi From 3b8900f8dc464c945d581b62bdd4f82ba9b05994 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 8 Dec 2020 16:06:56 +0900 Subject: [PATCH 214/865] version 1.1.2 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index a82b376d2..72f26f596 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.1.1" +__version__ = "1.1.2" From 1d01c6798e57ca780b765ff86ed6c29ff25015ee Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 8 Dec 2020 17:04:12 +0900 Subject: [PATCH 215/865] Add timeout-minutes to CI builds --- .github/workflows/ci-build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index b1dad657c..90ffbc6b7 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -10,6 +10,7 @@ on: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 10 strategy: matrix: python-version: ['3.6', '3.7', '3.8', '3.9'] From 890cf3f5535f81233782252be48a3e68a28d7fde Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Dec 2020 15:03:51 +0900 Subject: [PATCH 216/865] Update test runner script to avoid installation failures --- scripts/run_tests.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index a5ffb0da9..c9096048a 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -20,7 +20,8 @@ else black slack_bolt/ tests/ \ && pytest \ && pip install -e ".[adapter]" \ - && pip install -U pip setuptools wheel pytype \ + && pip install -U pip setuptools wheel \ + && pip install -U pytype \ && pytype slack_bolt/ else black slack_bolt/ tests/ && pytest From d82a7d9ee863b0c48a839e8e36319c2b02a662ad Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 11 Dec 2020 15:02:30 +0900 Subject: [PATCH 217/865] Fix #177 by updating App/AsyncApp bot_only flag initialization --- slack_bolt/app/app.py | 12 +- slack_bolt/app/async_app.py | 19 +- slack_bolt/logger/messages.py | 7 + tests/scenario_tests/test_app.py | 12 +- tests/scenario_tests/test_app_bot_only.py | 261 +++++++++++++++++ .../scenario_tests_async/test_app_bot_only.py | 262 ++++++++++++++++++ 6 files changed, 566 insertions(+), 7 deletions(-) create mode 100644 tests/scenario_tests/test_app_bot_only.py create mode 100644 tests/scenario_tests_async/test_app_bot_only.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 7636f7f5d..bc1f90f7b 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -41,6 +41,7 @@ error_unexpected_listener_middleware, error_client_invalid_type, error_authorize_conflicts, + warning_bot_only_conflicts, ) from slack_bolt.middleware import ( Middleware, @@ -80,7 +81,7 @@ def __init__( authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[InstallationStore] = None, # for v1.0.x compatibility - installation_store_bot_only: bool = False, + installation_store_bot_only: Optional[bool] = None, # for the OAuth flow oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, @@ -197,8 +198,13 @@ def __init__( self._framework_logger.warning(warning_token_skipped()) # after setting bot_only here, __init__ cannot replace authorize function - if self._authorize is not None: - self._authorize.bot_only = installation_store_bot_only + if installation_store_bot_only is not None and self._oauth_flow is not None: + app_bot_only = installation_store_bot_only or False + oauth_flow_bot_only = self._oauth_flow.settings.installation_store_bot_only + if app_bot_only != oauth_flow_bot_only: + self.logger.warning(warning_bot_only_conflicts()) + self._oauth_flow.settings.installation_store_bot_only = app_bot_only + self._authorize.bot_only = app_bot_only # -------------------------------------- # Middleware Initialization diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 7978eaf4c..8fdc6bca0 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -38,6 +38,7 @@ error_authorize_conflicts, error_oauth_settings_invalid_type_async, error_oauth_flow_invalid_type_async, + warning_bot_only_conflicts, ) from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -90,7 +91,7 @@ def __init__( client: Optional[AsyncWebClient] = None, # for multi-workspace apps installation_store: Optional[AsyncInstallationStore] = None, - installation_store_bot_only: bool = False, + installation_store_bot_only: Optional[bool] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, # for the OAuth flow oauth_settings: Optional[AsyncOAuthSettings] = None, @@ -219,8 +220,20 @@ def __init__( self._framework_logger.warning(warning_token_skipped()) # after setting bot_only here, __init__ cannot replace authorize function - if self._async_authorize is not None: - self._async_authorize.bot_only = installation_store_bot_only + if ( + installation_store_bot_only is not None + and self._async_oauth_flow is not None + ): + app_bot_only = installation_store_bot_only or False + oauth_flow_bot_only = ( + self._async_oauth_flow.settings.installation_store_bot_only + ) + if app_bot_only != oauth_flow_bot_only: + self.logger.warning(warning_bot_only_conflicts()) + self._async_oauth_flow.settings.installation_store_bot_only = ( + app_bot_only + ) + self._async_authorize.bot_only = app_bot_only # -------------------------------------- # Middleware Initialization diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index e01f03005..d850567a3 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -76,6 +76,13 @@ def warning_did_not_call_ack(listener_name: str) -> str: return f"{listener_name} didn't call ack()" +def warning_bot_only_conflicts() -> str: + return ( + "installation_store_bot_only exists in both App and OAuthFlow.settings. " + "The one passed in App constructor is used." + ) + + # ------------------------------- # Info # ------------------------------- diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index 3667a7a09..16ee40339 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -3,7 +3,7 @@ from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore -from slack_bolt import App +from slack_bolt import App, Say from slack_bolt.authorization import AuthorizeResult from slack_bolt.error import BoltError from slack_bolt.oauth import OAuthFlow @@ -29,6 +29,16 @@ def teardown_method(self): cleanup_mock_web_api_server(self) restore_os_env(self.old_os_env) + @staticmethod + def handle_app_mention(body, say: Say, payload, event): + assert body["event"] == payload + assert payload == event + say("What's up?") + + # -------------------------- + # basic tests + # -------------------------- + def test_signing_secret_absence(self): with pytest.raises(BoltError): App(signing_secret=None, token="xoxb-xxx") diff --git a/tests/scenario_tests/test_app_bot_only.py b/tests/scenario_tests/test_app_bot_only.py new file mode 100644 index 000000000..c6cfca323 --- /dev/null +++ b/tests/scenario_tests/test_app_bot_only.py @@ -0,0 +1,261 @@ +import datetime +import json +import logging +from time import time, sleep +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import App, BoltRequest, Say +from slack_bolt.oauth import OAuthFlow +from slack_bolt.oauth.oauth_settings import OAuthSettings +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 LegacyMemoryInstallationStore(InstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class MemoryInstallationStore(LegacyMemoryInstallationStore): + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class BotOnlyMemoryInstallationStore(LegacyMemoryInstallationStore): + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + raise ValueError + + +class TestApp: + 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_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + app_mention_request_body = { + "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"], + } + + @staticmethod + def handle_app_mention(body, say: Say, payload, event): + assert body["event"] == payload + assert payload == event + say("What's up?") + + oauth_settings_bot_only = OAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=BotOnlyMemoryInstallationStore(), + installation_store_bot_only=True, + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + + oauth_settings = OAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=BotOnlyMemoryInstallationStore(), + installation_store_bot_only=False, + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + + def build_app_mention_request(self): + timestamp, body = str(int(time())), json.dumps(self.app_mention_request_body) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_installation_store_bot_only_default(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MemoryInstallationStore(), + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_installation_store_bot_only_false(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MemoryInstallationStore(), + # the default is False + installation_store_bot_only=False, + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_installation_store_bot_only(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=BotOnlyMemoryInstallationStore(), + installation_store_bot_only=True, + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_installation_store_bot_only_oauth_settings(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=self.oauth_settings_bot_only, + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_installation_store_bot_only_oauth_settings_conflicts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store_bot_only=True, + oauth_settings=self.oauth_settings, + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_installation_store_bot_only_oauth_flow(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_flow=OAuthFlow(settings=self.oauth_settings_bot_only), + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_installation_store_bot_only_oauth_flow_conflicts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store_bot_only=True, + oauth_flow=OAuthFlow(settings=self.oauth_settings), + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 diff --git a/tests/scenario_tests_async/test_app_bot_only.py b/tests/scenario_tests_async/test_app_bot_only.py new file mode 100644 index 000000000..e664269bb --- /dev/null +++ b/tests/scenario_tests_async/test_app_bot_only.py @@ -0,0 +1,262 @@ +import asyncio +import datetime +import json +import logging +from time import time +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +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 TestAppBotOnly: + 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,) + + @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) + + 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_app_mention_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(app_mention_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_bot_only_default_off(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=BotOnlyMemoryInstallationStore(), + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + with pytest.raises(ValueError): + await app.async_dispatch(request) + + @pytest.mark.asyncio + async def test_bot_only(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=BotOnlyMemoryInstallationStore(), + installation_store_bot_only=True, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_bot_only_oauth_settings_conflicts(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=oauth_settings, + installation_store_bot_only=True, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_bot_only_oauth_settings(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=oauth_settings_bot_only, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_bot_only_oauth_flow_conflicts(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_flow=AsyncOAuthFlow(settings=oauth_settings), + installation_store_bot_only=True, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_bot_only_oauth_flow(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_flow=AsyncOAuthFlow(settings=oauth_settings_bot_only), + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + +app_mention_body = { + "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"], +} + + +async def whats_up(body, say, payload, event): + assert body["event"] == payload + assert payload == event + await say("What's up?") + + +class LegacyMemoryInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class MemoryInstallationStore(LegacyMemoryInstallationStore): + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class BotOnlyMemoryInstallationStore(LegacyMemoryInstallationStore): + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + raise ValueError + + +oauth_settings = AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=BotOnlyMemoryInstallationStore(), + installation_store_bot_only=False, +) + +oauth_settings_bot_only = AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=BotOnlyMemoryInstallationStore(), + installation_store_bot_only=True, +) From 8efa2ba9ca7e6c6d8c70ebd9f9f0da7d74154847 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 16 Dec 2020 17:11:17 +0900 Subject: [PATCH 218/865] version 1.1.3 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 72f26f596..0b2f79dbb 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.1.2" +__version__ = "1.1.3" From edd92796516939e6dded41b8e5690c9ca1fc386a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 18 Dec 2020 21:45:24 +0900 Subject: [PATCH 219/865] Fix #158 by adding token_verification_enabled option to App --- slack_bolt/app/app.py | 12 +++++++++--- tests/scenario_tests/test_app.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index bc1f90f7b..75593af78 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -76,6 +76,7 @@ def __init__( signing_secret: Optional[str] = None, # for single-workspace apps token: Optional[str] = None, + token_verification_enabled: bool = True, client: Optional[WebClient] = None, # for multi-workspace apps authorize: Optional[Callable[..., AuthorizeResult]] = None, @@ -95,6 +96,7 @@ def __init__( :param process_before_response: True if this app runs on Function as a Service. (Default: False) :param signing_secret: The Signing Secret value used for verifying requests from Slack. :param token: The bot access token required only for single-workspace app. + :param token_verification_enabled: Verifies the validity of the given token if True. :param client: The singleton slack_sdk.WebClient instance for this app. :param authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. @@ -227,9 +229,11 @@ def __init__( ) self._init_middleware_list_done = False - self._init_middleware_list() + self._init_middleware_list( + token_verification_enabled=token_verification_enabled + ) - def _init_middleware_list(self): + def _init_middleware_list(self, token_verification_enabled: bool): if self._init_middleware_list_done: return self._middleware_list.append( @@ -240,7 +244,9 @@ def _init_middleware_list(self): if self._oauth_flow is None: if self._token is not None: try: - auth_test_result = self._client.auth_test(token=self._token) + auth_test_result = None + if token_verification_enabled: + auth_test_result = self._client.auth_test(token=self._token) self._middleware_list.append( SingleTeamAuthorization(auth_test_result=auth_test_result) ) diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index 16ee40339..af2e98c34 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -67,6 +67,20 @@ def test_token_absence(self): with pytest.raises(BoltError): App(signing_secret="valid", token="") + def test_token_verification_enabled_False(self): + App( + signing_secret="valid", + client=self.web_client, + token_verification_enabled=False, + ) + App( + signing_secret="valid", + token="xoxb-invalid", + token_verification_enabled=False, + ) + + assert self.mock_received_requests.get("/auth.test") is None + # -------------------------- # multi teams auth # -------------------------- From 7d35ae361ac1376e6080b039d71e78a33166373f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 19 Dec 2020 12:31:41 +0900 Subject: [PATCH 220/865] Upgrade black code formatter to the latest version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b049c0102..bf95537e6 100755 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ "pytest-cov>=2,<3", "pytest-asyncio<1", # for async "aiohttp>=3,<4", # for async - "black==19.10b0", # TODO: upgrading the version + "black==20.8b1", ] setuptools.setup( From 809e255bcdb6ca44c5bc08a758042d5d3d30b2f0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 19 Dec 2020 12:33:13 +0900 Subject: [PATCH 221/865] Apply code formatter's changes --- examples/app_authorize.py | 9 +- examples/async_app_authorize.py | 7 +- examples/async_steps_from_apps.py | 23 +++- examples/async_steps_from_apps_primitive.py | 6 +- examples/aws_chalice/app.py | 5 +- examples/aws_lambda/aws_lambda_oauth.py | 5 +- examples/dialogs_app.py | 3 +- .../slackapp/migrations/0001_initial.py | 129 +++++++++++------- examples/django/slackapp/models.py | 47 +++++-- examples/django/slackapp/settings.py | 33 ++++- examples/falcon/app.py | 20 ++- examples/falcon/oauth_app.py | 20 ++- examples/fastapi/async_oauth_app.py | 1 + examples/getting_started/app.py | 22 ++- examples/lazy_async_modals_app.py | 23 +++- examples/lazy_modals_app.py | 23 +++- examples/message_events.py | 4 +- examples/modals_app.py | 28 +++- examples/modals_app_typed.py | 7 +- examples/oauth_app.py | 20 ++- examples/oauth_app_settings.py | 20 ++- examples/readme_app.py | 15 +- examples/readme_async_app.py | 17 ++- examples/sqlalchemy/async_oauth_app.py | 12 +- examples/sqlalchemy/oauth_app.py | 8 +- examples/steps_from_apps.py | 12 +- examples/steps_from_apps_primitive.py | 6 +- slack_bolt/adapter/aiohttp/__init__.py | 4 +- .../adapter/aws_lambda/chalice_handler.py | 16 ++- slack_bolt/adapter/aws_lambda/handler.py | 4 +- slack_bolt/adapter/bottle/handler.py | 6 +- slack_bolt/adapter/cherrypy/handler.py | 6 +- slack_bolt/adapter/django/handler.py | 9 +- slack_bolt/adapter/pyramid/handler.py | 4 +- slack_bolt/adapter/sanic/async_handler.py | 9 +- slack_bolt/adapter/starlette/async_handler.py | 9 +- slack_bolt/adapter/starlette/handler.py | 9 +- slack_bolt/app/app.py | 22 ++- slack_bolt/app/async_app.py | 18 ++- slack_bolt/app/async_server.py | 7 +- slack_bolt/authorization/async_authorize.py | 12 +- slack_bolt/authorization/authorize.py | 5 +- slack_bolt/context/say/async_say.py | 4 +- slack_bolt/context/say/say.py | 4 +- slack_bolt/lazy_listener/async_runner.py | 4 +- slack_bolt/lazy_listener/asyncio_runner.py | 7 +- slack_bolt/lazy_listener/internals.py | 4 +- slack_bolt/lazy_listener/runner.py | 6 +- slack_bolt/lazy_listener/thread_runner.py | 10 +- slack_bolt/listener/async_listener.py | 15 +- slack_bolt/listener/asyncio_runner.py | 12 +- slack_bolt/listener/custom_listener.py | 5 +- slack_bolt/listener/listener.py | 12 +- slack_bolt/listener/listener_error_handler.py | 15 +- slack_bolt/listener/thread_runner.py | 12 +- slack_bolt/listener_matcher/builtins.py | 71 +++++++--- .../authorization/async_internals.py | 3 +- .../middleware/authorization/internals.py | 3 +- .../multi_teams_authorization.py | 10 +- .../single_team_authorization.py | 6 +- slack_bolt/middleware/custom_middleware.py | 6 +- .../ignoring_self_events.py | 6 +- .../message_listener_matches.py | 6 +- slack_bolt/middleware/middleware.py | 6 +- .../request_verification.py | 6 +- slack_bolt/middleware/ssl_check/ssl_check.py | 6 +- .../url_verification/url_verification.py | 6 +- slack_bolt/oauth/async_callback_options.py | 7 +- slack_bolt/oauth/async_oauth_flow.py | 16 ++- slack_bolt/oauth/async_oauth_settings.py | 3 +- slack_bolt/oauth/callback_options.py | 7 +- slack_bolt/oauth/oauth_flow.py | 22 +-- slack_bolt/oauth/oauth_settings.py | 3 +- slack_bolt/request/async_internals.py | 3 +- slack_bolt/util/async_utils.py | 5 +- slack_bolt/util/utils.py | 5 +- slack_bolt/workflows/step/async_step.py | 25 +++- .../workflows/step/async_step_middleware.py | 5 +- slack_bolt/workflows/step/step.py | 19 ++- slack_bolt/workflows/step/step_middleware.py | 11 +- .../step/utilities/async_configure.py | 4 +- .../workflows/step/utilities/async_fail.py | 6 +- slack_bolt/workflows/step/utilities/fail.py | 6 +- tests/adapter_tests/test_aws_chalice.py | 23 +++- tests/adapter_tests/test_aws_lambda.py | 33 ++++- tests/adapter_tests/test_bottle.py | 13 +- tests/adapter_tests/test_bottle_oauth.py | 4 +- tests/adapter_tests/test_cherrypy.py | 13 +- tests/adapter_tests/test_cherrypy_oauth.py | 5 +- tests/adapter_tests/test_django.py | 23 +++- tests/adapter_tests/test_django_settings.py | 5 +- tests/adapter_tests/test_falcon.py | 35 +++-- tests/adapter_tests/test_fastapi.py | 35 +++-- tests/adapter_tests/test_flask.py | 35 +++-- .../test_lambda_s3_oauth_flow.py | 4 +- tests/adapter_tests/test_pyramid.py | 23 +++- tests/adapter_tests/test_starlette.py | 35 +++-- tests/adapter_tests/test_tornado.py | 13 +- tests/adapter_tests/test_tornado_oauth.py | 4 +- .../adapter_tests_async/test_async_fastapi.py | 35 +++-- tests/adapter_tests_async/test_async_sanic.py | 35 +++-- .../test_async_starlette.py | 35 +++-- tests/scenario_tests/test_app.py | 14 +- tests/scenario_tests/test_app_bot_only.py | 8 +- .../scenario_tests/test_attachment_actions.py | 57 ++++++-- tests/scenario_tests/test_authorize.py | 14 +- tests/scenario_tests/test_block_actions.py | 28 +++- tests/scenario_tests/test_block_suggestion.py | 38 ++++-- tests/scenario_tests/test_dialogs.py | 73 ++++++++-- tests/scenario_tests/test_error_handler.py | 26 +++- tests/scenario_tests/test_events.py | 8 +- tests/scenario_tests/test_events_org_apps.py | 6 +- .../test_events_shared_channels.py | 9 +- .../scenario_tests/test_events_socket_mode.py | 5 +- tests/scenario_tests/test_lazy.py | 24 +++- tests/scenario_tests/test_message.py | 38 ++++-- tests/scenario_tests/test_middleware.py | 18 ++- tests/scenario_tests/test_shortcut.py | 43 ++++-- tests/scenario_tests/test_slash_command.py | 18 ++- tests/scenario_tests/test_ssl_check.py | 8 +- tests/scenario_tests/test_view_closed.py | 53 +++++-- tests/scenario_tests/test_view_submission.py | 48 +++++-- tests/scenario_tests/test_workflow_steps.py | 34 ++++- tests/scenario_tests_async/test_app.py | 9 +- .../scenario_tests_async/test_app_bot_only.py | 8 +- .../test_attachment_actions.py | 57 ++++++-- tests/scenario_tests_async/test_authorize.py | 14 +- .../test_block_actions.py | 43 ++++-- .../test_block_suggestion.py | 38 ++++-- tests/scenario_tests_async/test_dialogs.py | 73 ++++++++-- .../test_error_handler.py | 26 +++- tests/scenario_tests_async/test_events.py | 43 ++++-- .../test_events_org_apps.py | 3 +- .../test_events_shared_channels.py | 6 +- .../test_events_socket_mode.py | 10 +- tests/scenario_tests_async/test_lazy.py | 24 +++- tests/scenario_tests_async/test_message.py | 38 ++++-- tests/scenario_tests_async/test_middleware.py | 18 ++- tests/scenario_tests_async/test_shortcut.py | 43 ++++-- .../test_slash_command.py | 18 ++- tests/scenario_tests_async/test_ssl_check.py | 8 +- .../scenario_tests_async/test_view_closed.py | 53 +++++-- .../test_view_submission.py | 48 +++++-- .../test_workflow_steps.py | 39 ++++-- tests/slack_bolt/app/test_dev_server.py | 5 +- tests/slack_bolt/context/test_ack.py | 10 +- .../test_custom_listener_matcher.py | 3 +- .../test_request_verification.py | 3 +- tests/slack_bolt_async/app/test_server.py | 5 +- .../authorization/test_async_authorize.py | 4 +- .../context/test_async_ack.py | 10 +- .../test_async_custom_listener_matcher.py | 3 +- .../test_request_verification.py | 3 +- .../oauth/test_async_oauth_flow.py | 3 +- .../oauth/test_async_oauth_flow_sqlite3.py | 5 +- 155 files changed, 2058 insertions(+), 650 deletions(-) diff --git a/examples/app_authorize.py b/examples/app_authorize.py index 0b81e9b66..3f4ab5b01 100644 --- a/examples/app_authorize.py +++ b/examples/app_authorize.py @@ -7,6 +7,7 @@ # ------------------------------------------------ import logging + logging.basicConfig(level=logging.DEBUG) import os @@ -14,6 +15,7 @@ from slack_bolt.authorization import AuthorizeResult from slack_sdk import WebClient + def authorize(enterprise_id, team_id, user_id, client: WebClient, logger): logger.info(f"{enterprise_id},{team_id},{user_id}") # You can implement your own logic here @@ -23,10 +25,9 @@ def authorize(enterprise_id, team_id, user_id, client: WebClient, logger): bot_token=token, ) -app = App( - signing_secret=os.environ["SLACK_SIGNING_SECRET"], - authorize=authorize -) + +app = App(signing_secret=os.environ["SLACK_SIGNING_SECRET"], authorize=authorize) + @app.command("/hello-bolt-python") def hello_command(ack, body): diff --git a/examples/async_app_authorize.py b/examples/async_app_authorize.py index aee349331..cb62fe925 100644 --- a/examples/async_app_authorize.py +++ b/examples/async_app_authorize.py @@ -14,6 +14,7 @@ from slack_bolt.authorization import AuthorizeResult from slack_bolt.async_app import AsyncApp + async def authorize(enterprise_id, team_id, user_id, client: AsyncWebClient, logger): logger.info(f"{enterprise_id},{team_id},{user_id}") # You can implement your own logic here @@ -24,10 +25,8 @@ async def authorize(enterprise_id, team_id, user_id, client: AsyncWebClient, log ) -app = AsyncApp( - signing_secret=os.environ["SLACK_SIGNING_SECRET"], - authorize=authorize -) +app = AsyncApp(signing_secret=os.environ["SLACK_SIGNING_SECRET"], authorize=authorize) + @app.event("app_mention") async def event_test(body, say, logger): diff --git a/examples/async_steps_from_apps.py b/examples/async_steps_from_apps.py index e38e352bb..061012eb0 100644 --- a/examples/async_steps_from_apps.py +++ b/examples/async_steps_from_apps.py @@ -9,7 +9,12 @@ from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient from slack_bolt.async_app import AsyncApp, AsyncAck -from slack_bolt.workflows.step.async_step import AsyncConfigure, AsyncUpdate, AsyncComplete, AsyncFail +from slack_bolt.workflows.step.async_step import ( + AsyncConfigure, + AsyncUpdate, + AsyncComplete, + AsyncFail, +) logging.basicConfig(level=logging.DEBUG) @@ -93,7 +98,11 @@ async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): }, }, outputs=[ - {"name": "taskName", "type": "text", "label": "Task Name", }, + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, { "name": "taskDescription", "type": "text", @@ -104,7 +113,7 @@ async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): "type": "text", "label": "Task Author Email", }, - ] + ], ) await ack() @@ -112,7 +121,9 @@ async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): pseudo_database = {} -async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail): +async def execute( + step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail +): try: await complete( outputs={ @@ -152,9 +163,7 @@ async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, f }, ) except: - await fail(error={ - "message": "Something wrong!" - }) + await fail(error={"message": "Something wrong!"}) app.step( diff --git a/examples/async_steps_from_apps_primitive.py b/examples/async_steps_from_apps_primitive.py index 3c65addf0..905fc38a8 100644 --- a/examples/async_steps_from_apps_primitive.py +++ b/examples/async_steps_from_apps_primitive.py @@ -102,7 +102,11 @@ async def save(ack: AsyncAck, client: AsyncWebClient, body: dict): }, }, "outputs": [ - {"name": "taskName", "type": "text", "label": "Task Name",}, + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, { "name": "taskDescription", "type": "text", diff --git a/examples/aws_chalice/app.py b/examples/aws_chalice/app.py index 59facf400..248f58a01 100644 --- a/examples/aws_chalice/app.py +++ b/examples/aws_chalice/app.py @@ -19,13 +19,14 @@ def handle_app_mentions(body, say, logger): def respond_to_slack_within_3_seconds(ack): ack("Accepted!") + def say_it(say): time.sleep(5) say("Done!") + bolt_app.command("/hello-bolt-python-chalice")( - ack=respond_to_slack_within_3_seconds, - lazy=[say_it] + ack=respond_to_slack_within_3_seconds, lazy=[say_it] ) ChaliceSlackRequestHandler.clear_all_log_handlers() diff --git a/examples/aws_lambda/aws_lambda_oauth.py b/examples/aws_lambda/aws_lambda_oauth.py index f7210dc85..348da4b2e 100644 --- a/examples/aws_lambda/aws_lambda_oauth.py +++ b/examples/aws_lambda/aws_lambda_oauth.py @@ -12,7 +12,10 @@ from slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow import LambdaS3OAuthFlow # process_before_response must be True when running on FaaS -app = App(process_before_response=True, oauth_flow=LambdaS3OAuthFlow(),) +app = App( + process_before_response=True, + oauth_flow=LambdaS3OAuthFlow(), +) @app.event("app_mention") diff --git a/examples/dialogs_app.py b/examples/dialogs_app.py index 46265afbc..e2f23e3ed 100644 --- a/examples/dialogs_app.py +++ b/examples/dialogs_app.py @@ -64,7 +64,7 @@ def dialog_submission_or_cancellation(ack: Ack, body: dict): errors = [ { "name": "loc_origin", - "error": "Pickup Location must be longer than 3 characters" + "error": "Pickup Location must be longer than 3 characters", } ] if len(errors) > 0: @@ -116,7 +116,6 @@ def dialog_suggestion(ack): ) - if __name__ == "__main__": app.start(3000) diff --git a/examples/django/slackapp/migrations/0001_initial.py b/examples/django/slackapp/migrations/0001_initial.py index 5d256685d..a2bcb49ef 100644 --- a/examples/django/slackapp/migrations/0001_initial.py +++ b/examples/django/slackapp/migrations/0001_initial.py @@ -7,69 +7,104 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='SlackBot', + name="SlackBot", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('client_id', models.TextField()), - ('app_id', models.TextField()), - ('enterprise_id', models.TextField(null=True)), - ('enterprise_name', models.TextField(null=True)), - ('team_id', models.TextField(null=True)), - ('team_name', models.TextField(null=True)), - ('bot_token', models.TextField(null=True)), - ('bot_id', models.TextField(null=True)), - ('bot_user_id', models.TextField(null=True)), - ('bot_scopes', models.TextField(null=True)), - ('is_enterprise_install', models.BooleanField(null=True)), - ('installed_at', models.DateTimeField()), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("client_id", models.TextField()), + ("app_id", models.TextField()), + ("enterprise_id", models.TextField(null=True)), + ("enterprise_name", models.TextField(null=True)), + ("team_id", models.TextField(null=True)), + ("team_name", models.TextField(null=True)), + ("bot_token", models.TextField(null=True)), + ("bot_id", models.TextField(null=True)), + ("bot_user_id", models.TextField(null=True)), + ("bot_scopes", models.TextField(null=True)), + ("is_enterprise_install", models.BooleanField(null=True)), + ("installed_at", models.DateTimeField()), ], ), migrations.CreateModel( - name='SlackInstallation', + name="SlackInstallation", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('client_id', models.TextField()), - ('app_id', models.TextField()), - ('enterprise_id', models.TextField(null=True)), - ('enterprise_name', models.TextField(null=True)), - ('enterprise_url', models.TextField(null=True)), - ('team_id', models.TextField(null=True)), - ('team_name', models.TextField(null=True)), - ('bot_token', models.TextField(null=True)), - ('bot_id', models.TextField(null=True)), - ('bot_user_id', models.TextField(null=True)), - ('bot_scopes', models.TextField(null=True)), - ('user_id', models.TextField()), - ('user_token', models.TextField(null=True)), - ('user_scopes', models.TextField(null=True)), - ('incoming_webhook_url', models.TextField(null=True)), - ('incoming_webhook_channel', models.TextField(null=True)), - ('incoming_webhook_channel_id', models.TextField(null=True)), - ('incoming_webhook_configuration_url', models.TextField(null=True)), - ('is_enterprise_install', models.BooleanField(null=True)), - ('token_type', models.TextField(null=True)), - ('installed_at', models.DateTimeField()), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("client_id", models.TextField()), + ("app_id", models.TextField()), + ("enterprise_id", models.TextField(null=True)), + ("enterprise_name", models.TextField(null=True)), + ("enterprise_url", models.TextField(null=True)), + ("team_id", models.TextField(null=True)), + ("team_name", models.TextField(null=True)), + ("bot_token", models.TextField(null=True)), + ("bot_id", models.TextField(null=True)), + ("bot_user_id", models.TextField(null=True)), + ("bot_scopes", models.TextField(null=True)), + ("user_id", models.TextField()), + ("user_token", models.TextField(null=True)), + ("user_scopes", models.TextField(null=True)), + ("incoming_webhook_url", models.TextField(null=True)), + ("incoming_webhook_channel", models.TextField(null=True)), + ("incoming_webhook_channel_id", models.TextField(null=True)), + ("incoming_webhook_configuration_url", models.TextField(null=True)), + ("is_enterprise_install", models.BooleanField(null=True)), + ("token_type", models.TextField(null=True)), + ("installed_at", models.DateTimeField()), ], ), migrations.CreateModel( - name='SlackOAuthState', + name="SlackOAuthState", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('state', models.TextField()), - ('expire_at', models.DateTimeField()), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("state", models.TextField()), + ("expire_at", models.DateTimeField()), ], ), migrations.AddIndex( - model_name='slackinstallation', - index=models.Index(fields=['client_id', 'enterprise_id', 'team_id', 'user_id', 'installed_at'], name='bolt_slacki_client__62c411_idx'), + model_name="slackinstallation", + index=models.Index( + fields=[ + "client_id", + "enterprise_id", + "team_id", + "user_id", + "installed_at", + ], + name="bolt_slacki_client__62c411_idx", + ), ), migrations.AddIndex( - model_name='slackbot', - index=models.Index(fields=['client_id', 'enterprise_id', 'team_id', 'installed_at'], name='bolt_slackb_client__be066b_idx'), + model_name="slackbot", + index=models.Index( + fields=["client_id", "enterprise_id", "team_id", "installed_at"], + name="bolt_slackb_client__be066b_idx", + ), ), ] diff --git a/examples/django/slackapp/models.py b/examples/django/slackapp/models.py index 6069ce345..737c4bd90 100644 --- a/examples/django/slackapp/models.py +++ b/examples/django/slackapp/models.py @@ -4,6 +4,7 @@ from django.db import models + class SlackBot(models.Model): client_id = models.TextField(null=False) app_id = models.TextField(null=False) @@ -86,7 +87,9 @@ class DjangoInstallationStore(InstallationStore): client_id: str def __init__( - self, client_id: str, logger: Logger, + self, + client_id: str, + logger: Logger, ): self.client_id = client_id self._logger = logger @@ -104,8 +107,11 @@ def save(self, installation: Installation): SlackBot(**b).save() def find_bot( - self, *, enterprise_id: Optional[str], team_id: Optional[str], - is_enterprise_install: Optional[bool] = False + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, ) -> Optional[Bot]: e_id = enterprise_id or None t_id = team_id or None @@ -143,13 +149,18 @@ def find_installation( if is_enterprise_install: t_id = None if user_id is None: - rows = (SlackInstallation.objects.filter( - enterprise_id=e_id).filter(team_id=t_id).order_by( - F("installed_at").desc())[:1]) + rows = ( + SlackInstallation.objects.filter(enterprise_id=e_id) + .filter(team_id=t_id) + .order_by(F("installed_at").desc())[:1] + ) else: - rows = (SlackInstallation.objects.filter( - enterprise_id=e_id).filter(team_id=t_id).filter( - user_id=user_id).order_by(F("installed_at").desc())[:1]) + rows = ( + SlackInstallation.objects.filter(enterprise_id=e_id) + .filter(team_id=t_id) + .filter(user_id=user_id) + .order_by(F("installed_at").desc())[:1] + ) if len(rows) > 0: i = rows[0] @@ -176,7 +187,9 @@ class DjangoOAuthStateStore(OAuthStateStore): expiration_seconds: int def __init__( - self, expiration_seconds: int, logger: Logger, + self, + expiration_seconds: int, + logger: Logger, ): self.expiration_seconds = expiration_seconds self._logger = logger @@ -202,6 +215,7 @@ def consume(self, state: str) -> bool: return True return False + # ---------------------- # Slack App # ---------------------- @@ -220,11 +234,17 @@ def consume(self, state: str) -> bool: app = App( signing_secret=signing_secret, - installation_store=DjangoInstallationStore(client_id=client_id, logger=logger,), + installation_store=DjangoInstallationStore( + client_id=client_id, + logger=logger, + ), oauth_settings=OAuthSettings( client_id=client_id, client_secret=client_secret, - state_store=DjangoOAuthStateStore(expiration_seconds=120, logger=logger,), + state_store=DjangoOAuthStateStore( + expiration_seconds=120, + logger=logger, + ), ), ) @@ -234,6 +254,7 @@ def event_test(body, say, logger): logger.info(body) say("What's up?") + @app.command("/hello-bolt-python") def command(ack): - ack("This is a Django app!") \ No newline at end of file + ack("This is a Django app!") diff --git a/examples/django/slackapp/settings.py b/examples/django/slackapp/settings.py index 13252519b..1553e7df3 100644 --- a/examples/django/slackapp/settings.py +++ b/examples/django/slackapp/settings.py @@ -15,16 +15,29 @@ LOGGING = { "version": 1, "disable_existing_loggers": False, - "handlers": {"console": {"class": "logging.StreamHandler",},}, - "root": {"handlers": ["console"], "level": "INFO",}, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "INFO", + }, "loggers": { "django": { "handlers": ["console"], "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), "propagate": False, }, - "django.db.backends": {"level": "DEBUG",}, - "slack_bolt": {"handlers": ["console"], "level": "DEBUG", "propagate": False,}, + "django.db.backends": { + "level": "DEBUG", + }, + "slack_bolt": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, }, } @@ -105,9 +118,15 @@ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, - {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",}, - {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",}, - {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",}, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, ] # Internationalization diff --git a/examples/falcon/app.py b/examples/falcon/app.py index 40e32e93d..1dbf39ffb 100644 --- a/examples/falcon/app.py +++ b/examples/falcon/app.py @@ -52,14 +52,26 @@ def test_shortcut(ack, client: WebClient, logger, body): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, diff --git a/examples/falcon/oauth_app.py b/examples/falcon/oauth_app.py index 167003a9c..900ffca99 100644 --- a/examples/falcon/oauth_app.py +++ b/examples/falcon/oauth_app.py @@ -52,14 +52,26 @@ def test_shortcut(ack, client: WebClient, logger, body): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, diff --git a/examples/fastapi/async_oauth_app.py b/examples/fastapi/async_oauth_app.py index 8ba33668a..a19ba402c 100644 --- a/examples/fastapi/async_oauth_app.py +++ b/examples/fastapi/async_oauth_app.py @@ -6,6 +6,7 @@ # ------------------------------------------------ import logging + logging.basicConfig(level=logging.DEBUG) from slack_bolt.async_app import AsyncApp diff --git a/examples/getting_started/app.py b/examples/getting_started/app.py index 23fd2f96d..e394dd2fa 100644 --- a/examples/getting_started/app.py +++ b/examples/getting_started/app.py @@ -4,7 +4,7 @@ # Initializes your app with your bot token and signing secret app = App( token=os.environ.get("SLACK_BOT_TOKEN"), - signing_secret=os.environ.get("SLACK_SIGNING_SECRET") + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), ) # Listens to incoming messages that contain "hello" @@ -15,29 +15,25 @@ def message_hello(message, say): blocks=[ { "type": "section", - "text": { - "type": "mrkdwn", - "text": f"Hey there <@{message['user']}>!" - }, + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, "accessory": { "type": "button", - "text": { - "type": "plain_text", - "text": "Click Me" - }, - "action_id": "button_click" - } + "text": {"type": "plain_text", "text": "Click Me"}, + "action_id": "button_click", + }, } ], - text=f"Hey there <@{message['user']}>!" + text=f"Hey there <@{message['user']}>!", ) + @app.action("button_click") def action_button_click(body, ack, say): # Acknowledge the action ack() say(f"<@{body['user']['id']}> clicked the button") + # Start your app if __name__ == "__main__": - app.start(port=int(os.environ.get("PORT", 3000))) \ No newline at end of file + app.start(port=int(os.environ.get("PORT", 3000))) diff --git a/examples/lazy_async_modals_app.py b/examples/lazy_async_modals_app.py index a566cdf56..7f25d5a73 100644 --- a/examples/lazy_async_modals_app.py +++ b/examples/lazy_async_modals_app.py @@ -52,14 +52,26 @@ async def open_modal(body, client, logger): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, }, { "type": "input", @@ -90,7 +102,8 @@ async def open_modal(body, client, logger): app.command("/hello-bolt-python")( - ack=ack_command, lazy=[post_button_message, open_modal], + ack=ack_command, + lazy=[post_button_message, open_modal], ) diff --git a/examples/lazy_modals_app.py b/examples/lazy_modals_app.py index 4de8c7778..9b668a859 100644 --- a/examples/lazy_modals_app.py +++ b/examples/lazy_modals_app.py @@ -53,14 +53,26 @@ def open_modal(body, client, logger): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, }, { "type": "input", @@ -91,7 +103,8 @@ def open_modal(body, client, logger): app.command("/hello-bolt-python")( - ack=ack_command, lazy=[post_button_message, open_modal], + ack=ack_command, + lazy=[post_button_message, open_modal], ) diff --git a/examples/message_events.py b/examples/message_events.py index e732fd3f6..e0693f7b6 100644 --- a/examples/message_events.py +++ b/examples/message_events.py @@ -75,7 +75,9 @@ def add_reaction( logger.info(f"subtype: {subtype}") message_ts = body["event"]["ts"] api_response = client.reactions_add( - channel=context.channel_id, timestamp=message_ts, name="eyes", + channel=context.channel_id, + timestamp=message_ts, + name="eyes", ) logger.info(f"api_response: {api_response}") diff --git a/examples/modals_app.py b/examples/modals_app.py index 7859028ff..f0da8d9f8 100644 --- a/examples/modals_app.py +++ b/examples/modals_app.py @@ -29,7 +29,10 @@ def handle_command(body, ack, respond, client, logger): { "type": "section", "block_id": "b", - "text": {"type": "mrkdwn", "text": ":white_check_mark: Accepted!",}, + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: Accepted!", + }, } ], ) @@ -58,14 +61,26 @@ def handle_command(body, ack, respond, client, logger): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, }, { "type": "input", @@ -155,7 +170,8 @@ def button_click(ack, body, respond): ) # ephemeral / kwargs respond( - replace_original=False, text=":white_check_mark: Done!", + replace_original=False, + text=":white_check_mark: Done!", ) diff --git a/examples/modals_app_typed.py b/examples/modals_app_typed.py index 62821bb23..ffad223cf 100644 --- a/examples/modals_app_typed.py +++ b/examples/modals_app_typed.py @@ -123,7 +123,9 @@ def show_multi_options(ack: Ack) -> None: ), OptionGroup( label=PlainTextObject(text="Group 2"), - options=[Option(text=PlainTextObject(text="Option 1"), value="2-1"),], + options=[ + Option(text=PlainTextObject(text="Option 1"), value="2-1"), + ], ), ] ) @@ -148,7 +150,8 @@ def button_click(ack: Ack, body: dict, respond: Respond) -> None: ) # ephemeral / kwargs respond( - replace_original=False, text=":white_check_mark: Done!", + replace_original=False, + text=":white_check_mark: Done!", ) diff --git a/examples/oauth_app.py b/examples/oauth_app.py index 596b52fad..26b772a77 100644 --- a/examples/oauth_app.py +++ b/examples/oauth_app.py @@ -48,14 +48,26 @@ def test_command(body, respond, client, ack, logger): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, diff --git a/examples/oauth_app_settings.py b/examples/oauth_app_settings.py index 4975de519..977ec0e49 100644 --- a/examples/oauth_app_settings.py +++ b/examples/oauth_app_settings.py @@ -74,14 +74,26 @@ def test_command(body, respond, client, ack, logger): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, diff --git a/examples/readme_app.py b/examples/readme_app.py index 6cdbd8ec1..963938658 100644 --- a/examples/readme_app.py +++ b/examples/readme_app.py @@ -34,14 +34,23 @@ def open_modal(ack, client, logger, body): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, "blocks": [ { "type": "input", "block_id": "b", "element": {"type": "plain_text_input", "action_id": "a"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, diff --git a/examples/readme_async_app.py b/examples/readme_async_app.py index 092dca64b..c43d3af32 100644 --- a/examples/readme_async_app.py +++ b/examples/readme_async_app.py @@ -14,11 +14,13 @@ app = AsyncApp() + @app.command("/hello-bolt-python") async def command(ack, body, respond): await ack() await respond(f"Hi <@{body['user_id']}>!") + # Middleware @app.middleware # or app.use(log_request) async def log_request(logger, body, next): @@ -44,14 +46,23 @@ async def open_modal(ack, client, logger, body): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, "blocks": [ { "type": "input", "block_id": "b", "element": {"type": "plain_text_input", "action_id": "a"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, diff --git a/examples/sqlalchemy/async_oauth_app.py b/examples/sqlalchemy/async_oauth_app.py index 4aca131de..605e1fb1b 100644 --- a/examples/sqlalchemy/async_oauth_app.py +++ b/examples/sqlalchemy/async_oauth_app.py @@ -157,10 +157,14 @@ async def async_consume(self, state: str) -> bool: ) installation_store = AsyncSQLAlchemyInstallationStore( - client_id=client_id, database_url=database_url, logger=logger, + client_id=client_id, + database_url=database_url, + logger=logger, ) oauth_state_store = AsyncSQLAlchemyOAuthStateStore( - expiration_seconds=120, database_url=database_url, logger=logger, + expiration_seconds=120, + database_url=database_url, + logger=logger, ) app = AsyncApp( @@ -168,7 +172,9 @@ async def async_consume(self, state: str) -> bool: signing_secret=signing_secret, installation_store=installation_store, oauth_settings=AsyncOAuthSettings( - client_id=client_id, client_secret=client_secret, state_store=oauth_state_store, + client_id=client_id, + client_secret=client_secret, + state_store=oauth_state_store, ), ) app_handler = AsyncSlackRequestHandler(app) diff --git a/examples/sqlalchemy/oauth_app.py b/examples/sqlalchemy/oauth_app.py index 40e1309ec..4ad7f8ff3 100644 --- a/examples/sqlalchemy/oauth_app.py +++ b/examples/sqlalchemy/oauth_app.py @@ -25,10 +25,14 @@ engine: Engine = sqlalchemy.create_engine(database_url) installation_store = SQLAlchemyInstallationStore( - client_id=client_id, engine=engine, logger=logger, + client_id=client_id, + engine=engine, + logger=logger, ) oauth_state_store = SQLAlchemyOAuthStateStore( - expiration_seconds=120, engine=engine, logger=logger, + expiration_seconds=120, + engine=engine, + logger=logger, ) try: diff --git a/examples/steps_from_apps.py b/examples/steps_from_apps.py index ab3921062..26cc9665a 100644 --- a/examples/steps_from_apps.py +++ b/examples/steps_from_apps.py @@ -101,7 +101,11 @@ def save(ack: Ack, view: dict, update: Update): }, }, outputs=[ - {"name": "taskName", "type": "text", "label": "Task Name", }, + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, { "name": "taskDescription", "type": "text", @@ -112,7 +116,7 @@ def save(ack: Ack, view: dict, update: Update): "type": "text", "label": "Task Author Email", }, - ] + ], ) ack() @@ -161,9 +165,7 @@ def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): }, ) except Exception as err: - fail(error={ - "message": "Something wrong!" - }) + fail(error={"message": "Something wrong!"}) app.step( diff --git a/examples/steps_from_apps_primitive.py b/examples/steps_from_apps_primitive.py index 9f8889cbc..efd206eda 100644 --- a/examples/steps_from_apps_primitive.py +++ b/examples/steps_from_apps_primitive.py @@ -104,7 +104,11 @@ def save(ack: Ack, client: WebClient, body: dict): }, }, "outputs": [ - {"name": "taskName", "type": "text", "label": "Task Name",}, + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, { "name": "taskDescription", "type": "text", diff --git a/slack_bolt/adapter/aiohttp/__init__.py b/slack_bolt/adapter/aiohttp/__init__.py index 6d4cb9715..02d6a1673 100644 --- a/slack_bolt/adapter/aiohttp/__init__.py +++ b/slack_bolt/adapter/aiohttp/__init__.py @@ -8,7 +8,9 @@ async def to_bolt_request(request: web.Request) -> AsyncBoltRequest: return AsyncBoltRequest( - body=await request.text(), query=request.query_string, headers=request.headers, + body=await request.text(), + query=request.query_string, + headers=request.headers, ) diff --git a/slack_bolt/adapter/aws_lambda/chalice_handler.py b/slack_bolt/adapter/aws_lambda/chalice_handler.py index 48d64ab3a..8d5fc9b21 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_handler.py +++ b/slack_bolt/adapter/aws_lambda/chalice_handler.py @@ -76,14 +76,24 @@ def handle(self, request: Request): def to_bolt_request(request: Request, body: str) -> BoltRequest: - return BoltRequest(body=body, query=request.query_params, headers=request.headers,) + return BoltRequest( + body=body, + query=request.query_params, + headers=request.headers, + ) def to_chalice_response(resp: BoltResponse) -> Response: return Response( - status_code=resp.status, body=resp.body, headers=resp.first_headers(), + status_code=resp.status, + body=resp.body, + headers=resp.first_headers(), ) def not_found() -> Response: - return Response(status_code=404, body="Not Found", headers={},) + return Response( + status_code=404, + body="Not Found", + headers={}, + ) diff --git a/slack_bolt/adapter/aws_lambda/handler.py b/slack_bolt/adapter/aws_lambda/handler.py index 885115cb3..fef64094c 100644 --- a/slack_bolt/adapter/aws_lambda/handler.py +++ b/slack_bolt/adapter/aws_lambda/handler.py @@ -82,7 +82,9 @@ def to_bolt_request(event) -> BoltRequest: headers = event.get("headers", {}) headers["cookie"] = cookies return BoltRequest( - body=body, query=event.get("queryStringParameters", {}), headers=headers, + body=body, + query=event.get("queryStringParameters", {}), + headers=headers, ) diff --git a/slack_bolt/adapter/bottle/handler.py b/slack_bolt/adapter/bottle/handler.py index 4b4d9aef0..d00976c32 100644 --- a/slack_bolt/adapter/bottle/handler.py +++ b/slack_bolt/adapter/bottle/handler.py @@ -10,7 +10,11 @@ def to_bolt_request(req: Request) -> BoltRequest: body = req.body.read() if isinstance(body, bytes): body = body.decode("utf-8") - return BoltRequest(body=body, query=req.query_string, headers=req.headers,) + return BoltRequest( + body=body, + query=req.query_string, + headers=req.headers, + ) def set_response(bolt_resp: BoltResponse, resp: Response) -> None: diff --git a/slack_bolt/adapter/cherrypy/handler.py b/slack_bolt/adapter/cherrypy/handler.py index bd8dd0dc3..aa228f3f8 100644 --- a/slack_bolt/adapter/cherrypy/handler.py +++ b/slack_bolt/adapter/cherrypy/handler.py @@ -11,7 +11,11 @@ def build_bolt_request() -> BoltRequest: req = cherrypy.request body = req.raw_body if hasattr(req, "raw_body") else "" - return BoltRequest(body=body, query=req.query_string, headers=req.headers,) + return BoltRequest( + body=body, + query=req.query_string, + headers=req.headers, + ) def set_response_status_and_headers(bolt_resp: BoltResponse) -> None: diff --git a/slack_bolt/adapter/django/handler.py b/slack_bolt/adapter/django/handler.py index 1749b67de..24eb91b0e 100644 --- a/slack_bolt/adapter/django/handler.py +++ b/slack_bolt/adapter/django/handler.py @@ -11,12 +11,17 @@ def to_bolt_request(req: HttpRequest) -> BoltRequest: raw_body: bytes = req.body body: str = raw_body.decode("utf-8") if raw_body else "" - return BoltRequest(body=body, query=req.META["QUERY_STRING"], headers=req.headers,) + return BoltRequest( + body=body, + query=req.META["QUERY_STRING"], + headers=req.headers, + ) def to_django_response(bolt_resp: BoltResponse) -> HttpResponse: resp: HttpResponse = HttpResponse( - status=bolt_resp.status, content=bolt_resp.body.encode("utf-8"), + status=bolt_resp.status, + content=bolt_resp.body.encode("utf-8"), ) for k, v in bolt_resp.first_headers_without_set_cookie().items(): resp[k] = v diff --git a/slack_bolt/adapter/pyramid/handler.py b/slack_bolt/adapter/pyramid/handler.py index 47fe96645..14ba14c2c 100644 --- a/slack_bolt/adapter/pyramid/handler.py +++ b/slack_bolt/adapter/pyramid/handler.py @@ -15,7 +15,9 @@ def to_bolt_request(request: Request) -> BoltRequest: else: body = request.body bolt_req = BoltRequest( - body=body, query=request.query_string, headers=request.headers, + body=body, + query=request.query_string, + headers=request.headers, ) return bolt_req diff --git a/slack_bolt/adapter/sanic/async_handler.py b/slack_bolt/adapter/sanic/async_handler.py index a18af2250..6b3d20c15 100644 --- a/slack_bolt/adapter/sanic/async_handler.py +++ b/slack_bolt/adapter/sanic/async_handler.py @@ -10,7 +10,9 @@ def to_async_bolt_request(req: Request) -> AsyncBoltRequest: return AsyncBoltRequest( - body=req.body.decode("utf-8"), query=req.query_string, headers=req.headers, + body=req.body.decode("utf-8"), + query=req.query_string, + headers=req.headers, ) @@ -58,4 +60,7 @@ async def handle(self, req: Request) -> HTTPResponse: bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req)) return to_sanic_response(bolt_resp) - return HTTPResponse(status=404, body="Not found",) + return HTTPResponse( + status=404, + body="Not found", + ) diff --git a/slack_bolt/adapter/starlette/async_handler.py b/slack_bolt/adapter/starlette/async_handler.py index a4fc0464b..bc38176bf 100644 --- a/slack_bolt/adapter/starlette/async_handler.py +++ b/slack_bolt/adapter/starlette/async_handler.py @@ -8,7 +8,9 @@ def to_async_bolt_request(req: Request, body: bytes) -> AsyncBoltRequest: return AsyncBoltRequest( - body=body.decode("utf-8"), query=req.query_params, headers=req.headers, + body=body.decode("utf-8"), + query=req.query_params, + headers=req.headers, ) @@ -56,4 +58,7 @@ async def handle(self, req: Request) -> Response: bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, body)) return to_starlette_response(bolt_resp) - return Response(status_code=404, content="Not found",) + return Response( + status_code=404, + content="Not found", + ) diff --git a/slack_bolt/adapter/starlette/handler.py b/slack_bolt/adapter/starlette/handler.py index e5e4f6057..c8cea10d4 100644 --- a/slack_bolt/adapter/starlette/handler.py +++ b/slack_bolt/adapter/starlette/handler.py @@ -7,7 +7,9 @@ def to_bolt_request(req: Request, body: bytes) -> BoltRequest: return BoltRequest( - body=body.decode("utf-8"), query=req.query_params, headers=req.headers, + body=body.decode("utf-8"), + query=req.query_params, + headers=req.headers, ) @@ -53,4 +55,7 @@ async def handle(self, req: Request) -> Response: bolt_resp = self.app.dispatch(to_bolt_request(req, body)) return to_starlette_response(bolt_resp) - return Response(status_code=404, content="Not found",) + return Response( + status_code=404, + content="Not found", + ) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 75593af78..da54e7393 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -224,7 +224,8 @@ def __init__( ), listener_executor=listener_executor, lazy_listener_runner=ThreadLazyListenerRunner( - logger=self._framework_logger, executor=listener_executor, + logger=self._framework_logger, + executor=listener_executor, ), ) @@ -306,7 +307,10 @@ def start(self, port: int = 3000, path: str = "/slack/events") -> None: :return: None """ self._development_server = SlackAppDevelopmentServer( - port=port, path=path, app=self, oauth_flow=self.oauth_flow, + port=port, + path=path, + app=self, + oauth_flow=self.oauth_flow, ) self._development_server.start() @@ -411,7 +415,10 @@ def step( step = callback_id if isinstance(callback_id, (str, Pattern)): step = WorkflowStep( - callback_id=callback_id, edit=edit, save=save, execute=execute, + callback_id=callback_id, + edit=edit, + save=save, + execute=execute, ) elif not isinstance(step, WorkflowStep): raise BoltError("Invalid step object") @@ -431,7 +438,8 @@ def error( :return: None """ self._listener_runner.listener_error_handler = CustomListenerErrorHandler( - logger=self._framework_logger, func=func, + logger=self._framework_logger, + func=func, ) return func @@ -864,7 +872,11 @@ def _register_listener( class SlackAppDevelopmentServer: def __init__( - self, port: int, path: str, app: App, oauth_flow: Optional[OAuthFlow] = None, + self, + port: int, + path: str, + app: App, + oauth_flow: Optional[OAuthFlow] = None, ): """Slack App Development Server diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 8fdc6bca0..d74ea898d 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -331,7 +331,11 @@ def server( or self._server.port != port or self._server.path != path ): - self._server = AsyncSlackAppServer(port=port, path=path, app=self,) + self._server = AsyncSlackAppServer( + port=port, + path=path, + app=self, + ) return self._server def web_app(self, path: str = "/slack/events") -> web.Application: @@ -461,7 +465,10 @@ def step( step = callback_id if isinstance(callback_id, (str, Pattern)): step = AsyncWorkflowStep( - callback_id=callback_id, edit=edit, save=save, execute=execute, + callback_id=callback_id, + edit=edit, + save=save, + execute=execute, ) elif not isinstance(step, AsyncWorkflowStep): raise BoltError("Invalid step object") @@ -480,8 +487,11 @@ def error( when getting an unhandled error in Bolt app. :return: None """ - self._async_listener_runner.listener_error_handler = AsyncCustomListenerErrorHandler( - logger=self._framework_logger, func=func, + self._async_listener_runner.listener_error_handler = ( + AsyncCustomListenerErrorHandler( + logger=self._framework_logger, + func=func, + ) ) return func diff --git a/slack_bolt/app/async_server.py b/slack_bolt/app/async_server.py index 6ae669098..aeb93fb73 100644 --- a/slack_bolt/app/async_server.py +++ b/slack_bolt/app/async_server.py @@ -14,7 +14,10 @@ class AsyncSlackAppServer: web_app: web.Application def __init__( # type:ignore - self, port: int, path: str, app: "AsyncApp", # type:ignore + self, + port: int, + path: str, + app: "AsyncApp", # type:ignore ): """Standalone AIOHTTP Web Server @@ -67,7 +70,7 @@ async def handle_post_requests(self, request: web.Request) -> web.Response: return await to_aiohttp_response(bolt_resp) def start(self) -> None: - """ Starts a new web server process. + """Starts a new web server process. :return: None """ diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index a78700ced..fb723f3cd 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -145,11 +145,13 @@ async def __call__( if installation.user_id != user_id: # try to fetch the request user's installation # to reflect the user's access token if exists - user_installation = await self.installation_store.async_find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, + user_installation = ( + await self.installation_store.async_find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) ) if user_installation is not None: installation = user_installation diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 7e51ab97b..090d5113f 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -29,7 +29,10 @@ def __call__( class CallableAuthorize(Authorize): def __init__( - self, *, logger: Logger, func: Callable[..., AuthorizeResult], + self, + *, + logger: Logger, + func: Callable[..., AuthorizeResult], ): self.logger = logger self.func = func diff --git a/slack_bolt/context/say/async_say.py b/slack_bolt/context/say/async_say.py index ccbefd4f9..a47838e1b 100644 --- a/slack_bolt/context/say/async_say.py +++ b/slack_bolt/context/say/async_say.py @@ -12,7 +12,9 @@ class AsyncSay: channel: Optional[str] def __init__( - self, client: Optional[AsyncWebClient], channel: Optional[str], + self, + client: Optional[AsyncWebClient], + channel: Optional[str], ): self.client = client self.channel = channel diff --git a/slack_bolt/context/say/say.py b/slack_bolt/context/say/say.py index 9c4181957..ccf7d6d91 100644 --- a/slack_bolt/context/say/say.py +++ b/slack_bolt/context/say/say.py @@ -13,7 +13,9 @@ class Say: channel: Optional[str] def __init__( - self, client: Optional[WebClient], channel: Optional[str], + self, + client: Optional[WebClient], + channel: Optional[str], ): self.client = client self.channel = channel diff --git a/slack_bolt/lazy_listener/async_runner.py b/slack_bolt/lazy_listener/async_runner.py index d640d48ec..da8712da9 100644 --- a/slack_bolt/lazy_listener/async_runner.py +++ b/slack_bolt/lazy_listener/async_runner.py @@ -31,6 +31,8 @@ async def run( :return: None """ func = to_runnable_function( - internal_func=function, logger=self.logger, request=request, + internal_func=function, + logger=self.logger, + request=request, ) return await func() # type: ignore diff --git a/slack_bolt/lazy_listener/asyncio_runner.py b/slack_bolt/lazy_listener/asyncio_runner.py index f9ceb6cf3..072f3ecc6 100644 --- a/slack_bolt/lazy_listener/asyncio_runner.py +++ b/slack_bolt/lazy_listener/asyncio_runner.py @@ -11,7 +11,8 @@ class AsyncioLazyListenerRunner(AsyncLazyListenerRunner): logger: Logger def __init__( - self, logger: Logger, + self, + logger: Logger, ): self.logger = logger @@ -20,6 +21,8 @@ def start( ) -> None: asyncio.ensure_future( to_runnable_function( - internal_func=function, logger=self.logger, request=request, + internal_func=function, + logger=self.logger, + request=request, ) ) diff --git a/slack_bolt/lazy_listener/internals.py b/slack_bolt/lazy_listener/internals.py index ee6659540..f2934ef69 100644 --- a/slack_bolt/lazy_listener/internals.py +++ b/slack_bolt/lazy_listener/internals.py @@ -8,7 +8,9 @@ def build_runnable_function( - func: Callable[..., None], logger: Logger, request: BoltRequest, + func: Callable[..., None], + logger: Logger, + request: BoltRequest, ) -> Callable[[], None]: arg_names = inspect.getfullargspec(func).args diff --git a/slack_bolt/lazy_listener/runner.py b/slack_bolt/lazy_listener/runner.py index 3442693ea..4fc8bb072 100644 --- a/slack_bolt/lazy_listener/runner.py +++ b/slack_bolt/lazy_listener/runner.py @@ -26,4 +26,8 @@ def run(self, function: Callable[..., None], request: BoltRequest) -> None: :param request: The request to pass to the function. The object must be thread-safe. :return: None """ - build_runnable_function(func=function, logger=self.logger, request=request,)() + build_runnable_function( + func=function, + logger=self.logger, + request=request, + )() diff --git a/slack_bolt/lazy_listener/thread_runner.py b/slack_bolt/lazy_listener/thread_runner.py index c6812a46a..d2e7a09a0 100644 --- a/slack_bolt/lazy_listener/thread_runner.py +++ b/slack_bolt/lazy_listener/thread_runner.py @@ -11,12 +11,18 @@ class ThreadLazyListenerRunner(LazyListenerRunner): logger: Logger def __init__( - self, logger: Logger, executor: ThreadPoolExecutor, + self, + logger: Logger, + executor: ThreadPoolExecutor, ): self.logger = logger self.executor = executor def start(self, function: Callable[..., None], request: BoltRequest) -> None: self.executor.submit( - build_runnable_function(func=function, logger=self.logger, request=request,) + build_runnable_function( + func=function, + logger=self.logger, + request=request, + ) ) diff --git a/slack_bolt/listener/async_listener.py b/slack_bolt/listener/async_listener.py index 7222b80a8..21fe7b033 100644 --- a/slack_bolt/listener/async_listener.py +++ b/slack_bolt/listener/async_listener.py @@ -16,7 +16,10 @@ class AsyncListener(metaclass=ABCMeta): auto_acknowledgement: bool async def async_matches( - self, *, req: AsyncBoltRequest, resp: BoltResponse, + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, ) -> bool: is_matched: bool = False for matcher in self.matchers: @@ -26,7 +29,10 @@ async def async_matches( return is_matched async def run_async_middleware( - self, *, req: AsyncBoltRequest, resp: BoltResponse, + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, ) -> Tuple[BoltResponse, bool]: """Runs an async middleware. @@ -100,7 +106,10 @@ def __init__( self.logger = get_bolt_app_logger(app_name, self.ack_function) async def run_ack_function( - self, *, request: AsyncBoltRequest, response: BoltResponse, + self, + *, + request: AsyncBoltRequest, + response: BoltResponse, ) -> Optional[BoltResponse]: return await self.ack_function( **build_async_required_kwargs( diff --git a/slack_bolt/listener/asyncio_runner.py b/slack_bolt/listener/asyncio_runner.py index dca9d5705..c9c1d49fa 100644 --- a/slack_bolt/listener/asyncio_runner.py +++ b/slack_bolt/listener/asyncio_runner.py @@ -62,7 +62,9 @@ async def run( response = BoltResponse(status=500) response.status = 500 await self.listener_error_handler.handle( - error=e, request=request, response=response, + error=e, + request=request, + response=response, ) ack.response = response @@ -95,7 +97,9 @@ async def run( # start the listener function asynchronously # NOTE: intentionally async def run_ack_function_asynchronously( - ack: AsyncAck, request: AsyncBoltRequest, response: BoltResponse, + ack: AsyncAck, + request: AsyncBoltRequest, + response: BoltResponse, ): try: await listener.run_ack_function( @@ -111,7 +115,9 @@ async def run_ack_function_asynchronously( response = None await self.listener_error_handler.handle( - error=e, request=request, response=response, + error=e, + request=request, + response=response, ) ack.response = response diff --git a/slack_bolt/listener/custom_listener.py b/slack_bolt/listener/custom_listener.py index 8e8a54e98..99f8c353f 100644 --- a/slack_bolt/listener/custom_listener.py +++ b/slack_bolt/listener/custom_listener.py @@ -41,7 +41,10 @@ def __init__( self.logger = get_bolt_app_logger(app_name, self.ack_function) def run_ack_function( - self, *, request: BoltRequest, response: BoltResponse, + self, + *, + request: BoltRequest, + response: BoltResponse, ) -> Optional[BoltResponse]: return self.ack_function( **build_required_kwargs( diff --git a/slack_bolt/listener/listener.py b/slack_bolt/listener/listener.py index 6f1d43c9a..748392f94 100644 --- a/slack_bolt/listener/listener.py +++ b/slack_bolt/listener/listener.py @@ -14,7 +14,12 @@ class Listener(metaclass=ABCMeta): lazy_functions: Sequence[Callable[..., None]] auto_acknowledgement: bool - def matches(self, *, req: BoltRequest, resp: BoltResponse,) -> bool: + def matches( + self, + *, + req: BoltRequest, + resp: BoltResponse, + ) -> bool: is_matched: bool = False for matcher in self.matchers: is_matched = matcher.matches(req, resp) @@ -23,7 +28,10 @@ def matches(self, *, req: BoltRequest, resp: BoltResponse,) -> bool: return is_matched def run_middleware( - self, *, req: BoltRequest, resp: BoltResponse, + self, + *, + req: BoltRequest, + resp: BoltResponse, ) -> Tuple[BoltResponse, bool]: """ diff --git a/slack_bolt/listener/listener_error_handler.py b/slack_bolt/listener/listener_error_handler.py index 5854fb356..0747bd368 100644 --- a/slack_bolt/listener/listener_error_handler.py +++ b/slack_bolt/listener/listener_error_handler.py @@ -21,7 +21,10 @@ class ListenerErrorHandler(metaclass=ABCMeta): @abstractmethod def handle( - self, error: Exception, request: BoltRequest, response: Optional[BoltResponse], + self, + error: Exception, + request: BoltRequest, + response: Optional[BoltResponse], ) -> None: """Handles an unhandled exception. @@ -40,7 +43,10 @@ def __init__(self, logger: Logger, func: Callable[..., None]): self.arg_names = inspect.getfullargspec(func).args def handle( - self, error: Exception, request: BoltRequest, response: Optional[BoltResponse], + self, + error: Exception, + request: BoltRequest, + response: Optional[BoltResponse], ): all_available_args = { "logger": self.logger, @@ -95,7 +101,10 @@ def __init__(self, logger: Logger): self.logger = logger def handle( - self, error: Exception, request: BoltRequest, response: Optional[BoltResponse], + self, + error: Exception, + request: BoltRequest, + response: Optional[BoltResponse], ): message = f"Failed to run listener function (error: {error})" self.logger.exception(message) diff --git a/slack_bolt/listener/thread_runner.py b/slack_bolt/listener/thread_runner.py index dfe2a51a3..28d4051da 100644 --- a/slack_bolt/listener/thread_runner.py +++ b/slack_bolt/listener/thread_runner.py @@ -63,7 +63,9 @@ def run( # type: ignore response = BoltResponse(status=500) response.status = 500 self.listener_error_handler.handle( - error=e, request=request, response=response, + error=e, + request=request, + response=response, ) ack.response = response @@ -103,7 +105,9 @@ def run_ack_function_asynchronously(): # You can customize this by passing your own error handler. if listener.auto_acknowledgement: self.listener_error_handler.handle( - error=e, request=request, response=response, + error=e, + request=request, + response=response, ) else: if response is None: @@ -112,7 +116,9 @@ def run_ack_function_asynchronously(): if ack.response is not None: # already acknowledged response = None self.listener_error_handler.handle( - error=e, request=request, response=response, + error=e, + request=request, + response=response, ) ack.response = response diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index fce9031b1..bcba2a043 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -55,7 +55,8 @@ def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: def build_listener_matcher( - func: Callable[..., bool], asyncio: bool, + func: Callable[..., bool], + asyncio: bool, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if asyncio: from .async_builtins import AsyncBuiltinListenerMatcher @@ -73,7 +74,8 @@ async def async_fun(body: Dict[str, Any]) -> bool: def event( - constraints: Union[str, Pattern, Dict[str, str]], asyncio: bool = False, + constraints: Union[str, Pattern, Dict[str, str]], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): event_type: Union[str, Pattern] = constraints @@ -110,7 +112,8 @@ def func(body: Dict[str, Any]) -> bool: def workflow_step_execute( - callback_id: Union[str, Pattern], asyncio: bool = False, + callback_id: Union[str, Pattern], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return ( @@ -128,7 +131,8 @@ def func(body: Dict[str, Any]) -> bool: def command( - command: Union[str, Pattern], asyncio: bool = False, + command: Union[str, Pattern], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_slash_command(body) and _matches(command, body["command"]) @@ -164,7 +168,8 @@ def func(body: Dict[str, Any]) -> bool: def global_shortcut( - callback_id: Union[str, Pattern], asyncio: bool = False, + callback_id: Union[str, Pattern], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_global_shortcut(body) and _matches(callback_id, body["callback_id"]) @@ -173,7 +178,8 @@ def func(body: Dict[str, Any]) -> bool: def message_shortcut( - callback_id: Union[str, Pattern], asyncio: bool = False, + callback_id: Union[str, Pattern], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_message_shortcut(body) and _matches(callback_id, body["callback_id"]) @@ -257,12 +263,16 @@ def func(body: Dict[str, Any]) -> bool: return build_listener_matcher(func, asyncio) -def _attachment_action(callback_id: Union[str, Pattern], body: Dict[str, Any],) -> bool: +def _attachment_action( + callback_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: return is_attachment_action(body) and _matches(callback_id, body["callback_id"]) def attachment_action( - callback_id: Union[str, Pattern], asyncio: bool = False, + callback_id: Union[str, Pattern], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _attachment_action(callback_id, body) @@ -270,12 +280,16 @@ def func(body: Dict[str, Any]) -> bool: return build_listener_matcher(func, asyncio) -def _dialog_submission(callback_id: Union[str, Pattern], body: Dict[str, Any],) -> bool: +def _dialog_submission( + callback_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: return is_dialog_submission(body) and _matches(callback_id, body["callback_id"]) def dialog_submission( - callback_id: Union[str, Pattern], asyncio: bool = False, + callback_id: Union[str, Pattern], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _dialog_submission(callback_id, body) @@ -284,13 +298,15 @@ def func(body: Dict[str, Any]) -> bool: def _dialog_cancellation( - callback_id: Union[str, Pattern], body: Dict[str, Any], + callback_id: Union[str, Pattern], + body: Dict[str, Any], ) -> bool: return is_dialog_cancellation(body) and _matches(callback_id, body["callback_id"]) def dialog_cancellation( - callback_id: Union[str, Pattern], asyncio: bool = False, + callback_id: Union[str, Pattern], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _dialog_cancellation(callback_id, body) @@ -299,13 +315,15 @@ def func(body: Dict[str, Any]) -> bool: def _workflow_step_edit( - callback_id: Union[str, Pattern], body: Dict[str, Any], + callback_id: Union[str, Pattern], + body: Dict[str, Any], ) -> bool: return is_workflow_step_edit(body) and _matches(callback_id, body["callback_id"]) def workflow_step_edit( - callback_id: Union[str, Pattern], asyncio: bool = False, + callback_id: Union[str, Pattern], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _workflow_step_edit(callback_id, body) @@ -335,7 +353,8 @@ def view( def view_submission( - callback_id: Union[str, Pattern], asyncio: bool = False, + callback_id: Union[str, Pattern], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_view_submission(body) and _matches( @@ -346,7 +365,8 @@ def func(body: Dict[str, Any]) -> bool: def view_closed( - callback_id: Union[str, Pattern], asyncio: bool = False, + callback_id: Union[str, Pattern], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_view_closed(body) and _matches( @@ -357,7 +377,8 @@ def func(body: Dict[str, Any]) -> bool: def workflow_step_save( - callback_id: Union[str, Pattern], asyncio: bool = False, + callback_id: Union[str, Pattern], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_workflow_step_save(body) and _matches( @@ -394,12 +415,16 @@ def func(body: Dict[str, Any]) -> bool: ) -def _block_suggestion(action_id: Union[str, Pattern], body: Dict[str, Any],) -> bool: +def _block_suggestion( + action_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: return is_block_suggestion(body) and _matches(action_id, body["action_id"]) def block_suggestion( - action_id: Union[str, Pattern], asyncio: bool = False, + action_id: Union[str, Pattern], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _block_suggestion(action_id, body) @@ -407,12 +432,16 @@ def func(body: Dict[str, Any]) -> bool: return build_listener_matcher(func, asyncio) -def _dialog_suggestion(callback_id: Union[str, Pattern], body: Dict[str, Any],) -> bool: +def _dialog_suggestion( + callback_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: return is_dialog_suggestion(body) and _matches(callback_id, body["callback_id"]) def dialog_suggestion( - callback_id: Union[str, Pattern], asyncio: bool = False, + callback_id: Union[str, Pattern], + asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _dialog_suggestion(callback_id, body) diff --git a/slack_bolt/middleware/authorization/async_internals.py b/slack_bolt/middleware/authorization/async_internals.py index 6a9d1b696..462e39836 100644 --- a/slack_bolt/middleware/authorization/async_internals.py +++ b/slack_bolt/middleware/authorization/async_internals.py @@ -23,5 +23,6 @@ def _is_no_auth_required(req: AsyncBoltRequest) -> bool: def _build_error_response() -> BoltResponse: # show an ephemeral message to the end-user return BoltResponse( - status=200, body=":x: Please install this app into the workspace :bow:", + status=200, + body=":x: Please install this app into the workspace :bow:", ) diff --git a/slack_bolt/middleware/authorization/internals.py b/slack_bolt/middleware/authorization/internals.py index cede00c42..42e958378 100644 --- a/slack_bolt/middleware/authorization/internals.py +++ b/slack_bolt/middleware/authorization/internals.py @@ -52,7 +52,8 @@ def _is_no_auth_test_call_required(req: Union[BoltRequest, "AsyncBoltRequest"]) def _build_error_response() -> BoltResponse: # show an ephemeral message to the end-user return BoltResponse( - status=200, body=":x: Please install this app into the workspace :bow:", + status=200, + body=":x: Please install this app into the workspace :bow:", ) diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index 5a130d623..20b31836a 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -19,7 +19,9 @@ class MultiTeamsAuthorization(Authorization): authorize: Authorize def __init__( - self, *, authorize: Authorize, + self, + *, + authorize: Authorize, ): """Multi-workspace authorization. @@ -29,7 +31,11 @@ def __init__( self.logger = get_bolt_logger(MultiTeamsAuthorization) def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + self, + *, + req: BoltRequest, + resp: BoltResponse, + next: Callable[[], BoltResponse], ) -> BoltResponse: if _is_no_auth_required(req): return next() diff --git a/slack_bolt/middleware/authorization/single_team_authorization.py b/slack_bolt/middleware/authorization/single_team_authorization.py index e09d399ab..9ca5b0541 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -25,7 +25,11 @@ def __init__(self, *, auth_test_result: Optional[SlackResponse] = None): self.logger = get_bolt_logger(SingleTeamAuthorization) def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + self, + *, + req: BoltRequest, + resp: BoltResponse, + next: Callable[[], BoltResponse], ) -> BoltResponse: if _is_no_auth_required(req): return next() diff --git a/slack_bolt/middleware/custom_middleware.py b/slack_bolt/middleware/custom_middleware.py index 61b71bac2..016b42ee9 100644 --- a/slack_bolt/middleware/custom_middleware.py +++ b/slack_bolt/middleware/custom_middleware.py @@ -22,7 +22,11 @@ def __init__(self, *, app_name: str, func: Callable): self.logger = get_bolt_app_logger(self.app_name, self.func) def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + self, + *, + req: BoltRequest, + resp: BoltResponse, + next: Callable[[], BoltResponse], ) -> BoltResponse: return self.func( **build_required_kwargs( diff --git a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py index dd9eb93a4..06642b301 100644 --- a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -14,7 +14,11 @@ def __init__(self): self.logger = get_bolt_logger(IgnoringSelfEvents) def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + self, + *, + req: BoltRequest, + resp: BoltResponse, + next: Callable[[], BoltResponse], ) -> BoltResponse: auth_result = req.context.authorize_result if self._is_self_event(auth_result, req.context.user_id, req.body): diff --git a/slack_bolt/middleware/message_listener_matches/message_listener_matches.py b/slack_bolt/middleware/message_listener_matches/message_listener_matches.py index 834ab7b4b..41905b381 100644 --- a/slack_bolt/middleware/message_listener_matches/message_listener_matches.py +++ b/slack_bolt/middleware/message_listener_matches/message_listener_matches.py @@ -12,7 +12,11 @@ def __init__(self, keyword: Union[str, Pattern]): self.keyword = keyword def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + self, + *, + req: BoltRequest, + resp: BoltResponse, + next: Callable[[], BoltResponse], ) -> BoltResponse: text = req.body.get("event", {}).get("text", "") if text: diff --git a/slack_bolt/middleware/middleware.py b/slack_bolt/middleware/middleware.py index 8ecc20ce0..d8c5c4df6 100644 --- a/slack_bolt/middleware/middleware.py +++ b/slack_bolt/middleware/middleware.py @@ -8,7 +8,11 @@ class Middleware(metaclass=ABCMeta): @abstractmethod def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + self, + *, + req: BoltRequest, + resp: BoltResponse, + next: Callable[[], BoltResponse], ) -> BoltResponse: raise NotImplementedError() diff --git a/slack_bolt/middleware/request_verification/request_verification.py b/slack_bolt/middleware/request_verification/request_verification.py index 538441070..4bca8b643 100644 --- a/slack_bolt/middleware/request_verification/request_verification.py +++ b/slack_bolt/middleware/request_verification/request_verification.py @@ -19,7 +19,11 @@ def __init__(self, signing_secret: str): self.logger = get_bolt_logger(RequestVerification) def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + self, + *, + req: BoltRequest, + resp: BoltResponse, + next: Callable[[], BoltResponse], ) -> BoltResponse: if self._can_skip(req.mode, req.body): return next() diff --git a/slack_bolt/middleware/ssl_check/ssl_check.py b/slack_bolt/middleware/ssl_check/ssl_check.py index 43d4eca5d..3b4849ed6 100644 --- a/slack_bolt/middleware/ssl_check/ssl_check.py +++ b/slack_bolt/middleware/ssl_check/ssl_check.py @@ -18,7 +18,11 @@ def __init__(self, verification_token: str = None): self.logger = get_bolt_logger(SslCheck) def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + self, + *, + req: BoltRequest, + resp: BoltResponse, + next: Callable[[], BoltResponse], ) -> BoltResponse: if self._is_ssl_check_request(req.body): if self._verify_token_if_needed(req.body): diff --git a/slack_bolt/middleware/url_verification/url_verification.py b/slack_bolt/middleware/url_verification/url_verification.py index 04e95d2fd..1d1f7b2b4 100644 --- a/slack_bolt/middleware/url_verification/url_verification.py +++ b/slack_bolt/middleware/url_verification/url_verification.py @@ -15,7 +15,11 @@ def __init__(self): self.logger = get_bolt_logger(UrlVerification) def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + self, + *, + req: BoltRequest, + resp: BoltResponse, + next: Callable[[], BoltResponse], ) -> BoltResponse: if self._is_url_verification_request(req.body): return self._build_success_response(req.body) diff --git a/slack_bolt/oauth/async_callback_options.py b/slack_bolt/oauth/async_callback_options.py index 24c453b53..1ba66dccb 100644 --- a/slack_bolt/oauth/async_callback_options.py +++ b/slack_bolt/oauth/async_callback_options.py @@ -98,10 +98,13 @@ def __init__( async def _success_handler(self, args: AsyncSuccessArgs) -> BoltResponse: return self._response_builder._build_callback_success_response( - request=args.request, installation=args.installation, + request=args.request, + installation=args.installation, ) async def _failure_handler(self, args: AsyncFailureArgs) -> BoltResponse: return self._response_builder._build_callback_failure_response( - request=args.request, reason=args.reason, status=args.suggested_status_code, + request=args.request, + reason=args.reason, + status=args.suggested_status_code, ) diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index d860d8dc7..233c7d149 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -139,7 +139,9 @@ def sqlite3( failure_url=failure_url, # Installation Management installation_store=SQLite3InstallationStore( - database=database, client_id=client_id, logger=logger, + database=database, + client_id=client_id, + logger=logger, ), installation_store_bot_only=installation_store_bot_only, # state parameter related configurations @@ -292,14 +294,14 @@ async def run_installation(self, code: str) -> Optional[Installation]: client_secret=self.settings.client_secret, redirect_uri=self.settings.redirect_uri, # can be None ) - installed_enterprise: Dict[str, str] = oauth_response.get( - "enterprise" - ) or {} + installed_enterprise: Dict[str, str] = ( + oauth_response.get("enterprise") or {} + ) installed_team: Dict[str, str] = oauth_response.get("team") or {} installer: Dict[str, str] = oauth_response.get("authed_user") or {} - incoming_webhook: Dict[str, str] = oauth_response.get( - "incoming_webhook" - ) or {} + incoming_webhook: Dict[str, str] = ( + oauth_response.get("incoming_webhook") or {} + ) bot_token: Optional[str] = oauth_response.get("access_token") # NOTE: oauth.v2.access doesn't include bot_id in response diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index 8e36baf2d..fec1bef80 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -140,7 +140,8 @@ def __init__( ) # state parameter related configurations self.state_store = state_store or FileOAuthStateStore( - expiration_seconds=state_expiration_seconds, client_id=client_id, + expiration_seconds=state_expiration_seconds, + client_id=client_id, ) self.state_cookie_name = state_cookie_name self.state_expiration_seconds = state_expiration_seconds diff --git a/slack_bolt/oauth/callback_options.py b/slack_bolt/oauth/callback_options.py index 52a87659c..a8efa8a69 100644 --- a/slack_bolt/oauth/callback_options.py +++ b/slack_bolt/oauth/callback_options.py @@ -103,10 +103,13 @@ def __init__( def _success_handler(self, args: SuccessArgs) -> BoltResponse: return self._response_builder._build_callback_success_response( - request=args.request, installation=args.installation, + request=args.request, + installation=args.installation, ) def _failure_handler(self, args: FailureArgs) -> BoltResponse: return self._response_builder._build_callback_failure_response( - request=args.request, reason=args.reason, status=args.suggested_status_code, + request=args.request, + reason=args.reason, + status=args.suggested_status_code, ) diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index d68b9d73c..3182c255b 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -134,7 +134,9 @@ def sqlite3( authorization_url=authorization_url, # Installation Management installation_store=SQLite3InstallationStore( - database=database, client_id=client_id, logger=logger, + database=database, + client_id=client_id, + logger=logger, ), installation_store_bot_only=installation_store_bot_only, # state parameter related configurations @@ -287,17 +289,17 @@ def run_installation(self, code: str) -> Optional[Installation]: client_secret=self.settings.client_secret, redirect_uri=self.settings.redirect_uri, # can be None ) - installed_enterprise: Dict[str, str] = oauth_response.get( - "enterprise" - ) or {} - is_enterprise_install: bool = oauth_response.get( - "is_enterprise_install" - ) or False + installed_enterprise: Dict[str, str] = ( + oauth_response.get("enterprise") or {} + ) + is_enterprise_install: bool = ( + oauth_response.get("is_enterprise_install") or False + ) installed_team: Dict[str, str] = oauth_response.get("team") or {} installer: Dict[str, str] = oauth_response.get("authed_user") or {} - incoming_webhook: Dict[str, str] = oauth_response.get( - "incoming_webhook" - ) or {} + incoming_webhook: Dict[str, str] = ( + oauth_response.get("incoming_webhook") or {} + ) bot_token: Optional[str] = oauth_response.get("access_token") # NOTE: oauth.v2.access doesn't include bot_id in response diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index 66ff03260..6010e5c26 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -134,7 +134,8 @@ def __init__( ) # state parameter related configurations self.state_store = state_store or FileOAuthStateStore( - expiration_seconds=state_expiration_seconds, client_id=client_id, + expiration_seconds=state_expiration_seconds, + client_id=client_id, ) self.state_cookie_name = state_cookie_name self.state_expiration_seconds = state_expiration_seconds diff --git a/slack_bolt/request/async_internals.py b/slack_bolt/request/async_internals.py index c87707ac1..19c20dd02 100644 --- a/slack_bolt/request/async_internals.py +++ b/slack_bolt/request/async_internals.py @@ -10,7 +10,8 @@ def build_async_context( - context: AsyncBoltContext, payload: Dict[str, Any], + context: AsyncBoltContext, + payload: Dict[str, Any], ) -> AsyncBoltContext: enterprise_id = extract_enterprise_id(payload) if enterprise_id: diff --git a/slack_bolt/util/async_utils.py b/slack_bolt/util/async_utils.py index 3713f83e6..60bbe0903 100644 --- a/slack_bolt/util/async_utils.py +++ b/slack_bolt/util/async_utils.py @@ -6,4 +6,7 @@ def create_async_web_client(token: Optional[str] = None) -> AsyncWebClient: - return AsyncWebClient(token=token, user_agent_prefix=f"Bolt-Async/{bolt_version}",) + return AsyncWebClient( + token=token, + user_agent_prefix=f"Bolt-Async/{bolt_version}", + ) diff --git a/slack_bolt/util/utils.py b/slack_bolt/util/utils.py index 4cdc39190..e52a53674 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -10,7 +10,10 @@ def create_web_client(token: Optional[str] = None) -> WebClient: - return WebClient(token=token, user_agent_prefix=f"Bolt/{bolt_version}",) + return WebClient( + token=token, + user_agent_prefix=f"Bolt/{bolt_version}", + ) def convert_to_dict_list(objects: Sequence[Union[Dict, JsonObject]]) -> Sequence[Dict]: diff --git a/slack_bolt/workflows/step/async_step.py b/slack_bolt/workflows/step/async_step.py index 3044ca513..a8b4573d7 100644 --- a/slack_bolt/workflows/step/async_step.py +++ b/slack_bolt/workflows/step/async_step.py @@ -47,7 +47,11 @@ def __init__( @classmethod def _build_listener( - cls, callback_id: str, app_name: str, listener: AsyncListener, name: str, + cls, + callback_id: str, + app_name: str, + listener: AsyncListener, + name: str, ) -> AsyncListener: if isinstance(listener, AsyncListener): return listener @@ -114,7 +118,9 @@ async def edit_listener_middleware( next: Callable[[], Awaitable[BoltResponse]], ): context["configure"] = AsyncConfigure( - callback_id=callback_id, client=client, body=body, + callback_id=callback_id, + client=client, + body=body, ) return await next() @@ -133,7 +139,10 @@ async def save_listener_middleware( body: dict, next: Callable[[], Awaitable[BoltResponse]], ): - context["update"] = AsyncUpdate(client=client, body=body,) + context["update"] = AsyncUpdate( + client=client, + body=body, + ) return await next() return AsyncCustomMiddleware(app_name=__name__, func=save_listener_middleware) @@ -151,8 +160,14 @@ async def execute_listener_middleware( body: dict, next: Callable[[], Awaitable[BoltResponse]], ): - context["complete"] = AsyncComplete(client=client, body=body,) - context["fail"] = AsyncFail(client=client, body=body,) + context["complete"] = AsyncComplete( + client=client, + body=body, + ) + context["fail"] = AsyncFail( + client=client, + body=body, + ) return await next() return AsyncCustomMiddleware(app_name=__name__, func=execute_listener_middleware) diff --git a/slack_bolt/workflows/step/async_step_middleware.py b/slack_bolt/workflows/step/async_step_middleware.py index cac50d3b0..c1583aa2f 100644 --- a/slack_bolt/workflows/step/async_step_middleware.py +++ b/slack_bolt/workflows/step/async_step_middleware.py @@ -37,7 +37,10 @@ async def async_process( return await next() async def _run( - self, listener: AsyncListener, req: AsyncBoltRequest, resp: BoltResponse, + self, + listener: AsyncListener, + req: AsyncBoltRequest, + resp: BoltResponse, ) -> Optional[BoltResponse]: resp, next_was_not_called = await listener.run_async_middleware( req=req, resp=resp diff --git a/slack_bolt/workflows/step/step.py b/slack_bolt/workflows/step/step.py index d9e562885..0e1954294 100644 --- a/slack_bolt/workflows/step/step.py +++ b/slack_bolt/workflows/step/step.py @@ -115,7 +115,9 @@ def edit_listener_middleware( next: Callable[[], BoltResponse], ): context["configure"] = Configure( - callback_id=callback_id, client=client, body=body, + callback_id=callback_id, + client=client, + body=body, ) return next() @@ -134,7 +136,10 @@ def save_listener_middleware( body: dict, next: Callable[[], BoltResponse], ): - context["update"] = Update(client=client, body=body,) + context["update"] = Update( + client=client, + body=body, + ) return next() return CustomMiddleware(app_name=__name__, func=save_listener_middleware) @@ -152,8 +157,14 @@ def execute_listener_middleware( body: dict, next: Callable[[], BoltResponse], ): - context["complete"] = Complete(client=client, body=body,) - context["fail"] = Fail(client=client, body=body,) + context["complete"] = Complete( + client=client, + body=body, + ) + context["fail"] = Fail( + client=client, + body=body, + ) return next() return CustomMiddleware(app_name=__name__, func=execute_listener_middleware) diff --git a/slack_bolt/workflows/step/step_middleware.py b/slack_bolt/workflows/step/step_middleware.py index 2cbaac772..6c86b26fc 100644 --- a/slack_bolt/workflows/step/step_middleware.py +++ b/slack_bolt/workflows/step/step_middleware.py @@ -14,7 +14,11 @@ def __init__(self, step: WorkflowStep, listener_runner: ThreadListenerRunner): self.listener_runner = listener_runner def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + self, + *, + req: BoltRequest, + resp: BoltResponse, + next: Callable[[], BoltResponse], ) -> BoltResponse: if self.step.edit.matches(req=req, resp=resp): @@ -33,7 +37,10 @@ def process( return next() def _run( - self, listener: Listener, req: BoltRequest, resp: BoltResponse, + self, + listener: Listener, + req: BoltRequest, + resp: BoltResponse, ) -> Optional[BoltResponse]: resp, next_was_not_called = listener.run_middleware(req=req, resp=resp) if next_was_not_called: diff --git a/slack_bolt/workflows/step/utilities/async_configure.py b/slack_bolt/workflows/step/utilities/async_configure.py index f6a73dfe4..9914f3dc7 100644 --- a/slack_bolt/workflows/step/utilities/async_configure.py +++ b/slack_bolt/workflows/step/utilities/async_configure.py @@ -11,7 +11,9 @@ def __init__(self, *, callback_id: str, client: AsyncWebClient, body: dict): self.body = body async def __call__( - self, *, blocks: Optional[Sequence[Union[dict, Block]]] = None, + self, + *, + blocks: Optional[Sequence[Union[dict, Block]]] = None, ) -> None: await self.client.views_open( trigger_id=self.body["trigger_id"], diff --git a/slack_bolt/workflows/step/utilities/async_fail.py b/slack_bolt/workflows/step/utilities/async_fail.py index f2c6b1de4..e1d277ff4 100644 --- a/slack_bolt/workflows/step/utilities/async_fail.py +++ b/slack_bolt/workflows/step/utilities/async_fail.py @@ -6,7 +6,11 @@ def __init__(self, *, client: AsyncWebClient, body: dict): self.client = client self.body = body - async def __call__(self, *, error: dict,) -> None: + async def __call__( + self, + *, + error: dict, + ) -> None: await self.client.workflows_stepFailed( workflow_step_execute_id=self.body["event"]["workflow_step"][ "workflow_step_execute_id" diff --git a/slack_bolt/workflows/step/utilities/fail.py b/slack_bolt/workflows/step/utilities/fail.py index 6b82dc272..b2c3c4239 100644 --- a/slack_bolt/workflows/step/utilities/fail.py +++ b/slack_bolt/workflows/step/utilities/fail.py @@ -6,7 +6,11 @@ def __init__(self, *, client: WebClient, body: dict): self.client = client self.body = body - def __call__(self, *, error: dict,) -> None: + def __call__( + self, + *, + error: dict, + ) -> None: self.client.workflows_stepFailed( workflow_step_execute_id=self.body["event"]["workflow_step"][ "workflow_step_execute_id" diff --git a/tests/adapter_tests/test_aws_chalice.py b/tests/adapter_tests/test_aws_chalice.py index a9f4912f7..ee80f62dd 100644 --- a/tests/adapter_tests/test_aws_chalice.py +++ b/tests/adapter_tests/test_aws_chalice.py @@ -40,7 +40,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -60,7 +61,10 @@ def test_not_found(self): assert response.status_code == 404 def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) @app.event("app_mention") def event_handler(): @@ -109,7 +113,10 @@ def events() -> Response: assert self.mock_received_requests["/auth.test"] == 1 def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) @app.shortcut("test-shortcut") def shortcut_handler(ack): @@ -153,7 +160,10 @@ def events() -> Response: assert self.mock_received_requests["/auth.test"] == 1 def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) @app.command("/hello-world") def command_handler(ack): @@ -197,7 +207,10 @@ def events() -> Response: assert self.mock_received_requests["/auth.test"] == 1 def test_lazy_listeners(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() diff --git a/tests/adapter_tests/test_aws_lambda.py b/tests/adapter_tests/test_aws_lambda.py index d2e638efd..0c05e07d6 100644 --- a/tests/adapter_tests/test_aws_lambda.py +++ b/tests/adapter_tests/test_aws_lambda.py @@ -30,7 +30,10 @@ class TestAWSLambda: 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,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) context = LambdaContext(function_name="test-function") @@ -44,7 +47,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -70,13 +74,19 @@ def test_first_value(self): @mock_lambda def test_clear_all_log_handlers(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) handler = SlackRequestHandler(app) handler.clear_all_log_handlers() @mock_lambda def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -128,7 +138,10 @@ def event_handler(): @mock_lambda def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -175,7 +188,10 @@ def shortcut_handler(ack): @mock_lambda def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() @@ -222,7 +238,10 @@ def command_handler(ack): @mock_lambda def test_lazy_listeners(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() diff --git a/tests/adapter_tests/test_bottle.py b/tests/adapter_tests/test_bottle.py index 7cb8dffa0..2cfd5a7fb 100644 --- a/tests/adapter_tests/test_bottle.py +++ b/tests/adapter_tests/test_bottle.py @@ -48,8 +48,14 @@ def setup_method(self): self.old_os_env = remove_os_env_temporarily() setup_mock_web_api_server(self) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) - app = App(client=web_client, signing_secret=signing_secret,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + app = App( + client=web_client, + signing_secret=signing_secret, + ) TestBottle.handler = SlackRequestHandler(app) app.event("app_mention")(event_handler) app.shortcut("test-shortcut")(shortcut_handler) @@ -61,7 +67,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/adapter_tests/test_bottle_oauth.py b/tests/adapter_tests/test_bottle_oauth.py index 37fd6b25d..52ff7a2ae 100644 --- a/tests/adapter_tests/test_bottle_oauth.py +++ b/tests/adapter_tests/test_bottle_oauth.py @@ -6,7 +6,9 @@ app = App( signing_secret=signing_secret, oauth_settings=OAuthSettings( - client_id="111.111", client_secret="xxx", scopes=["chat:write", "commands"], + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], ), ) handler = SlackRequestHandler(app) diff --git a/tests/adapter_tests/test_cherrypy.py b/tests/adapter_tests/test_cherrypy.py index fec885343..f21845911 100644 --- a/tests/adapter_tests/test_cherrypy.py +++ b/tests/adapter_tests/test_cherrypy.py @@ -29,8 +29,14 @@ def setup_server(cls): 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,) - app = App(client=web_client, signing_secret=signing_secret,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + app = App( + client=web_client, + signing_secret=signing_secret, + ) def event_handler(): pass @@ -63,7 +69,8 @@ def teardown_class(cls): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/adapter_tests/test_cherrypy_oauth.py b/tests/adapter_tests/test_cherrypy_oauth.py index ce8524bc8..80053331f 100644 --- a/tests/adapter_tests/test_cherrypy_oauth.py +++ b/tests/adapter_tests/test_cherrypy_oauth.py @@ -21,7 +21,10 @@ def setup_server(cls): 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,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) app = App( client=web_client, signing_secret=signing_secret, diff --git a/tests/adapter_tests/test_django.py b/tests/adapter_tests/test_django.py index 21836fc50..d1510594f 100644 --- a/tests/adapter_tests/test_django.py +++ b/tests/adapter_tests/test_django.py @@ -23,7 +23,10 @@ class TestDjango(TestCase): 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,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) os.environ["DJANGO_SETTINGS_MODULE"] = "tests.adapter_tests.test_django_settings" rf = RequestFactory() @@ -38,7 +41,8 @@ def tearDown(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -48,7 +52,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -87,7 +94,10 @@ def event_handler(): assert self.mock_received_requests["/auth.test"] == 1 def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -123,7 +133,10 @@ def shortcut_handler(ack): assert self.mock_received_requests["/auth.test"] == 1 def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() diff --git a/tests/adapter_tests/test_django_settings.py b/tests/adapter_tests/test_django_settings.py index 7e5f57df3..8811343fe 100644 --- a/tests/adapter_tests/test_django_settings.py +++ b/tests/adapter_tests/test_django_settings.py @@ -1,4 +1,7 @@ SECRET_KEY = "XXX" DATABASES = { - "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "logs/db.sqlite3",} + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "logs/db.sqlite3", + } } diff --git a/tests/adapter_tests/test_falcon.py b/tests/adapter_tests/test_falcon.py index e18374473..263a67275 100644 --- a/tests/adapter_tests/test_falcon.py +++ b/tests/adapter_tests/test_falcon.py @@ -22,7 +22,10 @@ class TestFalcon: 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,) + 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() @@ -34,7 +37,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -50,7 +54,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -85,13 +92,18 @@ def event_handler(): client = testing.TestClient(api) response = client.simulate_post( - "/slack/events", body=body, headers=self.build_headers(timestamp, body), + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -121,13 +133,18 @@ def shortcut_handler(ack): client = testing.TestClient(api) response = client.simulate_post( - "/slack/events", body=body, headers=self.build_headers(timestamp, body), + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() @@ -157,7 +174,9 @@ def command_handler(ack): client = testing.TestClient(api) response = client.simulate_post( - "/slack/events", body=body, headers=self.build_headers(timestamp, body), + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 diff --git a/tests/adapter_tests/test_fastapi.py b/tests/adapter_tests/test_fastapi.py index 2de6ef069..3e94542d0 100644 --- a/tests/adapter_tests/test_fastapi.py +++ b/tests/adapter_tests/test_fastapi.py @@ -23,7 +23,10 @@ class TestFastAPI: 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,) + 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() @@ -35,7 +38,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -51,7 +55,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -89,13 +96,18 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -128,13 +140,18 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() @@ -167,7 +184,9 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 diff --git a/tests/adapter_tests/test_flask.py b/tests/adapter_tests/test_flask.py index 30d58fc35..c6f320f6d 100644 --- a/tests/adapter_tests/test_flask.py +++ b/tests/adapter_tests/test_flask.py @@ -21,7 +21,10 @@ class TestFlask: 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,) + 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() @@ -33,7 +36,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -49,7 +53,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -86,13 +93,18 @@ def endpoint(): with flask_app.test_client() as client: rv = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert rv.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -124,13 +136,18 @@ def endpoint(): with flask_app.test_client() as client: rv = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert rv.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() @@ -162,7 +179,9 @@ def endpoint(): with flask_app.test_client() as client: rv = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert rv.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 diff --git a/tests/adapter_tests/test_lambda_s3_oauth_flow.py b/tests/adapter_tests/test_lambda_s3_oauth_flow.py index 4e3644b64..0756bc06f 100644 --- a/tests/adapter_tests/test_lambda_s3_oauth_flow.py +++ b/tests/adapter_tests/test_lambda_s3_oauth_flow.py @@ -16,7 +16,9 @@ def teardown_method(self): def test_instantiation(self): oauth_flow = LambdaS3OAuthFlow( settings=OAuthSettings( - client_id="111.222", client_secret="xxx", scopes=["chat:write"], + client_id="111.222", + client_secret="xxx", + scopes=["chat:write"], ), installation_bucket_name="dummy-installation", oauth_state_bucket_name="dummy-state", diff --git a/tests/adapter_tests/test_pyramid.py b/tests/adapter_tests/test_pyramid.py index 5c5d78cd8..bc88b37d0 100644 --- a/tests/adapter_tests/test_pyramid.py +++ b/tests/adapter_tests/test_pyramid.py @@ -24,7 +24,10 @@ class TestPyramid(TestCase): 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,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setUp(self): self.config = testing.setUp() @@ -38,7 +41,8 @@ def tearDown(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -54,7 +58,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -93,7 +100,10 @@ def event_handler(): assert self.mock_received_requests["/auth.test"] == 1 def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -127,7 +137,10 @@ def shortcut_handler(ack): assert self.mock_received_requests["/auth.test"] == 1 def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() diff --git a/tests/adapter_tests/test_starlette.py b/tests/adapter_tests/test_starlette.py index 9ab513d26..4dc4059f9 100644 --- a/tests/adapter_tests/test_starlette.py +++ b/tests/adapter_tests/test_starlette.py @@ -24,7 +24,10 @@ class TestStarlette: 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,) + 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() @@ -36,7 +39,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -52,7 +56,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -92,13 +99,18 @@ async def endpoint(req: Request): ) client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -133,13 +145,18 @@ async def endpoint(req: Request): ) client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() @@ -174,7 +191,9 @@ async def endpoint(req: Request): ) client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 diff --git a/tests/adapter_tests/test_tornado.py b/tests/adapter_tests/test_tornado.py index 468690fb6..87c4ccc09 100644 --- a/tests/adapter_tests/test_tornado.py +++ b/tests/adapter_tests/test_tornado.py @@ -41,8 +41,14 @@ def setUp(self): self.old_os_env = remove_os_env_temporarily() setup_mock_web_api_server(self) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) - self.app = App(client=web_client, signing_secret=signing_secret,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + self.app = App( + client=web_client, + signing_secret=signing_secret, + ) self.app.event("app_mention")(event_handler) self.app.shortcut("test-shortcut")(shortcut_handler) self.app.command("/hello-world")(command_handler) @@ -59,7 +65,8 @@ def get_app(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/adapter_tests/test_tornado_oauth.py b/tests/adapter_tests/test_tornado_oauth.py index c42468f2a..ae98a6ac6 100644 --- a/tests/adapter_tests/test_tornado_oauth.py +++ b/tests/adapter_tests/test_tornado_oauth.py @@ -13,7 +13,9 @@ app = App( signing_secret=signing_secret, oauth_settings=OAuthSettings( - client_id="111.111", client_secret="xxx", scopes=["chat:write", "commands"], + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], ), ) diff --git a/tests/adapter_tests_async/test_async_fastapi.py b/tests/adapter_tests_async/test_async_fastapi.py index 41b8a332e..f9c2c842b 100644 --- a/tests/adapter_tests_async/test_async_fastapi.py +++ b/tests/adapter_tests_async/test_async_fastapi.py @@ -23,7 +23,10 @@ class TestFastAPI: 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,) + 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() @@ -35,7 +38,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -51,7 +55,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def event_handler(): pass @@ -89,13 +96,18 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 def test_shortcuts(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def shortcut_handler(ack): await ack() @@ -128,13 +140,18 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 def test_commands(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def command_handler(ack): await ack() @@ -167,7 +184,9 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py index 587f858b6..38d91d95a 100644 --- a/tests/adapter_tests_async/test_async_sanic.py +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -24,7 +24,10 @@ class TestSanic: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -40,7 +43,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -57,7 +61,10 @@ def build_headers(self, timestamp: str, body: str): @pytest.mark.asyncio async def test_events(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def event_handler(): pass @@ -94,14 +101,19 @@ async def endpoint(req: Request): return await app_handler.handle(req) _, response = await api.asgi_client.post( - url="/slack/events", data=body, headers=self.build_headers(timestamp, body), + url="/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_shortcuts(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def shortcut_handler(ack): await ack() @@ -133,14 +145,19 @@ async def endpoint(req: Request): return await app_handler.handle(req) _, response = await api.asgi_client.post( - url="/slack/events", data=body, headers=self.build_headers(timestamp, body), + url="/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_commands(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def command_handler(ack): await ack() @@ -172,7 +189,9 @@ async def endpoint(req: Request): return await app_handler.handle(req) _, response = await api.asgi_client.post( - url="/slack/events", data=body, headers=self.build_headers(timestamp, body), + url="/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 diff --git a/tests/adapter_tests_async/test_async_starlette.py b/tests/adapter_tests_async/test_async_starlette.py index 17c81715f..3faba8b12 100644 --- a/tests/adapter_tests_async/test_async_starlette.py +++ b/tests/adapter_tests_async/test_async_starlette.py @@ -24,7 +24,10 @@ class TestAsyncStarlette: 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,) + 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() @@ -36,7 +39,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -52,7 +56,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def event_handler(): pass @@ -92,13 +99,18 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 def test_shortcuts(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def shortcut_handler(ack): await ack() @@ -133,13 +145,18 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 def test_commands(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def command_handler(ack): await ack() @@ -174,7 +191,9 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 assert self.mock_received_requests["/auth.test"] == 1 diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index af2e98c34..5d91dd577 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -19,7 +19,10 @@ class TestApp: 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,) + 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() @@ -152,7 +155,9 @@ def test_installation_store_conflicts(self): app = App( signing_secret="valid", oauth_settings=OAuthSettings( - client_id="111.222", client_secret="valid", installation_store=store1, + client_id="111.222", + client_secret="valid", + installation_store=store1, ), installation_store=store2, ) @@ -174,7 +179,10 @@ def test_installation_store_conflicts(self): app = App( signing_secret="valid", oauth_flow=OAuthFlow( - settings=OAuthSettings(client_id="111.222", client_secret="valid",) + settings=OAuthSettings( + client_id="111.222", + client_secret="valid", + ) ), installation_store=store1, ) diff --git a/tests/scenario_tests/test_app_bot_only.py b/tests/scenario_tests/test_app_bot_only.py index c6cfca323..53da026f9 100644 --- a/tests/scenario_tests/test_app_bot_only.py +++ b/tests/scenario_tests/test_app_bot_only.py @@ -88,7 +88,10 @@ class TestApp: 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,) + 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() @@ -100,7 +103,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/scenario_tests/test_attachment_actions.py b/tests/scenario_tests/test_attachment_actions.py index 3596dbcbd..c520a11ad 100644 --- a/tests/scenario_tests/test_attachment_actions.py +++ b/tests/scenario_tests/test_attachment_actions.py @@ -19,7 +19,10 @@ class TestAttachmentActions: 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,) + 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() @@ -31,7 +34,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -52,7 +56,10 @@ def test_mock_server_is_running(self): assert resp != None def test_success_without_type(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("pick_channel_for_fun")(simple_listener) request = self.build_valid_request() @@ -61,9 +68,15 @@ def test_success_without_type(self): assert self.mock_received_requests["/auth.test"] == 1 def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action( - {"callback_id": "pick_channel_for_fun", "type": "interactive_message",} + { + "callback_id": "pick_channel_for_fun", + "type": "interactive_message", + } )(simple_listener) request = self.build_valid_request() @@ -72,7 +85,10 @@ def test_success(self): assert self.mock_received_requests["/auth.test"] == 1 def test_success_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.attachment_action("pick_channel_for_fun")(simple_listener) request = self.build_valid_request() @@ -87,7 +103,10 @@ def test_process_before_response(self): process_before_response=True, ) app.action( - {"callback_id": "pick_channel_for_fun", "type": "interactive_message",} + { + "callback_id": "pick_channel_for_fun", + "type": "interactive_message", + } )(simple_listener) request = self.build_valid_request() @@ -96,7 +115,10 @@ def test_process_before_response(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure_without_type(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 @@ -108,21 +130,30 @@ def test_failure_without_type(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 assert self.mock_received_requests["/auth.test"] == 1 - app.action({"callback_id": "unknown", "type": "interactive_message",})( - simple_listener - ) + app.action( + { + "callback_id": "unknown", + "type": "interactive_message", + } + )(simple_listener) response = app.dispatch(request) assert response.status == 404 assert self.mock_received_requests["/auth.test"] == 1 def test_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 diff --git a/tests/scenario_tests/test_authorize.py b/tests/scenario_tests/test_authorize.py index 86bebef7d..6bb4345e8 100644 --- a/tests/scenario_tests/test_authorize.py +++ b/tests/scenario_tests/test_authorize.py @@ -24,7 +24,8 @@ def authorize(enterprise_id, team_id, user_id, client: WebClient): assert user_id == "W99999" auth_test = client.auth_test(token=valid_token) return AuthorizeResult.from_auth_test_response( - auth_test_response=auth_test, bot_token=valid_token, + auth_test_response=auth_test, + bot_token=valid_token, ) @@ -34,7 +35,8 @@ def user_authorize(enterprise_id, team_id, user_id, client: WebClient): assert user_id == "W99999" auth_test = client.auth_test(token=valid_user_token) return AuthorizeResult.from_auth_test_response( - auth_test_response=auth_test, user_token=valid_user_token, + auth_test_response=auth_test, + user_token=valid_user_token, ) @@ -49,7 +51,10 @@ class TestAuthorize: signing_secret = "secret" 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,) + 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() @@ -61,7 +66,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/scenario_tests/test_block_actions.py b/tests/scenario_tests/test_block_actions.py index bfe851c6f..6280bf6ed 100644 --- a/tests/scenario_tests/test_block_actions.py +++ b/tests/scenario_tests/test_block_actions.py @@ -19,7 +19,10 @@ class TestBlockActions: 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,) + 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() @@ -31,7 +34,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -52,7 +56,10 @@ def test_mock_server_is_running(self): assert resp != None def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("a")(simple_listener) request = self.build_valid_request() @@ -61,7 +68,10 @@ def test_success(self): assert self.mock_received_requests["/auth.test"] == 1 def test_success_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.block_action("a")(simple_listener) request = self.build_valid_request() @@ -110,7 +120,10 @@ def test_default_type_and_unmatched_block_id(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 @@ -122,7 +135,10 @@ def test_failure(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 diff --git a/tests/scenario_tests/test_block_suggestion.py b/tests/scenario_tests/test_block_suggestion.py index 341039377..67c5428a8 100644 --- a/tests/scenario_tests/test_block_suggestion.py +++ b/tests/scenario_tests/test_block_suggestion.py @@ -20,7 +20,10 @@ class TestBlockSuggestion: 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,) + 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() @@ -32,7 +35,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -59,7 +63,10 @@ def test_mock_server_is_running(self): assert resp != None def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options("es_a")(show_options) request = self.build_valid_request() @@ -70,7 +77,10 @@ def test_success(self): assert self.mock_received_requests["/auth.test"] == 1 def test_success_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.block_suggestion("es_a")(show_options) request = self.build_valid_request() @@ -81,7 +91,10 @@ def test_success_2(self): assert self.mock_received_requests["/auth.test"] == 1 def test_success_multi(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options("mes_a")(show_multi_options) request = self.build_valid_multi_request() @@ -122,7 +135,10 @@ def test_process_before_response_multi(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 @@ -134,7 +150,10 @@ def test_failure(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 @@ -146,7 +165,10 @@ def test_failure_2(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure_multi(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_multi_request() response = app.dispatch(request) assert response.status == 404 diff --git a/tests/scenario_tests/test_dialogs.py b/tests/scenario_tests/test_dialogs.py index 1d6b20c8f..671ca723e 100644 --- a/tests/scenario_tests/test_dialogs.py +++ b/tests/scenario_tests/test_dialogs.py @@ -19,7 +19,10 @@ class TestAttachmentActions: 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,) + 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() @@ -31,7 +34,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -50,7 +54,10 @@ def test_mock_server_is_running(self): assert resp != None def test_success_without_type(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options("dialog-callback-id")(handle_suggestion) app.action("dialog-callback-id")(handle_submission_cancellation) @@ -74,7 +81,10 @@ def test_success_without_type(self): assert self.mock_received_requests["/auth.test"] == 1 def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"})( handle_suggestion ) @@ -105,7 +115,10 @@ def test_success(self): assert self.mock_received_requests["/auth.test"] == 1 def test_success_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.dialog_suggestion("dialog-callback-id")(handle_suggestion) app.dialog_submission("dialog-callback-id")(handle_submission) app.dialog_cancellation("dialog-callback-id")(handle_cancellation) @@ -194,7 +207,10 @@ def test_process_before_response_2(self): assert self.mock_received_requests["/auth.test"] == 1 def test_suggestion_failure_without_type(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 @@ -206,7 +222,10 @@ def test_suggestion_failure_without_type(self): assert self.mock_received_requests["/auth.test"] == 1 def test_suggestion_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 @@ -218,7 +237,10 @@ def test_suggestion_failure(self): assert self.mock_received_requests["/auth.test"] == 1 def test_suggestion_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 @@ -232,7 +254,10 @@ def test_suggestion_failure_2(self): assert self.mock_received_requests["/auth.test"] == 1 def test_submission_failure_without_type(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 @@ -244,7 +269,10 @@ def test_submission_failure_without_type(self): assert self.mock_received_requests["/auth.test"] == 1 def test_submission_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 @@ -256,7 +284,10 @@ def test_submission_failure(self): assert self.mock_received_requests["/auth.test"] == 1 def test_submission_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 @@ -270,7 +301,10 @@ def test_submission_failure_2(self): assert self.mock_received_requests["/auth.test"] == 1 def test_cancellation_failure_without_type(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 @@ -282,7 +316,10 @@ def test_cancellation_failure_without_type(self): assert self.mock_received_requests["/auth.test"] == 1 def test_cancellation_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 @@ -294,7 +331,10 @@ def test_cancellation_failure(self): assert self.mock_received_requests["/auth.test"] == 1 def test_cancellation_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 @@ -381,7 +421,10 @@ def handle_submission(ack): "value": "UXD-342", }, {"label": "[FE-459] Remove the marquee tag", "value": "FE-459"}, - {"label": "[FE-238] Too many shades of gray in master CSS", "value": "FE-238",}, + { + "label": "[FE-238] Too many shades of gray in master CSS", + "value": "FE-238", + }, ] } diff --git a/tests/scenario_tests/test_error_handler.py b/tests/scenario_tests/test_error_handler.py index 131454633..7ebcc224d 100644 --- a/tests/scenario_tests/test_error_handler.py +++ b/tests/scenario_tests/test_error_handler.py @@ -19,7 +19,10 @@ class TestErrorHandler: 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,) + 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() @@ -35,7 +38,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -48,11 +52,15 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> BoltRequest: body = { "type": "block_actions", - "user": {"id": "W111",}, + "user": { + "id": "W111", + }, "api_app_id": "A111", "token": "verification_token", "trigger_id": "111.222.valid", - "team": {"id": "T111",}, + "team": { + "id": "T111", + }, "channel": {"id": "C111", "name": "test-channel"}, "response_url": "https://hooks.slack.com/actions/T111/111/random-value", "actions": [ @@ -80,7 +88,10 @@ def test_default(self): def failing_listener(): raise Exception("Something wrong!") - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("a")(failing_listener) request = self.build_valid_request() @@ -95,7 +106,10 @@ def error_handler(logger, payload, response): def failing_listener(): raise Exception("Something wrong!") - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.error(error_handler) app.action("a")(failing_listener) diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index 4a2b02dc8..12ec82742 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -17,7 +17,10 @@ class TestEvents: 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,) + 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() @@ -29,7 +32,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/scenario_tests/test_events_org_apps.py b/tests/scenario_tests/test_events_org_apps.py index 01dbe4be0..c9c2e737b 100644 --- a/tests/scenario_tests/test_events_org_apps.py +++ b/tests/scenario_tests/test_events_org_apps.py @@ -47,7 +47,8 @@ class TestEventsOrgApps: mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) web_client: WebClient = WebClient( - token=None, base_url=mock_api_server_base_url, + token=None, + base_url=mock_api_server_base_url, ) def setup_method(self): @@ -60,7 +61,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/scenario_tests/test_events_shared_channels.py b/tests/scenario_tests/test_events_shared_channels.py index 702b515f3..9829d91bc 100644 --- a/tests/scenario_tests/test_events_shared_channels.py +++ b/tests/scenario_tests/test_events_shared_channels.py @@ -20,7 +20,8 @@ def authorize(enterprise_id, team_id, client: WebClient): assert team_id == "T_INSTALLED" auth_test = client.auth_test(token=valid_token) return AuthorizeResult.from_auth_test_response( - auth_test_response=auth_test, bot_token=valid_token, + auth_test_response=auth_test, + bot_token=valid_token, ) @@ -29,7 +30,8 @@ class TestEventsSharedChannels: mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) web_client: WebClient = WebClient( - token=None, base_url=mock_api_server_base_url, + token=None, + base_url=mock_api_server_base_url, ) def setup_method(self): @@ -42,7 +44,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/scenario_tests/test_events_socket_mode.py b/tests/scenario_tests/test_events_socket_mode.py index 5a02f9624..2bb8709e4 100644 --- a/tests/scenario_tests/test_events_socket_mode.py +++ b/tests/scenario_tests/test_events_socket_mode.py @@ -15,7 +15,10 @@ class TestEventsSocketMode: 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,) + 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() diff --git a/tests/scenario_tests/test_lazy.py b/tests/scenario_tests/test_lazy.py index 0a81b3488..8a633f8cc 100644 --- a/tests/scenario_tests/test_lazy.py +++ b/tests/scenario_tests/test_lazy.py @@ -19,7 +19,10 @@ class TestErrorHandler: 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,) + 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() @@ -35,7 +38,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -48,11 +52,15 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> BoltRequest: body = { "type": "block_actions", - "user": {"id": "W111",}, + "user": { + "id": "W111", + }, "api_app_id": "A111", "token": "verification_token", "trigger_id": "111.222.valid", - "team": {"id": "T111",}, + "team": { + "id": "T111", + }, "channel": {"id": "C111", "name": "test-channel"}, "response_url": "https://hooks.slack.com/actions/T111/111/random-value", "actions": [ @@ -88,9 +96,13 @@ def async2(say): time.sleep(0.5) say(text="lazy function 2") - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("a")( - ack=just_ack, lazy=[async1, async2], + ack=just_ack, + lazy=[async1, async2], ) request = self.build_valid_request() diff --git a/tests/scenario_tests/test_message.py b/tests/scenario_tests/test_message.py index 1acfe56b7..d99edffc1 100644 --- a/tests/scenario_tests/test_message.py +++ b/tests/scenario_tests/test_message.py @@ -19,7 +19,10 @@ class TestMessage: 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,) + 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() @@ -31,7 +34,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -50,7 +54,10 @@ def build_request2(self) -> BoltRequest: return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) def test_string_keyword(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message("Hello")(whats_up) request = self.build_request() @@ -61,7 +68,10 @@ def test_string_keyword(self): assert self.mock_received_requests["/chat.postMessage"] == 1 def test_string_keyword_capturing(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message("We've received ([0-9]+) messages from (.+)!")(verify_matches) request = self.build_request2() @@ -72,7 +82,10 @@ def test_string_keyword_capturing(self): assert self.mock_received_requests["/chat.postMessage"] == 1 def test_string_keyword_capturing2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message(re.compile("We've received ([0-9]+) messages from (.+)!"))( verify_matches ) @@ -85,7 +98,10 @@ def test_string_keyword_capturing2(self): assert self.mock_received_requests["/chat.postMessage"] == 1 def test_string_keyword_unmatched(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message("HELLO")(whats_up) request = self.build_request() @@ -94,7 +110,10 @@ def test_string_keyword_unmatched(self): assert self.mock_received_requests["/auth.test"] == 1 def test_regexp_keyword(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message(re.compile("He.lo"))(whats_up) request = self.build_request() @@ -105,7 +124,10 @@ def test_regexp_keyword(self): assert self.mock_received_requests["/chat.postMessage"] == 1 def test_regexp_keyword_unmatched(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message(re.compile("HELLO"))(whats_up) request = self.build_request() diff --git a/tests/scenario_tests/test_middleware.py b/tests/scenario_tests/test_middleware.py index 517c1b1ce..569dc6261 100644 --- a/tests/scenario_tests/test_middleware.py +++ b/tests/scenario_tests/test_middleware.py @@ -18,7 +18,10 @@ class TestMiddleware: 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,) + 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() @@ -50,7 +53,8 @@ def build_request(self) -> BoltRequest: "content-type": ["application/json"], "x-slack-signature": [ self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) ], "x-slack-request-timestamp": [timestamp], @@ -58,7 +62,10 @@ def build_request(self) -> BoltRequest: ) def test_no_next_call(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.use(no_next) app.shortcut("test-shortcut")(just_ack) @@ -67,7 +74,10 @@ def test_no_next_call(self): assert self.mock_received_requests["/auth.test"] == 1 def test_next_call(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.use(just_next) app.shortcut("test-shortcut")(just_ack) diff --git a/tests/scenario_tests/test_shortcut.py b/tests/scenario_tests/test_shortcut.py index b646d77a3..42143942a 100644 --- a/tests/scenario_tests/test_shortcut.py +++ b/tests/scenario_tests/test_shortcut.py @@ -19,7 +19,10 @@ class TestShortcut: 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,) + 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() @@ -31,7 +34,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -51,7 +55,10 @@ def test_mock_server_is_running(self): # NOTE: This is a compatible behavior with Bolt for JS def test_success_both_global_and_message(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) @@ -65,7 +72,10 @@ def test_success_both_global_and_message(self): assert self.mock_received_requests["/auth.test"] == 1 def test_success_global(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) @@ -74,7 +84,10 @@ def test_success_global(self): assert self.mock_received_requests["/auth.test"] == 1 def test_success_global_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.global_shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) @@ -88,7 +101,10 @@ def test_success_global_2(self): assert self.mock_received_requests["/auth.test"] == 1 def test_success_message(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.shortcut({"type": "message_action", "callback_id": "test-shortcut"})( simple_listener ) @@ -104,7 +120,10 @@ def test_success_message(self): assert self.mock_received_requests["/auth.test"] == 1 def test_success_message_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message_shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(message_shortcut_raw_body) @@ -131,7 +150,10 @@ def test_process_before_response_global(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 @@ -143,7 +165,10 @@ def test_failure(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 diff --git a/tests/scenario_tests/test_slash_command.py b/tests/scenario_tests/test_slash_command.py index acfac442b..23f07dbc0 100644 --- a/tests/scenario_tests/test_slash_command.py +++ b/tests/scenario_tests/test_slash_command.py @@ -18,7 +18,10 @@ class TestSlashCommand: 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,) + 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() @@ -30,7 +33,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -49,7 +53,10 @@ def test_mock_server_is_running(self): assert resp != None def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.command("/hello-world")(commander) request = self.build_valid_request() @@ -71,7 +78,10 @@ def test_process_before_response(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 diff --git a/tests/scenario_tests/test_ssl_check.py b/tests/scenario_tests/test_ssl_check.py index bb27939fd..3470321de 100644 --- a/tests/scenario_tests/test_ssl_check.py +++ b/tests/scenario_tests/test_ssl_check.py @@ -16,7 +16,10 @@ class TestSSLCheck: 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,) + 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() @@ -28,7 +31,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def test_mock_server_is_running(self): diff --git a/tests/scenario_tests/test_view_closed.py b/tests/scenario_tests/test_view_closed.py index 55c34ab4c..8294108f1 100644 --- a/tests/scenario_tests/test_view_closed.py +++ b/tests/scenario_tests/test_view_closed.py @@ -19,7 +19,10 @@ class TestViewClosed: 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,) + 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() @@ -31,7 +34,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -52,7 +56,10 @@ def test_mock_server_is_running(self): assert resp != None def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view({"type": "view_closed", "callback_id": "view-id"})(simple_listener) request = self.build_valid_request() @@ -61,7 +68,10 @@ def test_success(self): assert self.mock_received_requests["/auth.test"] == 1 def test_success_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view_closed("view-id")(simple_listener) request = self.build_valid_request() @@ -83,7 +93,10 @@ def test_process_before_response(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 @@ -95,7 +108,10 @@ def test_failure(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 @@ -107,7 +123,10 @@ def test_failure(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 @@ -143,7 +162,10 @@ def test_failure_2(self): { "type": "input", "block_id": "hspI", - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, "optional": False, "element": {"type": "plain_text_input", "action_id": "maBWU"}, } @@ -152,11 +174,20 @@ def test_failure_2(self): "callback_id": "view-id", "state": {"values": {}}, "hash": "1596530361.3wRYuk3R", - "title": {"type": "plain_text", "text": "My App",}, + "title": { + "type": "plain_text", + "text": "My App", + }, "clear_on_close": False, "notify_on_close": False, - "close": {"type": "plain_text", "text": "Cancel",}, - "submit": {"type": "plain_text", "text": "Submit",}, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, "previous_view_id": None, "root_view_id": "V111", "app_id": "A111", diff --git a/tests/scenario_tests/test_view_submission.py b/tests/scenario_tests/test_view_submission.py index c0ecbfa2e..ced03e181 100644 --- a/tests/scenario_tests/test_view_submission.py +++ b/tests/scenario_tests/test_view_submission.py @@ -19,7 +19,10 @@ class TestViewSubmission: 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,) + 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() @@ -31,7 +34,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -52,7 +56,10 @@ def test_mock_server_is_running(self): assert resp != None def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view("view-id")(simple_listener) request = self.build_valid_request() @@ -61,7 +68,10 @@ def test_success(self): assert self.mock_received_requests["/auth.test"] == 1 def test_success_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view_submission("view-id")(simple_listener) request = self.build_valid_request() @@ -83,7 +93,10 @@ def test_process_before_response(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 @@ -95,7 +108,10 @@ def test_failure(self): assert self.mock_received_requests["/auth.test"] == 1 def test_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 @@ -132,7 +148,10 @@ def test_failure_2(self): { "type": "input", "block_id": "hspI", - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, "optional": False, "element": {"type": "plain_text_input", "action_id": "maBWU"}, } @@ -143,11 +162,20 @@ def test_failure_2(self): "values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}} }, "hash": "1596530361.3wRYuk3R", - "title": {"type": "plain_text", "text": "My App",}, + "title": { + "type": "plain_text", + "text": "My App", + }, "clear_on_close": False, "notify_on_close": False, - "close": {"type": "plain_text", "text": "Cancel",}, - "submit": {"type": "plain_text", "text": "Submit",}, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, "previous_view_id": None, "root_view_id": "V111", "app_id": "A111", diff --git a/tests/scenario_tests/test_workflow_steps.py b/tests/scenario_tests/test_workflow_steps.py index 9dacd83a3..b41d32e12 100644 --- a/tests/scenario_tests/test_workflow_steps.py +++ b/tests/scenario_tests/test_workflow_steps.py @@ -32,7 +32,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_app(self, callback_id: str): @@ -364,7 +365,10 @@ def edit(ack: Ack, step, configure: Configure): "element": { "type": "plain_text_input", "action_id": "task_name", - "placeholder": {"type": "plain_text", "text": "Write a task name",}, + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, }, "label": {"type": "plain_text", "text": "Task name"}, }, @@ -387,7 +391,10 @@ def edit(ack: Ack, step, configure: Configure): "element": { "type": "plain_text_input", "action_id": "task_author", - "placeholder": {"type": "plain_text", "text": "Write a task name",}, + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, }, "label": {"type": "plain_text", "text": "Task author"}, }, @@ -414,9 +421,21 @@ def save(ack: Ack, step: dict, view: dict, update: Update): }, }, outputs=[ - {"name": "taskName", "type": "text", "label": "Task Name",}, - {"name": "taskDescription", "type": "text", "label": "Task Description",}, - {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, ], ) ack() @@ -487,7 +506,8 @@ def save_lazy(step: dict, view: dict, update: Update): assert step is not None assert view is not None update( - inputs={}, outputs=[], + inputs={}, + outputs=[], ) diff --git a/tests/scenario_tests_async/test_app.py b/tests/scenario_tests_async/test_app.py index 6f9d0a8a8..1640087bc 100644 --- a/tests/scenario_tests_async/test_app.py +++ b/tests/scenario_tests_async/test_app.py @@ -131,7 +131,9 @@ def test_installation_store_conflicts(self): app = AsyncApp( signing_secret="valid", oauth_settings=AsyncOAuthSettings( - client_id="111.222", client_secret="valid", installation_store=store1, + client_id="111.222", + client_secret="valid", + installation_store=store1, ), installation_store=store2, ) @@ -153,7 +155,10 @@ def test_installation_store_conflicts(self): app = AsyncApp( signing_secret="valid", oauth_flow=AsyncOAuthFlow( - settings=AsyncOAuthSettings(client_id="111.222", client_secret="valid",) + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="valid", + ) ), installation_store=store1, ) diff --git a/tests/scenario_tests_async/test_app_bot_only.py b/tests/scenario_tests_async/test_app_bot_only.py index e664269bb..58685393a 100644 --- a/tests/scenario_tests_async/test_app_bot_only.py +++ b/tests/scenario_tests_async/test_app_bot_only.py @@ -29,7 +29,10 @@ class TestAppBotOnly: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -45,7 +48,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/scenario_tests_async/test_attachment_actions.py b/tests/scenario_tests_async/test_attachment_actions.py index 34110bd38..00338c68e 100644 --- a/tests/scenario_tests_async/test_attachment_actions.py +++ b/tests/scenario_tests_async/test_attachment_actions.py @@ -21,7 +21,10 @@ class TestAsyncAttachmentActions: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -37,7 +40,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -60,7 +64,10 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_success_without_type(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("pick_channel_for_fun")(simple_listener) request = self.build_valid_request() @@ -70,9 +77,15 @@ async def test_success_without_type(self): @pytest.mark.asyncio async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action( - {"callback_id": "pick_channel_for_fun", "type": "interactive_message",} + { + "callback_id": "pick_channel_for_fun", + "type": "interactive_message", + } )(simple_listener) request = self.build_valid_request() @@ -82,7 +95,10 @@ async def test_success(self): @pytest.mark.asyncio async def test_success_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.attachment_action("pick_channel_for_fun")(simple_listener) request = self.build_valid_request() @@ -98,7 +114,10 @@ async def test_process_before_response(self): process_before_response=True, ) app.action( - {"callback_id": "pick_channel_for_fun", "type": "interactive_message",} + { + "callback_id": "pick_channel_for_fun", + "type": "interactive_message", + } )(simple_listener) request = self.build_valid_request() @@ -122,7 +141,10 @@ async def test_process_before_response_2(self): @pytest.mark.asyncio async def test_failure_without_type(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 @@ -135,22 +157,31 @@ async def test_failure_without_type(self): @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 assert self.mock_received_requests["/auth.test"] == 1 - app.action({"callback_id": "unknown", "type": "interactive_message",})( - simple_listener - ) + app.action( + { + "callback_id": "unknown", + "type": "interactive_message", + } + )(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 assert self.mock_received_requests["/auth.test"] == 1 @pytest.mark.asyncio async def test_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 diff --git a/tests/scenario_tests_async/test_authorize.py b/tests/scenario_tests_async/test_authorize.py index 83e7d45a3..01a3c4331 100644 --- a/tests/scenario_tests_async/test_authorize.py +++ b/tests/scenario_tests_async/test_authorize.py @@ -26,7 +26,8 @@ async def authorize(enterprise_id, team_id, user_id, client: AsyncWebClient): assert user_id == "W99999" auth_test = await client.auth_test(token=valid_token) return AuthorizeResult.from_auth_test_response( - auth_test_response=auth_test, bot_token=valid_token, + auth_test_response=auth_test, + bot_token=valid_token, ) @@ -36,7 +37,8 @@ async def user_authorize(enterprise_id, team_id, user_id, client: AsyncWebClient assert user_id == "W99999" auth_test = await client.auth_test(token=valid_user_token) return AuthorizeResult.from_auth_test_response( - auth_test_response=auth_test, user_token=valid_user_token, + auth_test_response=auth_test, + user_token=valid_user_token, ) @@ -51,7 +53,10 @@ class TestAsyncAuthorize: signing_secret = "secret" 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -67,7 +72,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/scenario_tests_async/test_block_actions.py b/tests/scenario_tests_async/test_block_actions.py index 3eab11552..4ee1b1fb1 100644 --- a/tests/scenario_tests_async/test_block_actions.py +++ b/tests/scenario_tests_async/test_block_actions.py @@ -22,7 +22,10 @@ class TestAsyncBlockActions: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -38,7 +41,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -61,7 +65,10 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("a")(simple_listener) request = self.build_valid_request() @@ -71,7 +78,10 @@ async def test_success(self): @pytest.mark.asyncio async def test_success_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.block_action("a")(simple_listener) request = self.build_valid_request() @@ -81,7 +91,10 @@ async def test_success_2(self): @pytest.mark.asyncio async def test_default_type(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action({"action_id": "a", "block_id": "b"})(simple_listener) request = self.build_valid_request() @@ -91,7 +104,10 @@ async def test_default_type(self): @pytest.mark.asyncio async def test_default_type_no_block_id(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action({"action_id": "a"})(simple_listener) request = self.build_valid_request() @@ -101,7 +117,10 @@ async def test_default_type_no_block_id(self): @pytest.mark.asyncio async def test_default_type_unmatched_block_id(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action({"action_id": "a", "block_id": "bbb"})(simple_listener) request = self.build_valid_request() @@ -139,7 +158,10 @@ async def test_process_before_response_2(self): @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 @@ -152,7 +174,10 @@ async def test_failure(self): @pytest.mark.asyncio async def test_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 diff --git a/tests/scenario_tests_async/test_block_suggestion.py b/tests/scenario_tests_async/test_block_suggestion.py index b6f7aa80c..7351b290b 100644 --- a/tests/scenario_tests_async/test_block_suggestion.py +++ b/tests/scenario_tests_async/test_block_suggestion.py @@ -22,7 +22,10 @@ class TestAsyncBlockSuggestion: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -38,7 +41,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -67,7 +71,10 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options("es_a")(show_options) request = self.build_valid_request() @@ -79,7 +86,10 @@ async def test_success(self): @pytest.mark.asyncio async def test_success_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.block_suggestion("es_a")(show_options) request = self.build_valid_request() @@ -91,7 +101,10 @@ async def test_success_2(self): @pytest.mark.asyncio async def test_success_multi(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options("mes_a")(show_multi_options) request = self.build_valid_multi_request() @@ -135,7 +148,10 @@ async def test_process_before_response_multi(self): @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 @@ -148,7 +164,10 @@ async def test_failure(self): @pytest.mark.asyncio async def test_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 @@ -161,7 +180,10 @@ async def test_failure_2(self): @pytest.mark.asyncio async def test_failure_multi(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_multi_request() response = await app.async_dispatch(request) assert response.status == 404 diff --git a/tests/scenario_tests_async/test_dialogs.py b/tests/scenario_tests_async/test_dialogs.py index 2f99d468f..1cad07ff4 100644 --- a/tests/scenario_tests_async/test_dialogs.py +++ b/tests/scenario_tests_async/test_dialogs.py @@ -21,7 +21,10 @@ class TestAsyncAttachmentActions: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -37,7 +40,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -58,7 +62,10 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_success_without_type(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options("dialog-callback-id")(handle_suggestion) app.action("dialog-callback-id")(handle_submission_or_cancellation) @@ -83,7 +90,10 @@ async def test_success_without_type(self): @pytest.mark.asyncio async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"})( handle_suggestion ) @@ -115,7 +125,10 @@ async def test_success(self): @pytest.mark.asyncio async def test_success_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.dialog_suggestion("dialog-callback-id")(handle_suggestion) app.dialog_submission("dialog-callback-id")(handle_submission) app.dialog_cancellation("dialog-callback-id")(handle_cancellation) @@ -207,7 +220,10 @@ async def test_process_before_response_2(self): @pytest.mark.asyncio async def test_suggestion_failure_without_type(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 @@ -220,7 +236,10 @@ async def test_suggestion_failure_without_type(self): @pytest.mark.asyncio async def test_suggestion_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 @@ -233,7 +252,10 @@ async def test_suggestion_failure(self): @pytest.mark.asyncio async def test_suggestion_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 @@ -248,7 +270,10 @@ async def test_suggestion_failure_2(self): @pytest.mark.asyncio async def test_submission_failure_without_type(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 @@ -261,7 +286,10 @@ async def test_submission_failure_without_type(self): @pytest.mark.asyncio async def test_submission_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 @@ -274,7 +302,10 @@ async def test_submission_failure(self): @pytest.mark.asyncio async def test_submission_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 @@ -289,7 +320,10 @@ async def test_submission_failure_2(self): @pytest.mark.asyncio async def test_cancellation_failure_without_type(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 @@ -302,7 +336,10 @@ async def test_cancellation_failure_without_type(self): @pytest.mark.asyncio async def test_cancellation_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 @@ -315,7 +352,10 @@ async def test_cancellation_failure(self): @pytest.mark.asyncio async def test_cancellation_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 @@ -402,7 +442,10 @@ async def handle_submission(ack): "value": "UXD-342", }, {"label": "[FE-459] Remove the marquee tag", "value": "FE-459"}, - {"label": "[FE-238] Too many shades of gray in master CSS", "value": "FE-238",}, + { + "label": "[FE-238] Too many shades of gray in master CSS", + "value": "FE-238", + }, ] } diff --git a/tests/scenario_tests_async/test_error_handler.py b/tests/scenario_tests_async/test_error_handler.py index 6a599c5a0..d0e553ff4 100644 --- a/tests/scenario_tests_async/test_error_handler.py +++ b/tests/scenario_tests_async/test_error_handler.py @@ -21,7 +21,10 @@ class TestAsyncErrorHandler: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -41,7 +44,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -54,11 +58,15 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> AsyncBoltRequest: body = { "type": "block_actions", - "user": {"id": "W111",}, + "user": { + "id": "W111", + }, "api_app_id": "A111", "token": "verification_token", "trigger_id": "111.222.valid", - "team": {"id": "T111",}, + "team": { + "id": "T111", + }, "channel": {"id": "C111", "name": "test-channel"}, "response_url": "https://hooks.slack.com/actions/T111/111/random-value", "actions": [ @@ -87,7 +95,10 @@ async def test_default(self): async def failing_listener(): raise Exception("Something wrong!") - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("a")(failing_listener) request = self.build_valid_request() @@ -103,7 +114,10 @@ async def error_handler(logger, payload, response): async def failing_listener(): raise Exception("Something wrong!") - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.error(error_handler) app.action("a")(failing_listener) diff --git a/tests/scenario_tests_async/test_events.py b/tests/scenario_tests_async/test_events.py index 67e02db0b..346883bee 100644 --- a/tests/scenario_tests_async/test_events.py +++ b/tests/scenario_tests_async/test_events.py @@ -22,7 +22,10 @@ class TestAsyncEvents: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -38,7 +41,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -59,7 +63,10 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_app_mention(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.event("app_mention")(whats_up) request = self.build_valid_app_mention_request() @@ -97,7 +104,10 @@ async def test_middleware_skip(self): @pytest.mark.asyncio async def test_simultaneous_requests(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.event("app_mention")(random_sleeper) request = self.build_valid_app_mention_request() @@ -120,7 +130,10 @@ def build_valid_reaction_added_request(self) -> AsyncBoltRequest: @pytest.mark.asyncio async def test_reaction_added(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.event("reaction_added")(whats_up) request = self.build_valid_reaction_added_request() @@ -132,7 +145,10 @@ async def test_reaction_added(self): @pytest.mark.asyncio async def test_stable_auto_ack(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.event("reaction_added")(always_failing) for _ in range(10): @@ -142,7 +158,10 @@ async def test_stable_auto_ack(self): @pytest.mark.asyncio async def test_self_events(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.event("reaction_added")(whats_up) self_event = { @@ -180,7 +199,10 @@ async def test_self_events(self): @pytest.mark.asyncio async def test_self_joined_left_events(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.event("reaction_added")(whats_up) join_event_body = { @@ -249,7 +271,10 @@ async def handle_member_left_channel(say): @pytest.mark.asyncio async def test_joined_left_events(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.event("reaction_added")(whats_up) join_event_body = { diff --git a/tests/scenario_tests_async/test_events_org_apps.py b/tests/scenario_tests_async/test_events_org_apps.py index 04182d937..596927879 100644 --- a/tests/scenario_tests_async/test_events_org_apps.py +++ b/tests/scenario_tests_async/test_events_org_apps.py @@ -68,7 +68,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/scenario_tests_async/test_events_shared_channels.py b/tests/scenario_tests_async/test_events_shared_channels.py index 0fb85f11d..b7877cf15 100644 --- a/tests/scenario_tests_async/test_events_shared_channels.py +++ b/tests/scenario_tests_async/test_events_shared_channels.py @@ -25,7 +25,8 @@ async def authorize(enterprise_id, team_id, client: AsyncWebClient): assert team_id == "T_INSTALLED" auth_test = await client.auth_test(token=valid_token) return AuthorizeResult.from_auth_test_response( - auth_test_response=auth_test, bot_token=valid_token, + auth_test_response=auth_test, + bot_token=valid_token, ) @@ -50,7 +51,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/scenario_tests_async/test_events_socket_mode.py b/tests/scenario_tests_async/test_events_socket_mode.py index d668f963c..ce6485f41 100644 --- a/tests/scenario_tests_async/test_events_socket_mode.py +++ b/tests/scenario_tests_async/test_events_socket_mode.py @@ -17,7 +17,10 @@ class TestAsyncEvents: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -53,7 +56,10 @@ async def test_app_mention(self): @pytest.mark.asyncio async def test_process_before_response(self): - app = AsyncApp(client=self.web_client, process_before_response=True,) + app = AsyncApp( + client=self.web_client, + process_before_response=True, + ) app.event("app_mention")(whats_up) request = self.build_valid_app_mention_request() diff --git a/tests/scenario_tests_async/test_lazy.py b/tests/scenario_tests_async/test_lazy.py index 229c7709b..f8fe438bc 100644 --- a/tests/scenario_tests_async/test_lazy.py +++ b/tests/scenario_tests_async/test_lazy.py @@ -21,7 +21,10 @@ class TestAsyncLazy: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -41,7 +44,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -54,11 +58,15 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> AsyncBoltRequest: body = { "type": "block_actions", - "user": {"id": "W111",}, + "user": { + "id": "W111", + }, "api_app_id": "A111", "token": "verification_token", "trigger_id": "111.222.valid", - "team": {"id": "T111",}, + "team": { + "id": "T111", + }, "channel": {"id": "C111", "name": "test-channel"}, "response_url": "https://hooks.slack.com/actions/T111/111/random-value", "actions": [ @@ -95,9 +103,13 @@ async def async2(say): await asyncio.sleep(0.5) await say(text="lazy function 2") - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("a")( - ack=just_ack, lazy=[async1, async2], + ack=just_ack, + lazy=[async1, async2], ) request = self.build_valid_request() diff --git a/tests/scenario_tests_async/test_message.py b/tests/scenario_tests_async/test_message.py index c2d6dda52..60ed2e458 100644 --- a/tests/scenario_tests_async/test_message.py +++ b/tests/scenario_tests_async/test_message.py @@ -21,7 +21,10 @@ class TestAsyncMessage: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -37,7 +40,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -57,7 +61,10 @@ def build_request2(self) -> AsyncBoltRequest: @pytest.mark.asyncio async def test_string_keyword(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message("Hello")(whats_up) request = self.build_request() @@ -69,7 +76,10 @@ async def test_string_keyword(self): @pytest.mark.asyncio async def test_string_keyword_capturing(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message("We've received ([0-9]+) messages from (.+)!")(verify_matches) request = self.build_request2() @@ -81,7 +91,10 @@ async def test_string_keyword_capturing(self): @pytest.mark.asyncio async def test_string_keyword_capturing2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message(re.compile("We've received ([0-9]+) messages from (.+)!"))( verify_matches ) @@ -95,7 +108,10 @@ async def test_string_keyword_capturing2(self): @pytest.mark.asyncio async def test_string_keyword_unmatched(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message("HELLO")(whats_up) request = self.build_request() @@ -105,7 +121,10 @@ async def test_string_keyword_unmatched(self): @pytest.mark.asyncio async def test_regexp_keyword(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message(re.compile("He.lo"))(whats_up) request = self.build_request() @@ -117,7 +136,10 @@ async def test_regexp_keyword(self): @pytest.mark.asyncio async def test_regexp_keyword_unmatched(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message(re.compile("HELLO"))(whats_up) request = self.build_request() diff --git a/tests/scenario_tests_async/test_middleware.py b/tests/scenario_tests_async/test_middleware.py index 3b81a387f..bcc8d4baf 100644 --- a/tests/scenario_tests_async/test_middleware.py +++ b/tests/scenario_tests_async/test_middleware.py @@ -20,7 +20,10 @@ class TestAsyncMiddleware: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -56,7 +59,8 @@ def build_request(self) -> AsyncBoltRequest: "content-type": ["application/json"], "x-slack-signature": [ self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) ], "x-slack-request-timestamp": [timestamp], @@ -65,7 +69,10 @@ def build_request(self) -> AsyncBoltRequest: @pytest.mark.asyncio async def test_no_next_call(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.use(no_next) app.shortcut("test-shortcut")(just_ack) @@ -75,7 +82,10 @@ async def test_no_next_call(self): @pytest.mark.asyncio async def test_next_call(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.use(just_next) app.shortcut("test-shortcut")(just_ack) diff --git a/tests/scenario_tests_async/test_shortcut.py b/tests/scenario_tests_async/test_shortcut.py index aac7cde89..5627198af 100644 --- a/tests/scenario_tests_async/test_shortcut.py +++ b/tests/scenario_tests_async/test_shortcut.py @@ -21,7 +21,10 @@ class TestAsyncShortcut: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -37,7 +40,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -59,7 +63,10 @@ async def test_mock_server_is_running(self): # NOTE: This is a compatible behavior with Bolt for JS @pytest.mark.asyncio async def test_success_both_global_and_message(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) @@ -74,7 +81,10 @@ async def test_success_both_global_and_message(self): @pytest.mark.asyncio async def test_success_global(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) @@ -84,7 +94,10 @@ async def test_success_global(self): @pytest.mark.asyncio async def test_success_global_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.global_shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) @@ -99,7 +112,10 @@ async def test_success_global_2(self): @pytest.mark.asyncio async def test_success_message(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.shortcut({"type": "message_action", "callback_id": "test-shortcut"})( simple_listener ) @@ -116,7 +132,10 @@ async def test_success_message(self): @pytest.mark.asyncio async def test_success_message_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message_shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(message_shortcut_raw_body) @@ -145,7 +164,10 @@ async def test_process_before_response_global(self): @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 @@ -158,7 +180,10 @@ async def test_failure(self): @pytest.mark.asyncio async def test_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 diff --git a/tests/scenario_tests_async/test_slash_command.py b/tests/scenario_tests_async/test_slash_command.py index 84d8a44eb..44c881b9a 100644 --- a/tests/scenario_tests_async/test_slash_command.py +++ b/tests/scenario_tests_async/test_slash_command.py @@ -20,7 +20,10 @@ class TestAsyncSlashCommand: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -36,7 +39,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -57,7 +61,10 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.command("/hello-world")(commander) request = self.build_valid_request() @@ -81,7 +88,10 @@ async def test_process_before_response(self): @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 diff --git a/tests/scenario_tests_async/test_ssl_check.py b/tests/scenario_tests_async/test_ssl_check.py index 3a22e85b8..651255903 100644 --- a/tests/scenario_tests_async/test_ssl_check.py +++ b/tests/scenario_tests_async/test_ssl_check.py @@ -19,7 +19,10 @@ class TestAsyncSSLCheck: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -35,7 +38,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) @pytest.mark.asyncio diff --git a/tests/scenario_tests_async/test_view_closed.py b/tests/scenario_tests_async/test_view_closed.py index 020301284..d5af6167c 100644 --- a/tests/scenario_tests_async/test_view_closed.py +++ b/tests/scenario_tests_async/test_view_closed.py @@ -21,7 +21,10 @@ class TestAsyncViewClosed: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -37,7 +40,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -60,7 +64,10 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view({"type": "view_closed", "callback_id": "view-id"})(simple_listener) request = self.build_valid_request() @@ -70,7 +77,10 @@ async def test_success(self): @pytest.mark.asyncio async def test_success_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view_closed("view-id")(simple_listener) request = self.build_valid_request() @@ -94,7 +104,10 @@ async def test_process_before_response(self): @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 @@ -107,7 +120,10 @@ async def test_failure(self): @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 @@ -120,7 +136,10 @@ async def test_failure(self): @pytest.mark.asyncio async def test_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 @@ -156,7 +175,10 @@ async def test_failure_2(self): { "type": "input", "block_id": "hspI", - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, "optional": False, "element": {"type": "plain_text_input", "action_id": "maBWU"}, } @@ -165,11 +187,20 @@ async def test_failure_2(self): "callback_id": "view-id", "state": {"values": {}}, "hash": "1596530361.3wRYuk3R", - "title": {"type": "plain_text", "text": "My App",}, + "title": { + "type": "plain_text", + "text": "My App", + }, "clear_on_close": False, "notify_on_close": False, - "close": {"type": "plain_text", "text": "Cancel",}, - "submit": {"type": "plain_text", "text": "Submit",}, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, "previous_view_id": None, "root_view_id": "V111", "app_id": "A111", diff --git a/tests/scenario_tests_async/test_view_submission.py b/tests/scenario_tests_async/test_view_submission.py index 4c5fa672d..2d7e1d993 100644 --- a/tests/scenario_tests_async/test_view_submission.py +++ b/tests/scenario_tests_async/test_view_submission.py @@ -21,7 +21,10 @@ class TestAsyncViewSubmission: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -37,7 +40,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -60,7 +64,10 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view("view-id")(simple_listener) request = self.build_valid_request() @@ -70,7 +77,10 @@ async def test_success(self): @pytest.mark.asyncio async def test_success_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view_submission("view-id")(simple_listener) request = self.build_valid_request() @@ -94,7 +104,10 @@ async def test_process_before_response(self): @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 @@ -107,7 +120,10 @@ async def test_failure(self): @pytest.mark.asyncio async def test_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 @@ -144,7 +160,10 @@ async def test_failure_2(self): { "type": "input", "block_id": "hspI", - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, "optional": False, "element": {"type": "plain_text_input", "action_id": "maBWU"}, } @@ -155,11 +174,20 @@ async def test_failure_2(self): "values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}} }, "hash": "1596530361.3wRYuk3R", - "title": {"type": "plain_text", "text": "My App",}, + "title": { + "type": "plain_text", + "text": "My App", + }, "clear_on_close": False, "notify_on_close": False, - "close": {"type": "plain_text", "text": "Cancel",}, - "submit": {"type": "plain_text", "text": "Submit",}, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, "previous_view_id": None, "root_view_id": "V111", "app_id": "A111", diff --git a/tests/scenario_tests_async/test_workflow_steps.py b/tests/scenario_tests_async/test_workflow_steps.py index 525a6f568..0d36ef99b 100644 --- a/tests/scenario_tests_async/test_workflow_steps.py +++ b/tests/scenario_tests_async/test_workflow_steps.py @@ -27,7 +27,10 @@ class TestAsyncWorkflowSteps: 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,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): @@ -43,7 +46,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_app(self, callback_id: str): @@ -381,7 +385,10 @@ async def edit(ack: AsyncAck, step, configure: AsyncConfigure): "element": { "type": "plain_text_input", "action_id": "task_name", - "placeholder": {"type": "plain_text", "text": "Write a task name",}, + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, }, "label": {"type": "plain_text", "text": "Task name"}, }, @@ -404,7 +411,10 @@ async def edit(ack: AsyncAck, step, configure: AsyncConfigure): "element": { "type": "plain_text_input", "action_id": "task_author", - "placeholder": {"type": "plain_text", "text": "Write a task name",}, + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, }, "label": {"type": "plain_text", "text": "Task author"}, }, @@ -431,9 +441,21 @@ async def save(ack: AsyncAck, step: dict, view: dict, update: AsyncUpdate): }, }, outputs=[ - {"name": "taskName", "type": "text", "label": "Task Name",}, - {"name": "taskDescription", "type": "text", "label": "Task Description",}, - {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, ], ) await ack() @@ -506,7 +528,8 @@ async def save_lazy(step: dict, view: dict, update: AsyncUpdate): assert step is not None assert view is not None await update( - inputs={}, outputs=[], + inputs={}, + outputs=[], ) diff --git a/tests/slack_bolt/app/test_dev_server.py b/tests/slack_bolt/app/test_dev_server.py index 7e09c6102..978cffc3f 100644 --- a/tests/slack_bolt/app/test_dev_server.py +++ b/tests/slack_bolt/app/test_dev_server.py @@ -12,7 +12,10 @@ class TestDevServer: 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,) + 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() diff --git a/tests/slack_bolt/context/test_ack.py b/tests/slack_bolt/context/test_ack.py index 9ecb17a69..e7d7443e9 100644 --- a/tests/slack_bolt/context/test_ack.py +++ b/tests/slack_bolt/context/test_ack.py @@ -138,8 +138,14 @@ def test_view_update(self): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [{"type": "divider", "block_id": "b"}], }, ) diff --git a/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py b/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py index e241298d5..5a680f137 100644 --- a/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py +++ b/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py @@ -19,7 +19,8 @@ def teardown_method(self): def test_instantiation(self): matcher: ListenerMatcher = CustomListenerMatcher( - app_name="foo", func=func, + app_name="foo", + func=func, ) resp = BoltResponse(status=201) diff --git a/tests/slack_bolt/middleware/request_verification/test_request_verification.py b/tests/slack_bolt/middleware/request_verification/test_request_verification.py index dca98e7b6..4fdb28964 100644 --- a/tests/slack_bolt/middleware/request_verification/test_request_verification.py +++ b/tests/slack_bolt/middleware/request_verification/test_request_verification.py @@ -17,7 +17,8 @@ class TestRequestVerification: def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/slack_bolt_async/app/test_server.py b/tests/slack_bolt_async/app/test_server.py index 1c8bbfc29..0c53f96c4 100644 --- a/tests/slack_bolt_async/app/test_server.py +++ b/tests/slack_bolt_async/app/test_server.py @@ -13,6 +13,9 @@ def test_instance(self): server = AsyncSlackAppServer( port=3001, path="/slack/events", - app=AsyncApp(signing_secret="valid", token="xoxb-valid",), + app=AsyncApp( + signing_secret="valid", + token="xoxb-valid", + ), ) assert server is not None diff --git a/tests/slack_bolt_async/authorization/test_async_authorize.py b/tests/slack_bolt_async/authorization/test_async_authorize.py index f8a3cc98c..f81927c86 100644 --- a/tests/slack_bolt_async/authorization/test_async_authorize.py +++ b/tests/slack_bolt_async/authorization/test_async_authorize.py @@ -22,7 +22,9 @@ class TestAsyncAuthorize: mock_api_server_base_url = "http://localhost:8888" - client = AsyncWebClient(base_url=mock_api_server_base_url,) + client = AsyncWebClient( + base_url=mock_api_server_base_url, + ) @pytest.fixture def event_loop(self): diff --git a/tests/slack_bolt_async/context/test_async_ack.py b/tests/slack_bolt_async/context/test_async_ack.py index fa74e198f..6004002dd 100644 --- a/tests/slack_bolt_async/context/test_async_ack.py +++ b/tests/slack_bolt_async/context/test_async_ack.py @@ -145,8 +145,14 @@ async def test_view_update(self): view={ "type": "modal", "callbAsyncAck_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [{"type": "divider", "block_id": "b"}], }, ) diff --git a/tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py b/tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py index 6e226c9b6..96a407aab 100644 --- a/tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py +++ b/tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py @@ -20,7 +20,8 @@ class TestAsyncCustomListenerMatcher: @pytest.mark.asyncio async def test_instantiation(self): matcher: AsyncListenerMatcher = AsyncCustomListenerMatcher( - app_name="foo", func=func, + app_name="foo", + func=func, ) resp = BoltResponse(status=201) diff --git a/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py b/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py index 783927572..098db8b09 100644 --- a/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py +++ b/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py @@ -21,7 +21,8 @@ class TestAsyncRequestVerification: def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index 44f4e5ea7..af5adbbd2 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -203,7 +203,8 @@ async def failure(args: AsyncFailureArgs) -> BoltResponse: installation_store=FileInstallationStore(), state_store=FileOAuthStateStore(expiration_seconds=120), callback_options=AsyncCallbackOptions( - success=success, failure=failure, + success=success, + failure=failure, ), ), ) diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py index 3c17ca49e..afd3cbdac 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py @@ -113,7 +113,10 @@ async def failure(args: AsyncFailureArgs) -> BoltResponse: client_id="111.222", client_secret="xxx", scopes=["chat:write", "commands"], - callback_options=AsyncCallbackOptions(success=success, failure=failure,), + callback_options=AsyncCallbackOptions( + success=success, + failure=failure, + ), ) state = await oauth_flow.issue_new_state(None) req = AsyncBoltRequest( From 4660bbcfcf41052ceebeebc1e0d66ee8c458f099 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 19 Dec 2020 12:50:12 +0900 Subject: [PATCH 222/865] version 1.1.4 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 0b2f79dbb..c72e3798a 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.1.3" +__version__ = "1.1.4" From 2216935b522dfc7f511e45252b76ffff5832ecb3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 4 Jan 2021 13:52:06 +0900 Subject: [PATCH 223/865] Update tests for Sanic adapter --- tests/adapter_tests_async/test_async_sanic.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py index 38d91d95a..38f0fbb00 100644 --- a/tests/adapter_tests_async/test_async_sanic.py +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -29,6 +29,10 @@ class TestSanic: base_url=mock_api_server_base_url, ) + @staticmethod + def unique_sanic_app_name() -> str: + return f"awesome-slack-app-{time()}" + @pytest.fixture def event_loop(self): old_os_env = remove_os_env_temporarily() @@ -93,7 +97,7 @@ async def event_handler(): } timestamp, body = str(int(time())), json.dumps(input) - api = Sanic(name="awesome-slack-app") + api = Sanic(name=self.unique_sanic_app_name()) app_handler = AsyncSlackRequestHandler(app) @api.post("/slack/events") @@ -137,7 +141,7 @@ async def shortcut_handler(ack): timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" - api = Sanic(name="awesome-slack-app") + api = Sanic(name=self.unique_sanic_app_name()) app_handler = AsyncSlackRequestHandler(app) @api.post("/slack/events") @@ -181,7 +185,7 @@ async def command_handler(ack): ) timestamp, body = str(int(time())), input - api = Sanic(name="awesome-slack-app") + api = Sanic(name=self.unique_sanic_app_name()) app_handler = AsyncSlackRequestHandler(app) @api.post("/slack/events") @@ -207,7 +211,7 @@ async def test_oauth(self): scopes=["chat:write", "commands"], ), ) - api = Sanic(name="awesome-slack-app") + api = Sanic(name=self.unique_sanic_app_name()) app_handler = AsyncSlackRequestHandler(app) @api.get("/slack/install") From a1feaddc3d9120472c5f8ad3b984630ef2f7ec30 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 4 Jan 2021 15:57:02 +0900 Subject: [PATCH 224/865] Add more unit tests --- .../authorization/test_authorize.py | 13 +- .../listener_matcher/test_builtins.py | 157 ++++++++++++++++++ tests/slack_bolt/util/__init__.py | 0 tests/slack_bolt/util/test_util.py | 60 +++++++ .../authorization/test_async_authorize.py | 16 +- 5 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 tests/slack_bolt/util/__init__.py create mode 100644 tests/slack_bolt/util/test_util.py diff --git a/tests/slack_bolt/authorization/test_authorize.py b/tests/slack_bolt/authorization/test_authorize.py index 792949faf..9dc613ded 100644 --- a/tests/slack_bolt/authorization/test_authorize.py +++ b/tests/slack_bolt/authorization/test_authorize.py @@ -3,12 +3,13 @@ from logging import Logger from typing import Optional +import pytest from slack_sdk import WebClient from slack_sdk.oauth import InstallationStore from slack_sdk.oauth.installation_store import Bot, Installation from slack_bolt import BoltContext -from slack_bolt.authorization.authorize import InstallationStoreAuthorize +from slack_bolt.authorization.authorize import InstallationStoreAuthorize, Authorize from tests.mock_web_api_server import ( cleanup_mock_web_api_server, setup_mock_web_api_server, @@ -24,6 +25,16 @@ def setup_method(self): def teardown_method(self): cleanup_mock_web_api_server(self) + def test_root_class(self): + authorize = Authorize() + with pytest.raises(NotImplementedError): + authorize( + context=BoltContext(), + enterprise_id="E111", + team_id="T111", + user_id="U111", + ) + def test_installation_store_legacy(self): installation_store = LegacyMemoryInstallationStore() authorize = InstallationStoreAuthorize( diff --git a/tests/slack_bolt/listener_matcher/test_builtins.py b/tests/slack_bolt/listener_matcher/test_builtins.py index f8185d938..38da17e1d 100644 --- a/tests/slack_bolt/listener_matcher/test_builtins.py +++ b/tests/slack_bolt/listener_matcher/test_builtins.py @@ -7,6 +7,8 @@ block_action, action, workflow_step_execute, + event, + shortcut, ) @@ -52,6 +54,7 @@ def test_block_action(self): action({"action_id": re.compile("invalid_.+")}).matches(req, resp) is False ) + # block_id + action_id assert ( action({"action_id": "valid_action_id", "block_id": "b"}).matches(req, resp) is True @@ -100,6 +103,26 @@ def test_block_action(self): is False ) + # with type + assert ( + action({"action_id": "valid_action_id", "type": "block_actions"}).matches( + req, resp + ) + is True + ) + assert ( + action( + {"callback_id": "valid_action_id", "type": "interactive_message"} + ).matches(req, resp) + is False + ) + assert ( + action( + {"callback_id": "valid_action_id", "type": "workflow_step_edit"} + ).matches(req, resp) + is False + ) + def test_workflow_step_execute(self): payload = { "team_id": "T111", @@ -135,3 +158,137 @@ def test_workflow_step_execute(self): m = workflow_step_execute(re.compile("copy_.+")) assert m.matches(request, None) == True + + def test_events(self): + request = BoltRequest(body=json.dumps(event_payload)) + + m = event("app_mention") + assert m.matches(request, None) + m = event({"type": "app_mention"}) + assert m.matches(request, None) + m = event("message") + assert not m.matches(request, None) + m = event({"type": "message"}) + assert not m.matches(request, None) + + request = BoltRequest(body=f"payload={quote(json.dumps(shortcut_payload))}") + + m = event("app_mention") + assert not m.matches(request, None) + m = event({"type": "app_mention"}) + assert not m.matches(request, None) + + def test_global_shortcuts(self): + request = BoltRequest(body=f"payload={quote(json.dumps(shortcut_payload))}") + + m = shortcut("test-shortcut") + assert m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut", "type": "shortcut"}) + assert m.matches(request, None) + + m = shortcut("test-shortcut!!!") + assert not m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut", "type": "message_action"}) + assert not m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut!!!", "type": "shortcut"}) + assert not m.matches(request, None) + + def test_message_shortcuts(self): + request = BoltRequest( + body=f"payload={quote(json.dumps(message_shortcut_payload))}" + ) + + m = shortcut("test-shortcut") + assert m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut", "type": "message_action"}) + assert m.matches(request, None) + + m = shortcut("test-shortcut!!!") + assert not m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut", "type": "shortcut"}) + assert not m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut!!!", "type": "message_action"}) + assert not m.matches(request, None) + + +event_payload = { + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": True, + } + ], +} + +shortcut_payload = { + "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", +} + + +message_shortcut_payload = { + "type": "message_action", + "token": "verification_token", + "action_ts": "1583637157.207593", + "team": { + "id": "T111", + "domain": "test-test", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "name": "test-test"}, + "channel": {"id": "C111", "name": "dev"}, + "callback_id": "test-shortcut", + "trigger_id": "111.222.xxx", + "message_ts": "1583636382.000300", + "message": { + "client_msg_id": "zzzz-111-222-xxx-yyy", + "type": "message", + "text": "<@W222> test", + "user": "W111", + "ts": "1583636382.000300", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "d7eJ", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U222"}, + {"type": "text", "text": " test"}, + ], + } + ], + } + ], + }, + "response_url": "https://hooks.slack.com/app/T111/111/xxx", +} diff --git a/tests/slack_bolt/util/__init__.py b/tests/slack_bolt/util/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/util/test_util.py b/tests/slack_bolt/util/test_util.py new file mode 100644 index 000000000..84ff5cf3b --- /dev/null +++ b/tests/slack_bolt/util/test_util.py @@ -0,0 +1,60 @@ +import sys +from typing import Set + +import pytest +from slack_sdk.models import JsonObject + +from slack_bolt.error import BoltError +from slack_bolt.util.utils import convert_to_dict, get_boot_message +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class Data: + def __init__(self, name: str): + self.name = name + + +class SerializableData(JsonObject): + @property + def attributes(self) -> Set[str]: + return {"name"} + + def __init__(self, name: str): + self.name = name + + +class TestUtil: + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + + def teardown_method(self): + restore_os_env(self.old_os_env) + + def test_convert_to_dict(self): + assert convert_to_dict({"foo": "bar"}) == {"foo": "bar"} + assert convert_to_dict(SerializableData("baz")) == {"name": "baz"} + + def test_convert_to_dict_errors(self): + with pytest.raises(BoltError): + convert_to_dict(None) + with pytest.raises(BoltError): + convert_to_dict(123) + with pytest.raises(BoltError): + convert_to_dict("test") + with pytest.raises(BoltError): + convert_to_dict(Data("baz")) + + def test_get_boot_message(self): + assert get_boot_message() == "⚡️ Bolt app is running!" + assert ( + get_boot_message(development_server=True) + == "⚡️ Bolt app is running! (development server)" + ) + + def test_get_boot_message_win32(self): + sys_platform_backup = sys.platform + try: + sys.platform = "win32" + assert get_boot_message() == "Bolt app is running!" + finally: + sys.platform = sys_platform_backup diff --git a/tests/slack_bolt_async/authorization/test_async_authorize.py b/tests/slack_bolt_async/authorization/test_async_authorize.py index f81927c86..b1dfd4685 100644 --- a/tests/slack_bolt_async/authorization/test_async_authorize.py +++ b/tests/slack_bolt_async/authorization/test_async_authorize.py @@ -11,7 +11,10 @@ ) from slack_sdk.web.async_client import AsyncWebClient -from slack_bolt.authorization.async_authorize import AsyncInstallationStoreAuthorize +from slack_bolt.authorization.async_authorize import ( + AsyncInstallationStoreAuthorize, + AsyncAuthorize, +) from slack_bolt.context.async_context import AsyncBoltContext from tests.mock_web_api_server import ( setup_mock_web_api_server, @@ -38,6 +41,17 @@ def event_loop(self): finally: restore_os_env(old_os_env) + @pytest.mark.asyncio + async def test_root_class(self): + authorize = AsyncAuthorize() + with pytest.raises(NotImplementedError): + await authorize( + context=AsyncBoltContext(), + enterprise_id="T111", + team_id="T111", + user_id="U111", + ) + @pytest.mark.asyncio async def test_installation_store_legacy(self): installation_store = LegacyMemoryInstallationStore() From 03fdd85698f769daafc3bc7e8d6c527e8accf6c7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 4 Jan 2021 16:01:15 +0900 Subject: [PATCH 225/865] Add type hint updates missed in #148 --- slack_bolt/authorization/async_authorize.py | 6 +++--- slack_bolt/authorization/async_authorize_args.py | 4 ++-- slack_bolt/authorization/authorize.py | 6 +++--- slack_bolt/authorization/authorize_args.py | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index fb723f3cd..335aff82c 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -22,7 +22,7 @@ async def __call__( *, context: AsyncBoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], ) -> Optional[AuthorizeResult]: raise NotImplementedError() @@ -41,7 +41,7 @@ async def __call__( *, context: AsyncBoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], ) -> Optional[AuthorizeResult]: try: @@ -116,7 +116,7 @@ async def __call__( *, context: AsyncBoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], ) -> Optional[AuthorizeResult]: diff --git a/slack_bolt/authorization/async_authorize_args.py b/slack_bolt/authorization/async_authorize_args.py index 5100ad19f..6d04a7f1e 100644 --- a/slack_bolt/authorization/async_authorize_args.py +++ b/slack_bolt/authorization/async_authorize_args.py @@ -11,7 +11,7 @@ class AsyncAuthorizeArgs: logger: Logger client: AsyncWebClient enterprise_id: Optional[str] - team_id: str + team_id: Optional[str] user_id: Optional[str] def __init__( @@ -19,7 +19,7 @@ def __init__( *, context: AsyncBoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], ): """The whole arguments that are passed to Authorize functions. diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 090d5113f..56cd04161 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -21,7 +21,7 @@ def __call__( *, context: BoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], ) -> Optional[AuthorizeResult]: raise NotImplementedError() @@ -43,7 +43,7 @@ def __call__( *, context: BoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], ) -> Optional[AuthorizeResult]: try: @@ -121,7 +121,7 @@ def __call__( *, context: BoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], ) -> Optional[AuthorizeResult]: diff --git a/slack_bolt/authorization/authorize_args.py b/slack_bolt/authorization/authorize_args.py index 170cd7843..084da415d 100644 --- a/slack_bolt/authorization/authorize_args.py +++ b/slack_bolt/authorization/authorize_args.py @@ -11,7 +11,7 @@ class AuthorizeArgs: logger: Logger client: WebClient enterprise_id: Optional[str] - team_id: str + team_id: Optional[str] user_id: Optional[str] def __init__( @@ -19,7 +19,7 @@ def __init__( *, context: BoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], ): """The whole arguments that are passed to Authorize functions. From 55d4f16975fd7ba0d98d683527882935a3197f20 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 6 Jan 2021 15:52:32 +0900 Subject: [PATCH 226/865] Fix #193 by enabling listener middleware to return a response --- slack_bolt/app/app.py | 20 +++- slack_bolt/app/async_app.py | 23 +++- slack_bolt/listener/async_listener.py | 2 +- slack_bolt/listener/asyncio_runner.py | 3 +- slack_bolt/listener/listener.py | 4 +- slack_bolt/listener/thread_runner.py | 3 +- slack_bolt/logger/messages.py | 8 ++ slack_bolt/middleware/async_middleware.py | 4 +- slack_bolt/middleware/middleware.py | 4 +- slack_bolt/workflows/step/step_middleware.py | 2 +- .../test_listener_middleware.py | 102 ++++++++++++++++ .../test_listener_middleware.py | 113 ++++++++++++++++++ 12 files changed, 274 insertions(+), 14 deletions(-) create mode 100644 tests/scenario_tests/test_listener_middleware.py create mode 100644 tests/scenario_tests_async/test_listener_middleware.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index da54e7393..2d6b0b891 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -2,6 +2,7 @@ import json import logging import os +import time from concurrent.futures.thread import ThreadPoolExecutor from http.server import SimpleHTTPRequestHandler, HTTPServer from typing import List, Union, Pattern, Callable, Dict, Optional, Sequence @@ -42,6 +43,7 @@ error_client_invalid_type, error_authorize_conflicts, warning_bot_only_conflicts, + debug_return_listener_middleware_response, ) from slack_bolt.middleware import ( Middleware, @@ -323,6 +325,7 @@ def dispatch(self, req: BoltRequest) -> BoltResponse: :param req: An incoming request from Slack. :return: The response generated by this Bolt app. """ + starting_time = time.time() self._init_context(req) resp: BoltResponse = BoltResponse(status=200, body="") @@ -348,12 +351,27 @@ def middleware_next(): self._framework_logger.debug(debug_checking_listener(listener_name)) if listener.matches(req=req, resp=resp): # run all the middleware attached to this listener first - resp, next_was_not_called = listener.run_middleware(req=req, resp=resp) + middleware_resp, next_was_not_called = listener.run_middleware( + req=req, resp=resp + ) if next_was_not_called: + if middleware_resp is not None: + if self._framework_logger.level <= logging.DEBUG: + debug_message = debug_return_listener_middleware_response( + listener_name, + middleware_resp.status, + middleware_resp.body, + starting_time, + ) + self._framework_logger.debug(debug_message) + return middleware_resp # The last listener middleware didn't call next() method. # This means the listener is not for this incoming request. continue + if middleware_resp is not None: + resp = middleware_resp + self._framework_logger.debug(debug_running_listener(listener_name)) listener_response: Optional[BoltResponse] = self._listener_runner.run( request=req, diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index d74ea898d..834852724 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -1,6 +1,7 @@ import inspect import logging import os +import time from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable, Sequence from aiohttp import web @@ -39,6 +40,7 @@ error_oauth_settings_invalid_type_async, error_oauth_flow_invalid_type_async, warning_bot_only_conflicts, + debug_return_listener_middleware_response, ) from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -359,6 +361,7 @@ async def async_dispatch(self, req: AsyncBoltRequest) -> BoltResponse: :param req: An incoming request from Slack. :return: The response generated by this Bolt app. """ + starting_time = time.time() self._init_context(req) resp: BoltResponse = BoltResponse(status=200, body="") @@ -386,14 +389,28 @@ async def async_middleware_next(): self._framework_logger.debug(debug_checking_listener(listener_name)) if await listener.async_matches(req=req, resp=resp): # run all the middleware attached to this listener first - resp, next_was_not_called = await listener.run_async_middleware( - req=req, resp=resp - ) + ( + middleware_resp, + next_was_not_called, + ) = await listener.run_async_middleware(req=req, resp=resp) if next_was_not_called: + if middleware_resp is not None: + if self._framework_logger.level <= logging.DEBUG: + debug_message = debug_return_listener_middleware_response( + listener_name, + middleware_resp.status, + middleware_resp.body, + starting_time, + ) + self._framework_logger.debug(debug_message) + return middleware_resp # The last listener middleware didn't call next() method. # This means the listener is not for this incoming request. continue + if middleware_resp is not None: + resp = middleware_resp + self._framework_logger.debug(debug_running_listener(listener_name)) listener_response: Optional[ BoltResponse diff --git a/slack_bolt/listener/async_listener.py b/slack_bolt/listener/async_listener.py index 21fe7b033..567a51e17 100644 --- a/slack_bolt/listener/async_listener.py +++ b/slack_bolt/listener/async_listener.py @@ -33,7 +33,7 @@ async def run_async_middleware( *, req: AsyncBoltRequest, resp: BoltResponse, - ) -> Tuple[BoltResponse, bool]: + ) -> Tuple[Optional[BoltResponse], bool]: """Runs an async middleware. :param req: The incoming request diff --git a/slack_bolt/listener/asyncio_runner.py b/slack_bolt/listener/asyncio_runner.py index c9c1d49fa..1ea9de2a2 100644 --- a/slack_bolt/listener/asyncio_runner.py +++ b/slack_bolt/listener/asyncio_runner.py @@ -42,9 +42,10 @@ async def run( response: BoltResponse, listener_name: str, listener: AsyncListener, + starting_time: Optional[float] = None, ) -> Optional[BoltResponse]: ack = request.context.ack - starting_time = time.time() + starting_time = starting_time if starting_time is not None else time.time() if self.process_before_response: if not request.lazy_only: try: diff --git a/slack_bolt/listener/listener.py b/slack_bolt/listener/listener.py index 748392f94..cce1d2649 100644 --- a/slack_bolt/listener/listener.py +++ b/slack_bolt/listener/listener.py @@ -1,5 +1,5 @@ from abc import abstractmethod, ABCMeta -from typing import Callable, Tuple, Sequence +from typing import Callable, Tuple, Sequence, Optional from slack_bolt.listener_matcher import ListenerMatcher from slack_bolt.middleware import Middleware @@ -32,7 +32,7 @@ def run_middleware( *, req: BoltRequest, resp: BoltResponse, - ) -> Tuple[BoltResponse, bool]: + ) -> Tuple[Optional[BoltResponse], bool]: """ :param req: the incoming request diff --git a/slack_bolt/listener/thread_runner.py b/slack_bolt/listener/thread_runner.py index 28d4051da..2e60e1c41 100644 --- a/slack_bolt/listener/thread_runner.py +++ b/slack_bolt/listener/thread_runner.py @@ -43,9 +43,10 @@ def run( # type: ignore response: BoltResponse, listener_name: str, listener: Listener, + starting_time: Optional[float] = None, ) -> Optional[BoltResponse]: ack = request.context.ack - starting_time = time.time() + starting_time = starting_time if starting_time is not None else time.time() if self.process_before_response: if not request.lazy_only: try: diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index d850567a3..54e85a6d7 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -1,3 +1,4 @@ +import time from typing import Union from slack_sdk.web import SlackResponse @@ -111,3 +112,10 @@ def debug_running_lazy_listener(func_name: str) -> str: def debug_responding(status: int, body: str, millis: int) -> str: return f'Responding with status: {status} body: "{body}" ({millis} millis)' + + +def debug_return_listener_middleware_response( + listener_name: str, status: int, body: str, starting_time: float +) -> str: + millis = int((time.time() - starting_time) * 1000) + return f"Responding with listener middleware's response - listener: {listener_name}, status: {status}, body: {body} ({millis} millis)" diff --git a/slack_bolt/middleware/async_middleware.py b/slack_bolt/middleware/async_middleware.py index 1e846f684..88684974b 100644 --- a/slack_bolt/middleware/async_middleware.py +++ b/slack_bolt/middleware/async_middleware.py @@ -1,5 +1,5 @@ from abc import ABCMeta, abstractmethod -from typing import Callable, Awaitable +from typing import Callable, Awaitable, Optional from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse @@ -13,7 +13,7 @@ async def async_process( req: AsyncBoltRequest, resp: BoltResponse, next: Callable[[], Awaitable[BoltResponse]], - ) -> BoltResponse: + ) -> Optional[BoltResponse]: raise NotImplementedError() @property diff --git a/slack_bolt/middleware/middleware.py b/slack_bolt/middleware/middleware.py index d8c5c4df6..48b4b2120 100644 --- a/slack_bolt/middleware/middleware.py +++ b/slack_bolt/middleware/middleware.py @@ -1,5 +1,5 @@ from abc import ABCMeta, abstractmethod -from typing import Callable +from typing import Callable, Optional from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse @@ -13,7 +13,7 @@ def process( req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], - ) -> BoltResponse: + ) -> Optional[BoltResponse]: raise NotImplementedError() @property diff --git a/slack_bolt/workflows/step/step_middleware.py b/slack_bolt/workflows/step/step_middleware.py index 6c86b26fc..5a562d30b 100644 --- a/slack_bolt/workflows/step/step_middleware.py +++ b/slack_bolt/workflows/step/step_middleware.py @@ -19,7 +19,7 @@ def process( req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], - ) -> BoltResponse: + ) -> Optional[BoltResponse]: if self.step.edit.matches(req=req, resp=resp): resp = self._run(self.step.edit, req, resp) diff --git a/tests/scenario_tests/test_listener_middleware.py b/tests/scenario_tests/test_listener_middleware.py new file mode 100644 index 000000000..4906375d8 --- /dev/null +++ b/tests/scenario_tests/test_listener_middleware.py @@ -0,0 +1,102 @@ +import json +from time import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import BoltResponse +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +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 TestListenerMiddleware: + 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) + + body = { + "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", + } + + def build_request(self) -> BoltRequest: + timestamp, body = str(int(time())), json.dumps(self.body) + return BoltRequest( + body=body, + headers={ + "content-type": ["application/json"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + + def test_return_response(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.shortcut( + constraints="test-shortcut", + middleware=[listener_middleware_returning_response], + ) + def handle(ack): + ack() + + response = app.dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "listener middleware" + + def test_next(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.shortcut(constraints="test-shortcut", middleware=[just_next]) + def handle(ack): + ack() + + response = app.dispatch(self.build_request()) + assert response.status == 200 + + +def listener_middleware_returning_response(): + return BoltResponse(status=200, body="listener middleware") + + +def just_next(next): + next() diff --git a/tests/scenario_tests_async/test_listener_middleware.py b/tests/scenario_tests_async/test_listener_middleware.py new file mode 100644 index 000000000..bf3914468 --- /dev/null +++ b/tests/scenario_tests_async/test_listener_middleware.py @@ -0,0 +1,113 @@ +import asyncio +import json +from time import time + +import pytest +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.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 TestAsyncListenerMiddleware: + 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, + ) + + @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) + + body = { + "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", + } + + def build_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(self.body) + return AsyncBoltRequest( + body=body, + headers={ + "content-type": ["application/json"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + + @pytest.mark.asyncio + async def test_return_response(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.shortcut( + constraints="test-shortcut", + middleware=[listener_middleware_returning_response], + ) + async def handle(ack): + await ack() + + response = await app.async_dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "listener middleware" + + @pytest.mark.asyncio + async def test_next(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.shortcut( + constraints="test-shortcut", + middleware=[just_next], + ) + async def handle(ack): + await ack() + + response = await app.async_dispatch(self.build_request()) + assert response.status == 200 + + +async def listener_middleware_returning_response(): + return BoltResponse(status=200, body="listener middleware") + + +async def just_next(next): + await next() From 4f5cd1e393cffbb38503c085b08722c368a03ffd Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 6 Jan 2021 16:17:04 +0900 Subject: [PATCH 227/865] Add codecov.yml --- codecov.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..b24c2afb1 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +coverage: + status: + project: + default: + threshold: 0.3% + patch: + default: + target: 50% From b480eb0d3f4d8d8fe5be82954b220fb275c82e71 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 7 Jan 2021 12:26:00 +0900 Subject: [PATCH 228/865] Upgrade slack_sdk to 3.1.1 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bf95537e6..cb789759a 100755 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.1.0,<3.2",], + install_requires=["slack_sdk>=3.1.1,<3.2",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", From e29e061928fc62c80eb162fab54dd09108e45d05 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 7 Jan 2021 12:31:48 +0900 Subject: [PATCH 229/865] version 1.1.5 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index c72e3798a..9b102be76 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.1.4" +__version__ = "1.1.5" From 899ce766c8badd31702338e5acec930069b4596b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 5 Jan 2021 20:12:04 +0900 Subject: [PATCH 230/865] Fix #174 by enabling to use instance/class methods for listeners/middleware Co-authored-by: Yang Liu --- slack_bolt/kwargs_injection/async_utils.py | 11 + slack_bolt/kwargs_injection/utils.py | 11 + slack_bolt/logger/messages.py | 7 + .../test_app_using_methods_in_class.py | 165 +++++++++++++++ .../test_app_using_methods_in_class.py | 188 ++++++++++++++++++ 5 files changed, 382 insertions(+) create mode 100644 tests/scenario_tests/test_app_using_methods_in_class.py create mode 100644 tests/scenario_tests_async/test_app_using_methods_in_class.py diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index ae7773725..a4b069d72 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -15,6 +15,7 @@ to_message, to_step, ) +from ..logger.messages import warning_skip_uncommon_arg_name def build_async_required_kwargs( @@ -65,6 +66,16 @@ def build_async_required_kwargs( if k not in all_available_args: all_available_args[k] = v + if len(required_arg_names) > 0: + # To support instance/class methods in a class for listeners/middleware, + # check if the first argument is either self or cls + first_arg_name = required_arg_names[0] + 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) + 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 0d936cb14..e10febb59 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -15,6 +15,7 @@ to_message, to_step, ) +from ..logger.messages import warning_skip_uncommon_arg_name def build_required_kwargs( @@ -65,6 +66,16 @@ def build_required_kwargs( if k not in all_available_args: all_available_args[k] = v + if len(required_arg_names) > 0: + # To support instance/class methods in a class for listeners/middleware, + # check if the first argument is either self or cls + first_arg_name = required_arg_names[0] + 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) + 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/logger/messages.py b/slack_bolt/logger/messages.py index 54e85a6d7..9be35c486 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -84,6 +84,13 @@ def warning_bot_only_conflicts() -> str: ) +def warning_skip_uncommon_arg_name(arg_name: str) -> str: + return ( + f"Bolt skips injecting a value to the first keyword argument ({arg_name}). " + "If it is self/cls of a method, we recommend using the common names." + ) + + # ------------------------------- # Info # ------------------------------- diff --git a/tests/scenario_tests/test_app_using_methods_in_class.py b/tests/scenario_tests/test_app_using_methods_in_class.py new file mode 100644 index 000000000..4102c819c --- /dev/null +++ b/tests/scenario_tests/test_app_using_methods_in_class.py @@ -0,0 +1,165 @@ +import json +from time import time, sleep +from typing import Callable + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest, Say, Ack, BoltContext +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 TestAppUsingMethodsInClass: + 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 run_app_and_verify(self, app: App): + payload = { + "type": "message_action", + "token": "verification_token", + "action_ts": "1583637157.207593", + "team": { + "id": "T111", + "domain": "test-test", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "name": "test-test"}, + "channel": {"id": "C111", "name": "dev"}, + "callback_id": "test-shortcut", + "trigger_id": "111.222.xxx", + "message_ts": "1583636382.000300", + "message": { + "client_msg_id": "zzzz-111-222-xxx-yyy", + "type": "message", + "text": "<@W222> test", + "user": "W111", + "ts": "1583636382.000300", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "d7eJ", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U222"}, + {"type": "text", "text": " test"}, + ], + } + ], + } + ], + }, + "response_url": "https://hooks.slack.com/app/T111/111/xxx", + } + + timestamp, body = str(int(time())), f"payload={json.dumps(payload)}" + request: BoltRequest = BoltRequest( + body=body, + headers={ + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(0.5) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_class_methods(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.class_middleware) + app.shortcut("test-shortcut")(AwesomeClass.class_method) + self.run_app_and_verify(app) + + def test_class_methods_uncommon_name(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.class_middleware) + app.shortcut("test-shortcut")(AwesomeClass.class_method2) + self.run_app_and_verify(app) + + def test_instance_methods(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_method) + self.run_app_and_verify(app) + + def test_instance_methods_uncommon_name(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_method2) + 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) + + +class AwesomeClass: + def __init__(self, name: str): + self.name = name + + @classmethod + def class_middleware(cls, next: Callable): + next() + + def instance_middleware(self, next: Callable): + next() + + @staticmethod + def static_middleware(next): + next() + + @classmethod + def class_method(cls, context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>!") + + @classmethod + def class_method2(xyz, context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>!") + + def instance_method(self, context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>! My name is {self.name}") + + def instance_method2(whatever, context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>! My name is {whatever.name}") + + @staticmethod + def static_method(context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>!") 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 new file mode 100644 index 000000000..a1131fd35 --- /dev/null +++ b/tests/scenario_tests_async/test_app_using_methods_in_class.py @@ -0,0 +1,188 @@ +import asyncio +import json +from time import time +from typing import Callable + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.context.say.async_say import AsyncSay +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 TestAppUsingMethodsInClass: + 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, + ) + + @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) + + async def run_app_and_verify(self, app: AsyncApp): + payload = { + "type": "message_action", + "token": "verification_token", + "action_ts": "1583637157.207593", + "team": { + "id": "T111", + "domain": "test-test", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "name": "test-test"}, + "channel": {"id": "C111", "name": "dev"}, + "callback_id": "test-shortcut", + "trigger_id": "111.222.xxx", + "message_ts": "1583636382.000300", + "message": { + "client_msg_id": "zzzz-111-222-xxx-yyy", + "type": "message", + "text": "<@W222> test", + "user": "W111", + "ts": "1583636382.000300", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "d7eJ", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U222"}, + {"type": "text", "text": " test"}, + ], + } + ], + } + ], + }, + "response_url": "https://hooks.slack.com/app/T111/111/xxx", + } + + timestamp, body = str(int(time())), f"payload={json.dumps(payload)}" + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, + headers={ + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(0.5) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_class_methods(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.class_middleware) + app.shortcut("test-shortcut")(AwesomeClass.class_method) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_class_methods_uncommon_name(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.class_middleware) + app.shortcut("test-shortcut")(AwesomeClass.class_method2) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_instance_methods(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_method) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_instance_methods_uncommon_name(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_static_methods(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.static_middleware) + app.shortcut("test-shortcut")(AwesomeClass.static_method) + await self.run_app_and_verify(app) + + +class AwesomeClass: + def __init__(self, name: str): + self.name = name + + @classmethod + async def class_middleware(cls, next: Callable): + await next() + + async def instance_middleware(self, next: Callable): + await next() + + @staticmethod + async def static_middleware(next): + await next() + + @classmethod + async def class_method( + cls, context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck + ): + await ack() + await say(f"Hello <@{context.user_id}>!") + + @classmethod + async def class_method2( + xyz, context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck + ): + await ack() + await say(f"Hello <@{context.user_id}>!") + + async def instance_method( + self, context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck + ): + await ack() + await say(f"Hello <@{context.user_id}>! My name is {self.name}") + + async def instance_method2( + whatever, context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck + ): + await ack() + await say(f"Hello <@{context.user_id}>! My name is {whatever.name}") + + @staticmethod + async def static_method(context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck): + await ack() + await say(f"Hello <@{context.user_id}>!") From 512a53c1ffa23b0dcad448ef08c2f57ca8e82e9d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 4 Jan 2021 12:13:20 +0900 Subject: [PATCH 231/865] Fix #183 Easier way to configure the installation URL as the direct install URL on App Directory --- slack_bolt/oauth/async_oauth_flow.py | 31 +++++++++++++------ slack_bolt/oauth/async_oauth_settings.py | 4 +++ slack_bolt/oauth/oauth_flow.py | 31 +++++++++++++------ slack_bolt/oauth/oauth_settings.py | 4 +++ tests/slack_bolt/oauth/test_oauth_flow.py | 22 ++++++++++++- .../oauth/test_async_oauth_flow.py | 20 +++++++++++- 6 files changed, 90 insertions(+), 22 deletions(-) diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 233c7d149..dc2d0f094 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -162,19 +162,30 @@ def sqlite3( async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse: state = await self.issue_new_state(request) url = await self.build_authorize_url(state, request) - html = await self.build_install_page_html(url, request) set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( state ) - return BoltResponse( - status=200, - body=html, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(bytes(html, "utf-8")), - "Set-Cookie": [set_cookie_value], - }, - ) + if self.settings.install_page_rendering_enabled: + html = await self.build_install_page_html(url, request) + return BoltResponse( + status=200, + body=html, + headers={ + "Content-Type": "text/html; charset=utf-8", + "Content-Length": len(bytes(html, "utf-8")), + "Set-Cookie": [set_cookie_value], + }, + ) + else: + return BoltResponse( + status=302, + body="", + headers={ + "Content-Type": "text/html; charset=utf-8", + "Location": url, + "Set-Cookie": [set_cookie_value], + }, + ) # ---------------------- # Internal methods for Installation diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index fec1bef80..948bf7547 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -32,6 +32,7 @@ class AsyncOAuthSettings: redirect_uri: Optional[str] # Handler configuration install_path: str + install_page_rendering_enabled: bool redirect_uri_path: str callback_options: Optional[CallbackOptions] = None success_url: Optional[str] @@ -63,6 +64,7 @@ def __init__( redirect_uri: Optional[str] = None, # Handler configuration install_path: str = "/slack/install", + install_page_rendering_enabled: bool = True, redirect_uri_path: str = "/slack/oauth_redirect", callback_options: Optional[CallbackOptions] = None, success_url: Optional[str] = None, @@ -86,6 +88,7 @@ def __init__( :param user_scopes: Check the value in Settings > Manage Distribution :param redirect_uri: Check the value in Features > OAuth & Permissions > Redirect URLs :param install_path: The endpoint to start an OAuth flow (Default: /slack/install) + :param install_page_rendering_enabled: Renders a web page for install_path access if True :param redirect_uri_path: The path of Redirect URL (Default: /slack/oauth_redirect) :param callback_options: Give success/failure functions f you want to customize callback functions. :param success_url: Set a complete URL if you want to redirect end-users when an installation completes. @@ -119,6 +122,7 @@ def __init__( self.install_path = install_path or os.environ.get( "SLACK_INSTALL_PATH", "/slack/install" ) + self.install_page_rendering_enabled = install_page_rendering_enabled self.redirect_uri_path = redirect_uri_path or os.environ.get( "SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect" ) diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index 3182c255b..867c90761 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -157,19 +157,30 @@ def sqlite3( def handle_installation(self, request: BoltRequest) -> BoltResponse: state = self.issue_new_state(request) url = self.build_authorize_url(state, request) - html = self.build_install_page_html(url, request) set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( state ) - return BoltResponse( - status=200, - body=html, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(bytes(html, "utf-8")), - "Set-Cookie": [set_cookie_value], - }, - ) + if self.settings.install_page_rendering_enabled: + html = self.build_install_page_html(url, request) + return BoltResponse( + status=200, + body=html, + headers={ + "Content-Type": "text/html; charset=utf-8", + "Content-Length": len(bytes(html, "utf-8")), + "Set-Cookie": [set_cookie_value], + }, + ) + else: + return BoltResponse( + status=302, + body="", + headers={ + "Content-Type": "text/html; charset=utf-8", + "Location": url, + "Set-Cookie": [set_cookie_value], + }, + ) # ---------------------- # Internal methods for Installation diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index 6010e5c26..ebb2fad27 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -27,6 +27,7 @@ class OAuthSettings: redirect_uri: Optional[str] # Handler configuration install_path: str + install_page_rendering_enabled: bool redirect_uri_path: str callback_options: Optional[CallbackOptions] = None success_url: Optional[str] @@ -58,6 +59,7 @@ def __init__( redirect_uri: Optional[str] = None, # Handler configuration install_path: str = "/slack/install", + install_page_rendering_enabled: bool = True, redirect_uri_path: str = "/slack/oauth_redirect", callback_options: Optional[CallbackOptions] = None, success_url: Optional[str] = None, @@ -81,6 +83,7 @@ def __init__( :param user_scopes: Check the value in Settings > Manage Distribution :param redirect_uri: Check the value in Features > OAuth & Permissions > Redirect URLs :param install_path: The endpoint to start an OAuth flow (Default: /slack/install) + :param install_page_rendering_enabled: Renders a web page for install_path access if True :param redirect_uri_path: The path of Redirect URL (Default: /slack/oauth_redirect) :param callback_options: Give success/failure functions f you want to customize callback functions. :param success_url: Set a complete URL if you want to redirect end-users when an installation completes. @@ -113,6 +116,7 @@ def __init__( self.install_path = install_path or os.environ.get( "SLACK_INSTALL_PATH", "/slack/install" ) + self.install_page_rendering_enabled = install_page_rendering_enabled self.redirect_uri_path = redirect_uri_path or os.environ.get( "SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect" ) diff --git a/tests/slack_bolt/oauth/test_oauth_flow.py b/tests/slack_bolt/oauth/test_oauth_flow.py index a2797e5e0..cbb119753 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow.py +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -40,7 +40,7 @@ def test_instantiation(self): assert oauth_flow.logger is not None assert oauth_flow.client is not None - def test_handle_installation(self): + def test_handle_installation_default(self): oauth_flow = OAuthFlow( settings=OAuthSettings( client_id="111.222", @@ -58,6 +58,26 @@ def test_handle_installation(self): assert resp.headers.get("content-length") == ["576"] assert "https://slack.com/oauth/v2/authorize?state=" in resp.body + # https://github.com/slackapi/bolt-python/issues/183 + # For direct install URL suppport + def test_handle_installation_no_rendering(self): + oauth_flow = OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + user_scopes=["search:read"], + installation_store=FileInstallationStore(), + install_page_rendering_enabled=False, # disabled + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + req = BoltRequest(body="") + resp = oauth_flow.handle_installation(req) + assert resp.status == 302 + location_header = resp.headers.get("location")[0] + assert "https://slack.com/oauth/v2/authorize?state=" in location_header + def test_scopes_as_str(self): settings = OAuthSettings( client_id="111.222", diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index af5adbbd2..090429bd3 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -92,7 +92,7 @@ async def test_instantiation_non_async_settings_to_app(self): ) @pytest.mark.asyncio - async def test_handle_installation(self): + async def test_handle_installation_default(self): oauth_flow = AsyncOAuthFlow( settings=AsyncOAuthSettings( client_id="111.222", @@ -109,6 +109,24 @@ async def test_handle_installation(self): assert resp.headers.get("content-length") == ["565"] assert "https://slack.com/oauth/v2/authorize?state=" in resp.body + @pytest.mark.asyncio + async def test_handle_installation_no_rendering(self): + oauth_flow = AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + install_page_rendering_enabled=False, # disabled + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + req = AsyncBoltRequest(body="") + resp = await oauth_flow.handle_installation(req) + assert resp.status == 302 + location_header = resp.headers.get("location")[0] + assert "https://slack.com/oauth/v2/authorize?state=" in location_header + @pytest.mark.asyncio async def test_handle_callback(self): oauth_flow = AsyncOAuthFlow( From 47063fa8cb471347bd50cb14804b4ca5bd70e7cf Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 10 Jan 2021 16:33:19 +0900 Subject: [PATCH 232/865] Fix #197 by supporting classic bot messages in app.message --- slack_bolt/app/app.py | 11 +- slack_bolt/app/async_app.py | 9 +- slack_bolt/listener_matcher/builtins.py | 22 +- tests/scenario_tests/test_message_bot.py | 206 +++++++++++++++++ .../scenario_tests_async/test_message_bot.py | 211 ++++++++++++++++++ 5 files changed, 450 insertions(+), 9 deletions(-) create mode 100644 tests/scenario_tests/test_message_bot.py create mode 100644 tests/scenario_tests_async/test_message_bot.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 2d6b0b891..690eb6d36 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -466,7 +466,9 @@ def error( def event( self, - event: Union[str, Pattern, Dict[str, str]], + event: Union[ + str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]] + ], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Optional[Callable[..., Optional[BoltResponse]]]: @@ -501,9 +503,10 @@ def message( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.event( - {"type": "message", "subtype": None} - ) + # As of Jan 2021, most bot messages no longer have the subtype bot_message. + # By contrast, messages posted using class app's bot token still have the subtype. + constraints = {"type": "message", "subtype": (None, "bot_message")} + primary_matcher = builtin_matchers.event(constraints=constraints) middleware.append(MessageListenerMatches(keyword)) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 834852724..d08df8e2c 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -517,7 +517,9 @@ def error( def event( self, - event: Union[str, Pattern, Dict[str, str]], + event: Union[ + str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]] + ], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: @@ -550,8 +552,11 @@ def message( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) + # As of Jan 2021, most bot messages no longer have the subtype bot_message. + # By contrast, messages posted using class app's bot token still have the subtype. + constraints = {"type": "message", "subtype": (None, "bot_message")} primary_matcher = builtin_matchers.event( - {"type": "message", "subtype": None}, True + constraints=constraints, asyncio=True ) middleware.append(AsyncMessageListenerMatches(keyword)) return self._register_listener( diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index bcba2a043..27efa2c24 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -26,7 +26,7 @@ from re import _pattern_type as Pattern else: from re import Pattern -from typing import Callable, Awaitable, Any +from typing import Callable, Awaitable, Any, Sequence, Optional, Union from typing import Union, Optional, Dict from slack_bolt.kwargs_injection import build_required_kwargs @@ -74,7 +74,9 @@ async def async_fun(body: Dict[str, Any]) -> bool: def event( - constraints: Union[str, Pattern, Dict[str, str]], + constraints: Union[ + str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]] + ], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): @@ -93,10 +95,24 @@ def func(body: Dict[str, Any]) -> bool: if not _matches(constraints["type"], event["type"]): return False if "subtype" in constraints: - expected_subtype = constraints["subtype"] + expected_subtype: Union[ + str, Sequence[Optional[Union[str, Pattern]]] + ] = constraints["subtype"] if expected_subtype is None: # "subtype" in constraints is intentionally None for this pattern return "subtype" not in event + elif isinstance(expected_subtype, Sequence): + subtypes: Sequence[ + Optional[Union[str, Pattern]] + ] = expected_subtype + for expected in subtypes: + actual: Optional[str] = event.get("subtype") + if expected is None: + if actual is None: + return True + elif actual is not None and _matches(expected, actual): + return True + return False else: return "subtype" in event and _matches( expected_subtype, event["subtype"] diff --git a/tests/scenario_tests/test_message_bot.py b/tests/scenario_tests/test_message_bot.py new file mode 100644 index 000000000..be24e0fb4 --- /dev/null +++ b/tests/scenario_tests/test_message_bot.py @@ -0,0 +1,206 @@ +import json +import re +import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +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 TestBotMessage: + 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_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, event_payload: dict) -> BoltRequest: + timestamp, body = str(int(time.time())), json.dumps(event_payload) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_message_handler(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + result = {"call_count": 0} + + @app.message("Hi there!") + def handle_messages(event, logger): + logger.info(event) + result["call_count"] = result["call_count"] + 1 + + request = self.build_request(user_message_event_payload) + response = app.dispatch(request) + assert response.status == 200 + + request = self.build_request(bot_message_event_payload) + response = app.dispatch(request) + assert response.status == 200 + + request = self.build_request(classic_bot_message_event_payload) + response = app.dispatch(request) + assert response.status == 200 + + assert self.mock_received_requests["/auth.test"] == 1 + time.sleep(1) # wait a bit after auto ack() + assert result["call_count"] == 3 + + +user_message_event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "968c94da-c271-4f2a-8ec9-12a9985e5df4", + "type": "message", + "text": "Hi there! Thanks for sharing the info!", + "user": "W111", + "ts": "1610261659.001400", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "bN8", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Hi there! Thanks for sharing the info!", + } + ], + } + ], + } + ], + "channel": "C111", + "event_ts": "1610261659.001400", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610261659, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} + +bot_message_event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "bot_id": "B999", + "type": "message", + "text": "Hi there! Thanks for sharing the info!", + "user": "UB111", + "ts": "1610261539.000900", + "team": "T111", + "bot_profile": { + "id": "B999", + "deleted": False, + "name": "other-app", + "updated": 1607307935, + "app_id": "A222", + "icons": { + "image_36": "https://a.slack-edge.com/80588/img/plugins/app/bot_36.png", + "image_48": "https://a.slack-edge.com/80588/img/plugins/app/bot_48.png", + "image_72": "https://a.slack-edge.com/80588/img/plugins/app/service_72.png", + }, + "team_id": "T111", + }, + "channel": "C111", + "event_ts": "1610261539.000900", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev222", + "event_time": 1610261539, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "UB111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} + +classic_bot_message_event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "bot_message", + "text": "Hi there! Thanks for sharing the info!", + "ts": "1610262363.001600", + "username": "classic-bot", + "bot_id": "B888", + "channel": "C111", + "event_ts": "1610262363.001600", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev333", + "event_time": 1610262363, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "UB222", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} diff --git a/tests/scenario_tests_async/test_message_bot.py b/tests/scenario_tests_async/test_message_bot.py new file mode 100644 index 000000000..599848bc5 --- /dev/null +++ b/tests/scenario_tests_async/test_message_bot.py @@ -0,0 +1,211 @@ +import asyncio +import json +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +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 TestAsyncMessage: + 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, + ) + + @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) + + 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, event_payload: dict) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(event_payload) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_string_keyword(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + result = {"call_count": 0} + + @app.message("Hi there!") + async def handle_messages(event, logger): + logger.info(event) + result["call_count"] = result["call_count"] + 1 + + request = self.build_request(user_message_event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + request = self.build_request(bot_message_event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + request = self.build_request(classic_bot_message_event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(1) # wait a bit after auto ack() + assert result["call_count"] == 3 + + +user_message_event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "968c94da-c271-4f2a-8ec9-12a9985e5df4", + "type": "message", + "text": "Hi there! Thanks for sharing the info!", + "user": "W111", + "ts": "1610261659.001400", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "bN8", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Hi there! Thanks for sharing the info!", + } + ], + } + ], + } + ], + "channel": "C111", + "event_ts": "1610261659.001400", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610261659, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} + +bot_message_event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "bot_id": "B999", + "type": "message", + "text": "Hi there! Thanks for sharing the info!", + "user": "UB111", + "ts": "1610261539.000900", + "team": "T111", + "bot_profile": { + "id": "B999", + "deleted": False, + "name": "other-app", + "updated": 1607307935, + "app_id": "A222", + "icons": { + "image_36": "https://a.slack-edge.com/80588/img/plugins/app/bot_36.png", + "image_48": "https://a.slack-edge.com/80588/img/plugins/app/bot_48.png", + "image_72": "https://a.slack-edge.com/80588/img/plugins/app/service_72.png", + }, + "team_id": "T111", + }, + "channel": "C111", + "event_ts": "1610261539.000900", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev222", + "event_time": 1610261539, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "UB111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} + +classic_bot_message_event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "bot_message", + "text": "Hi there! Thanks for sharing the info!", + "ts": "1610262363.001600", + "username": "classic-bot", + "bot_id": "B888", + "channel": "C111", + "event_ts": "1610262363.001600", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev333", + "event_time": 1610262363, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "UB222", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} From 3eabe68f9ffa5bc31f24cb5d71dfbccb42b2d7ef Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 12 Jan 2021 19:28:15 +0900 Subject: [PATCH 233/865] Improve #192 by using inspect module methods --- slack_bolt/kwargs_injection/async_utils.py | 10 +++- slack_bolt/kwargs_injection/utils.py | 10 +++- slack_bolt/lazy_listener/async_internals.py | 1 + slack_bolt/lazy_listener/internals.py | 1 + slack_bolt/listener/async_listener.py | 1 + slack_bolt/listener/custom_listener.py | 1 + slack_bolt/listener_matcher/async_builtins.py | 1 + .../async_listener_matcher.py | 1 + slack_bolt/listener_matcher/builtins.py | 1 + .../custom_listener_matcher.py | 1 + .../middleware/async_custom_middleware.py | 1 + slack_bolt/middleware/custom_middleware.py | 1 + .../test_app_using_methods_in_class.py | 49 +++++++++++++++++ .../test_app_using_methods_in_class.py | 53 ++++++++++++++++++- 14 files changed, 127 insertions(+), 5 deletions(-) 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/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") From 51af9e048611deb236241aec894beae65580c77d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 25 Nov 2020 12:40:20 +0900 Subject: [PATCH 234/865] Add Socket Mode support #159 --- examples/socket_mode.py | 39 ++++++++++++++ examples/socket_mode_async.py | 46 ++++++++++++++++ examples/socket_mode_oauth.py | 52 ++++++++++++++++++ setup.py | 2 +- slack_bolt/adapter/socket_mode/__init__.py | 0 .../adapter/socket_mode/aiohttp/__init__.py | 54 +++++++++++++++++++ .../adapter/socket_mode/async_base_handler.py | 29 ++++++++++ .../adapter/socket_mode/async_internals.py | 46 ++++++++++++++++ .../adapter/socket_mode/base_handler.py | 29 ++++++++++ slack_bolt/adapter/socket_mode/internals.py | 47 ++++++++++++++++ .../socket_mode/websocket_client/__init__.py | 30 +++++++++++ .../socket_mode/websockets/__init__.py | 54 +++++++++++++++++++ slack_bolt/version.py | 2 +- 13 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 examples/socket_mode.py create mode 100644 examples/socket_mode_async.py create mode 100644 examples/socket_mode_oauth.py create mode 100644 slack_bolt/adapter/socket_mode/__init__.py create mode 100644 slack_bolt/adapter/socket_mode/aiohttp/__init__.py create mode 100644 slack_bolt/adapter/socket_mode/async_base_handler.py create mode 100644 slack_bolt/adapter/socket_mode/async_internals.py create mode 100644 slack_bolt/adapter/socket_mode/base_handler.py create mode 100644 slack_bolt/adapter/socket_mode/internals.py create mode 100644 slack_bolt/adapter/socket_mode/websocket_client/__init__.py create mode 100644 slack_bolt/adapter/socket_mode/websockets/__init__.py diff --git a/examples/socket_mode.py b/examples/socket_mode.py new file mode 100644 index 000000000..88e407397 --- /dev/null +++ b/examples/socket_mode.py @@ -0,0 +1,39 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging +import os + +from slack_bolt import App +from slack_bolt.adapter.socket_mode.websocket_client 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']}>!") + + +@app.shortcut("socket-mode") +def global_shortcut(ack): + ack() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + + # 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..a98424b0c --- /dev/null +++ b/examples/socket_mode_async.py @@ -0,0 +1,46 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging +import os + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.adapter.socket_mode.aiohttp 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']}>!") + + +@app.shortcut("socket-mode") +async def global_shortcut(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__": + logging.basicConfig(level=logging.DEBUG) + 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..7167db68b --- /dev/null +++ b/examples/socket_mode_oauth.py @@ -0,0 +1,52 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging +import os +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_bolt.adapter.socket_mode.websocket_client 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']}>!") + + +@app.shortcut("socket-mode") +def global_shortcut(ack): + ack() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + 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/setup.py b/setup.py index cb789759a..817629061 100755 --- a/setup.py +++ b/setup.py @@ -33,7 +33,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.0b1,<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..e69de29bb 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..37cecd575 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/aiohttp/__init__.py @@ -0,0 +1,54 @@ +import os +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_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 + app_token: str + client: SocketModeClient + + def __init__( + self, app: App, app_token: Optional[str] = None, + ): + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient(app_token=self.app_token) + 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 + app_token: str + client: SocketModeClient + + def __init__( + self, app: AsyncApp, app_token: Optional[str] = None, + ): + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient(app_token=self.app_token) + 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..62ee0107b --- /dev/null +++ b/slack_bolt/adapter/socket_mode/async_base_handler.py @@ -0,0 +1,29 @@ +import asyncio +import logging + +from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest + + +class AsyncBaseSocketModeHandler: + 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("⚡️ Bolt app is running!") + else: + self.app.logger.info("⚡️ Bolt app is running!") + await asyncio.sleep(float("inf")) 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..cd01ce7fc --- /dev/null +++ b/slack_bolt/adapter/socket_mode/async_internals.py @@ -0,0 +1,46 @@ +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): + 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: int, +): + if bolt_resp.status == 200: + 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 bolt_resp.body.startswith("{"): + await client.send_socket_mode_response( + SocketModeResponse(envelope_id=req.envelope_id, payload=bolt_resp.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..782fafe88 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/base_handler.py @@ -0,0 +1,29 @@ +import logging +from threading import Event + +from slack_sdk.socket_mode.client import BaseSocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest + + +class BaseSocketModeHandler: + 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("⚡️ Bolt app is running!") + else: + self.app.logger.info("⚡️ Bolt app is running!") + Event().wait() diff --git a/slack_bolt/adapter/socket_mode/internals.py b/slack_bolt/adapter/socket_mode/internals.py new file mode 100644 index 000000000..8ea68ed84 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/internals.py @@ -0,0 +1,47 @@ +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): + 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: int, +): + if bolt_resp.status == 200: + if bolt_resp.body is None or len(bolt_resp.body) == 0: + client.send_socket_mode_response( + SocketModeResponse(envelope_id=req.envelope_id) + ) + elif bolt_resp.body.startswith("{"): + client.send_socket_mode_response( + SocketModeResponse(envelope_id=req.envelope_id, payload=bolt_resp.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..433761d75 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/websocket_client/__init__.py @@ -0,0 +1,30 @@ +import os +from time import time +from typing import Optional + +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 + app_token: str + client: SocketModeClient + + def __init__( + self, app: App, app_token: Optional[str] = None, + ): + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient(app_token=self.app_token) + 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..fb9c5933d --- /dev/null +++ b/slack_bolt/adapter/socket_mode/websockets/__init__.py @@ -0,0 +1,54 @@ +import os +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_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 + app_token: str + client: SocketModeClient + + def __init__( + self, app: App, app_token: Optional[str] = None, + ): + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient(app_token=self.app_token) + 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 + app_token: str + client: SocketModeClient + + def __init__( + self, app: AsyncApp, app_token: Optional[str] = None, + ): + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient(app_token=self.app_token) + 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/version.py b/slack_bolt/version.py index 9b102be76..48346736b 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.1.5" +__version__ = "1.2.0b1" From 16a55aba7556d53c5595684d50ccba83eb6b5298 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 1 Dec 2020 23:07:40 +0900 Subject: [PATCH 235/865] version 1.2.0b1 --- examples/socket_mode.py | 73 ++++++++++++++++-- examples/socket_mode_async.py | 74 ++++++++++++++++++- examples/socket_mode_oauth.py | 74 +++++++++++++++++-- slack_bolt/adapter/socket_mode/__init__.py | 2 + .../adapter/socket_mode/aiohttp/__init__.py | 16 ++-- .../adapter/socket_mode/async_base_handler.py | 9 ++- .../adapter/socket_mode/async_handler.py | 1 + .../adapter/socket_mode/async_internals.py | 11 ++- .../adapter/socket_mode/base_handler.py | 3 + .../adapter/socket_mode/builtin/__init__.py | 35 +++++++++ slack_bolt/adapter/socket_mode/internals.py | 11 ++- .../socket_mode/websocket_client/__init__.py | 13 +++- .../socket_mode/websockets/__init__.py | 16 ++-- 13 files changed, 299 insertions(+), 39 deletions(-) create mode 100644 slack_bolt/adapter/socket_mode/async_handler.py create mode 100644 slack_bolt/adapter/socket_mode/builtin/__init__.py diff --git a/examples/socket_mode.py b/examples/socket_mode.py index 88e407397..e4b2f10ba 100644 --- a/examples/socket_mode.py +++ b/examples/socket_mode.py @@ -6,10 +6,13 @@ # ------------------------------------------------ import logging + +logging.basicConfig(level=logging.DEBUG) + import os from slack_bolt import App -from slack_bolt.adapter.socket_mode.websocket_client import SocketModeHandler +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"]) @@ -26,14 +29,74 @@ def event_test(event, say): say(f"Hi there, <@{event['user']}>!") -@app.shortcut("socket-mode") -def global_shortcut(ack): +def ack_shortcut(ack): ack() -if __name__ == "__main__": - logging.basicConfig(level=logging.DEBUG) +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 index a98424b0c..99bb0ae69 100644 --- a/examples/socket_mode_async.py +++ b/examples/socket_mode_async.py @@ -7,10 +7,13 @@ # ------------------------------------------------ import logging + +logging.basicConfig(level=logging.DEBUG) + import os from slack_bolt.app.async_app import AsyncApp -from slack_bolt.adapter.socket_mode.aiohttp import AsyncSocketModeHandler +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"]) @@ -27,20 +30,83 @@ async def event_test(event, say): await say(f"Hi there, <@{event['user']}>!") -@app.shortcut("socket-mode") -async def global_shortcut(ack): +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__": - logging.basicConfig(level=logging.DEBUG) import asyncio + asyncio.run(main()) diff --git a/examples/socket_mode_oauth.py b/examples/socket_mode_oauth.py index 7167db68b..fd51b3150 100644 --- a/examples/socket_mode_oauth.py +++ b/examples/socket_mode_oauth.py @@ -6,10 +6,13 @@ # ------------------------------------------------ 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.websocket_client import SocketModeHandler +from slack_bolt.adapter.socket_mode import SocketModeHandler app = App( signing_secret=os.environ["SLACK_SIGNING_SECRET"], @@ -17,7 +20,7 @@ client_id=os.environ["SLACK_CLIENT_ID"], client_secret=os.environ["SLACK_CLIENT_SECRET"], scopes=os.environ["SLACK_SCOPES"].split(","), - ) + ), ) @@ -32,13 +35,74 @@ def event_test(event, say): say(f"Hi there, <@{event['user']}>!") -@app.shortcut("socket-mode") -def global_shortcut(ack): +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__": - logging.basicConfig(level=logging.DEBUG) SocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")).connect() app.start() diff --git a/slack_bolt/adapter/socket_mode/__init__.py b/slack_bolt/adapter/socket_mode/__init__.py index e69de29bb..94223be4d 100644 --- a/slack_bolt/adapter/socket_mode/__init__.py +++ 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 index 37cecd575..e8c9c81b4 100644 --- a/slack_bolt/adapter/socket_mode/aiohttp/__init__.py +++ b/slack_bolt/adapter/socket_mode/aiohttp/__init__.py @@ -17,12 +17,14 @@ class SocketModeHandler(AsyncBaseSocketModeHandler): - app: App + app: App # type: ignore app_token: str client: SocketModeClient - def __init__( - self, app: App, app_token: Optional[str] = None, + def __init__( # type: ignore + self, + app: App, # type: ignore + app_token: Optional[str] = None, ): self.app = app self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] @@ -36,12 +38,14 @@ async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None class AsyncSocketModeHandler(AsyncBaseSocketModeHandler): - app: AsyncApp + app: AsyncApp # type: ignore app_token: str client: SocketModeClient - def __init__( - self, app: AsyncApp, app_token: Optional[str] = None, + def __init__( # type: ignore + self, + app: AsyncApp, # type: ignore + app_token: Optional[str] = None, ): self.app = app self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] diff --git a/slack_bolt/adapter/socket_mode/async_base_handler.py b/slack_bolt/adapter/socket_mode/async_base_handler.py index 62ee0107b..2837a3d09 100644 --- a/slack_bolt/adapter/socket_mode/async_base_handler.py +++ b/slack_bolt/adapter/socket_mode/async_base_handler.py @@ -1,14 +1,21 @@ 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 + class AsyncBaseSocketModeHandler: + app: Union[App, AsyncApp] # type: ignore client: AsyncBaseSocketModeClient - async def handle(self, client: AsyncBaseSocketModeClient, req: SocketModeRequest) -> None: + async def handle( + self, client: AsyncBaseSocketModeClient, req: SocketModeRequest + ) -> None: raise NotImplementedError() async def connect_async(self): 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 index cd01ce7fc..e11a923d6 100644 --- a/slack_bolt/adapter/socket_mode/async_internals.py +++ b/slack_bolt/adapter/socket_mode/async_internals.py @@ -1,3 +1,4 @@ +import json import logging from time import time @@ -10,7 +11,7 @@ from slack_bolt.response import BoltResponse -async def run_async_bolt_app(app: AsyncApp, req: SocketModeRequest): +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 @@ -20,16 +21,18 @@ async def send_async_response( client: AsyncBaseSocketModeClient, req: SocketModeRequest, bolt_resp: BoltResponse, - start_time: int, + 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 bolt_resp.body.startswith("{"): + 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=bolt_resp.body,) + SocketModeResponse(envelope_id=req.envelope_id, payload=dict_body) ) else: await client.send_socket_mode_response( diff --git a/slack_bolt/adapter/socket_mode/base_handler.py b/slack_bolt/adapter/socket_mode/base_handler.py index 782fafe88..1cc6b2cc1 100644 --- a/slack_bolt/adapter/socket_mode/base_handler.py +++ b/slack_bolt/adapter/socket_mode/base_handler.py @@ -4,8 +4,11 @@ from slack_sdk.socket_mode.client import BaseSocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest +from slack_bolt import App + class BaseSocketModeHandler: + app: App # type: ignore client: BaseSocketModeClient def handle(self, client: BaseSocketModeClient, req: SocketModeRequest) -> None: 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..c9dacee1b --- /dev/null +++ b/slack_bolt/adapter/socket_mode/builtin/__init__.py @@ -0,0 +1,35 @@ +import os +from time import time +from typing import Optional + +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, + 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, 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/internals.py b/slack_bolt/adapter/socket_mode/internals.py index 8ea68ed84..4f7f4d68c 100644 --- a/slack_bolt/adapter/socket_mode/internals.py +++ b/slack_bolt/adapter/socket_mode/internals.py @@ -1,3 +1,4 @@ +import json import logging from time import time @@ -10,7 +11,7 @@ from slack_bolt.response import BoltResponse -def run_bolt_app(app: App, req: SocketModeRequest): +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 @@ -20,16 +21,18 @@ def send_response( client: BaseSocketModeClient, req: SocketModeRequest, bolt_resp: BoltResponse, - start_time: int, + 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 bolt_resp.body.startswith("{"): + 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=bolt_resp.body,) + SocketModeResponse(envelope_id=req.envelope_id, payload=dict_body) ) else: client.send_socket_mode_response( diff --git a/slack_bolt/adapter/socket_mode/websocket_client/__init__.py b/slack_bolt/adapter/socket_mode/websocket_client/__init__.py index 433761d75..d9052a465 100644 --- a/slack_bolt/adapter/socket_mode/websocket_client/__init__.py +++ b/slack_bolt/adapter/socket_mode/websocket_client/__init__.py @@ -12,16 +12,21 @@ class SocketModeHandler(BaseSocketModeHandler): - app: App + app: App # type: ignore app_token: str client: SocketModeClient - def __init__( - self, app: App, app_token: Optional[str] = None, + def __init__( # type: ignore + self, + app: App, # type: ignore + app_token: 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) + self.client = SocketModeClient( + app_token=self.app_token, trace_enabled=trace_enabled + ) self.client.socket_mode_request_listeners.append(self.handle) def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: diff --git a/slack_bolt/adapter/socket_mode/websockets/__init__.py b/slack_bolt/adapter/socket_mode/websockets/__init__.py index fb9c5933d..bf691ce1e 100644 --- a/slack_bolt/adapter/socket_mode/websockets/__init__.py +++ b/slack_bolt/adapter/socket_mode/websockets/__init__.py @@ -17,12 +17,14 @@ class SocketModeHandler(AsyncBaseSocketModeHandler): - app: App + app: App # type: ignore app_token: str client: SocketModeClient - def __init__( - self, app: App, app_token: Optional[str] = None, + def __init__( # type: ignore + self, + app: App, # type: ignore + app_token: Optional[str] = None, ): self.app = app self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] @@ -36,12 +38,14 @@ async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None class AsyncSocketModeHandler(AsyncBaseSocketModeHandler): - app: AsyncApp + app: AsyncApp # type: ignore app_token: str client: SocketModeClient - def __init__( - self, app: AsyncApp, app_token: Optional[str] = None, + def __init__( # type: ignore + self, + app: AsyncApp, # type: ignore + app_token: Optional[str] = None, ): self.app = app self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] From d8a98ab8c8c264d0e30cc88ffaf843df0e70d497 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 3 Dec 2020 20:19:36 +0900 Subject: [PATCH 236/865] version 1.2.0b2 --- setup.py | 2 +- slack_bolt/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 817629061..9ef37e5fb 100755 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.2.0b1,<3.3",], + install_requires=["slack_sdk>=3.2.0b3,<3.3",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 48346736b..9d9fa0a11 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.2.0b1" +__version__ = "1.2.0b2" From b183b0ccd5a2d1906486d106c4c89080d584c3fe Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 8 Dec 2020 22:06:39 +0900 Subject: [PATCH 237/865] version 1.2.0b3 --- setup.py | 2 +- .../adapter/socket_mode/aiohttp/__init__.py | 14 ++++++++++- .../adapter/socket_mode/builtin/__init__.py | 15 +++++++++++- .../socket_mode/websocket_client/__init__.py | 23 +++++++++++++++++-- .../socket_mode/websockets/__init__.py | 12 +++++++++- slack_bolt/version.py | 2 +- 6 files changed, 61 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index 9ef37e5fb..12a0e40eb 100755 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.2.0b3,<3.3",], + install_requires=["slack_sdk>=3.2.0b5,<3.3",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/adapter/socket_mode/aiohttp/__init__.py b/slack_bolt/adapter/socket_mode/aiohttp/__init__.py index e8c9c81b4..5b26fcb9b 100644 --- a/slack_bolt/adapter/socket_mode/aiohttp/__init__.py +++ b/slack_bolt/adapter/socket_mode/aiohttp/__init__.py @@ -1,9 +1,11 @@ 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 @@ -25,10 +27,20 @@ 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) + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger, + web_client=web_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: diff --git a/slack_bolt/adapter/socket_mode/builtin/__init__.py b/slack_bolt/adapter/socket_mode/builtin/__init__.py index c9dacee1b..6659073db 100644 --- a/slack_bolt/adapter/socket_mode/builtin/__init__.py +++ b/slack_bolt/adapter/socket_mode/builtin/__init__.py @@ -1,7 +1,9 @@ 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 @@ -20,12 +22,23 @@ 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, + proxy: 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, trace_enabled=trace_enabled + app_token=self.app_token, + logger=logger, + web_client=web_client, + proxy=proxy, + ping_interval=ping_interval, + concurrency=concurrency, + trace_enabled=trace_enabled, ) self.client.socket_mode_request_listeners.append(self.handle) diff --git a/slack_bolt/adapter/socket_mode/websocket_client/__init__.py b/slack_bolt/adapter/socket_mode/websocket_client/__init__.py index d9052a465..432e09651 100644 --- a/slack_bolt/adapter/socket_mode/websocket_client/__init__.py +++ b/slack_bolt/adapter/socket_mode/websocket_client/__init__.py @@ -1,7 +1,9 @@ import os +from logging import Logger from time import time -from typing import Optional +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 @@ -20,12 +22,29 @@ 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, trace_enabled=trace_enabled + app_token=self.app_token, + logger=logger, + web_client=web_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) diff --git a/slack_bolt/adapter/socket_mode/websockets/__init__.py b/slack_bolt/adapter/socket_mode/websockets/__init__.py index bf691ce1e..ba8cd5879 100644 --- a/slack_bolt/adapter/socket_mode/websockets/__init__.py +++ b/slack_bolt/adapter/socket_mode/websockets/__init__.py @@ -1,9 +1,11 @@ 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 @@ -25,10 +27,18 @@ 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) + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger, + web_client=web_client, + ping_interval=ping_interval, + ) self.client.socket_mode_request_listeners.append(self.handle) async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 9d9fa0a11..2aa5cc32a 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.2.0b2" +__version__ = "1.2.0b3" From e70705a36ccff0536551248be8282061ba44f0ec Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 14 Dec 2020 09:33:21 +0900 Subject: [PATCH 238/865] version 1.2.0b4 --- setup.py | 2 +- .../adapter/socket_mode/aiohttp/__init__.py | 16 +++++++++++++--- .../adapter/socket_mode/builtin/__init__.py | 6 +++--- .../socket_mode/websocket_client/__init__.py | 4 ++-- .../adapter/socket_mode/websockets/__init__.py | 14 +++++++++++--- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/setup.py b/setup.py index 12a0e40eb..3750ba420 100755 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.2.0b5,<3.3",], + install_requires=["slack_sdk>=3.2.0b6,<3.3",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/adapter/socket_mode/aiohttp/__init__.py b/slack_bolt/adapter/socket_mode/aiohttp/__init__.py index 5b26fcb9b..0e3038c75 100644 --- a/slack_bolt/adapter/socket_mode/aiohttp/__init__.py +++ b/slack_bolt/adapter/socket_mode/aiohttp/__init__.py @@ -36,8 +36,8 @@ def __init__( # type: ignore self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] self.client = SocketModeClient( app_token=self.app_token, - logger=logger, - web_client=web_client, + 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, ) @@ -58,10 +58,20 @@ 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) + 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: diff --git a/slack_bolt/adapter/socket_mode/builtin/__init__.py b/slack_bolt/adapter/socket_mode/builtin/__init__.py index 6659073db..54b4fbf9c 100644 --- a/slack_bolt/adapter/socket_mode/builtin/__init__.py +++ b/slack_bolt/adapter/socket_mode/builtin/__init__.py @@ -33,9 +33,9 @@ def __init__( # type: ignore self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] self.client = SocketModeClient( app_token=self.app_token, - logger=logger, - web_client=web_client, - proxy=proxy, + 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, ping_interval=ping_interval, concurrency=concurrency, trace_enabled=trace_enabled, diff --git a/slack_bolt/adapter/socket_mode/websocket_client/__init__.py b/slack_bolt/adapter/socket_mode/websocket_client/__init__.py index 432e09651..589edb80d 100644 --- a/slack_bolt/adapter/socket_mode/websocket_client/__init__.py +++ b/slack_bolt/adapter/socket_mode/websocket_client/__init__.py @@ -36,8 +36,8 @@ def __init__( # type: ignore self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] self.client = SocketModeClient( app_token=self.app_token, - logger=logger, - web_client=web_client, + 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, diff --git a/slack_bolt/adapter/socket_mode/websockets/__init__.py b/slack_bolt/adapter/socket_mode/websockets/__init__.py index ba8cd5879..878eaf5e6 100644 --- a/slack_bolt/adapter/socket_mode/websockets/__init__.py +++ b/slack_bolt/adapter/socket_mode/websockets/__init__.py @@ -35,8 +35,8 @@ def __init__( # type: ignore self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] self.client = SocketModeClient( app_token=self.app_token, - logger=logger, - web_client=web_client, + 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) @@ -56,10 +56,18 @@ 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) + 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: From ae08015450938219a60c3a06bd3eb076ead5ae4e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 10 Jan 2021 02:16:25 +0900 Subject: [PATCH 239/865] Add tests --- codecov.yml | 2 +- setup.py | 3 +- .../adapter/socket_mode/async_internals.py | 3 +- slack_bolt/version.py | 2 +- tests/adapter_tests/socket_mode/__init__.py | 0 .../socket_mode/mock_socket_mode_server.py | 42 +++++ .../socket_mode/mock_web_api_server.py | 153 ++++++++++++++++++ .../socket_mode/test_interactions_builtin.py | 75 +++++++++ .../test_interactions_web_client.py | 74 +++++++++ .../socket_mode/__init__.py | 0 .../socket_mode/test_async_aiohttp.py | 78 +++++++++ .../socket_mode/test_async_websockets.py | 78 +++++++++ 12 files changed, 506 insertions(+), 4 deletions(-) create mode 100644 tests/adapter_tests/socket_mode/__init__.py create mode 100644 tests/adapter_tests/socket_mode/mock_socket_mode_server.py create mode 100644 tests/adapter_tests/socket_mode/mock_web_api_server.py create mode 100644 tests/adapter_tests/socket_mode/test_interactions_builtin.py create mode 100644 tests/adapter_tests/socket_mode/test_interactions_web_client.py create mode 100644 tests/adapter_tests_async/socket_mode/__init__.py create mode 100644 tests/adapter_tests_async/socket_mode/test_async_aiohttp.py create mode 100644 tests/adapter_tests_async/socket_mode/test_async_websockets.py 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/setup.py b/setup.py index 3750ba420..74e401edc 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.2.0b6,<3.3",], + install_requires=["slack_sdk>=3.2.0b7,<3.3",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/adapter/socket_mode/async_internals.py b/slack_bolt/adapter/socket_mode/async_internals.py index e11a923d6..5b5750bd1 100644 --- a/slack_bolt/adapter/socket_mode/async_internals.py +++ b/slack_bolt/adapter/socket_mode/async_internals.py @@ -37,7 +37,8 @@ async def send_async_response( else: await client.send_socket_mode_response( SocketModeResponse( - envelope_id=req.envelope_id, payload={"text": bolt_resp.body}, + envelope_id=req.envelope_id, + payload={"text": bolt_resp.body}, ) ) if client.logger.level <= logging.DEBUG: diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 2aa5cc32a..c27610a64 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.2.0b3" +__version__ = "1.2.0b4 " 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() From c28f23110c2166df69a86136d5e37bf769c34dac Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 10 Jan 2021 02:19:58 +0900 Subject: [PATCH 240/865] Fix CI builds --- .github/workflows/ci-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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/ From e5800a8a6bca7061dd05afa0d669876ab7f72967 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 10 Jan 2021 02:28:16 +0900 Subject: [PATCH 241/865] Replace the boot message --- slack_bolt/adapter/socket_mode/async_base_handler.py | 5 +++-- slack_bolt/adapter/socket_mode/base_handler.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/slack_bolt/adapter/socket_mode/async_base_handler.py b/slack_bolt/adapter/socket_mode/async_base_handler.py index 2837a3d09..836074fad 100644 --- a/slack_bolt/adapter/socket_mode/async_base_handler.py +++ b/slack_bolt/adapter/socket_mode/async_base_handler.py @@ -7,6 +7,7 @@ from slack_bolt import App from slack_bolt.app.async_app import AsyncApp +from slack_bolt.util.utils import get_boot_message class AsyncBaseSocketModeHandler: @@ -30,7 +31,7 @@ async def close_async(self): async def start_async(self): await self.connect_async() if self.app.logger.level > logging.INFO: - print("⚡️ Bolt app is running!") + print(get_boot_message()) else: - self.app.logger.info("⚡️ Bolt app is running!") + self.app.logger.info(get_boot_message()) await asyncio.sleep(float("inf")) diff --git a/slack_bolt/adapter/socket_mode/base_handler.py b/slack_bolt/adapter/socket_mode/base_handler.py index 1cc6b2cc1..99568754e 100644 --- a/slack_bolt/adapter/socket_mode/base_handler.py +++ b/slack_bolt/adapter/socket_mode/base_handler.py @@ -5,6 +5,7 @@ from slack_sdk.socket_mode.request import SocketModeRequest from slack_bolt import App +from slack_bolt.util.utils import get_boot_message class BaseSocketModeHandler: @@ -26,7 +27,7 @@ def close(self): def start(self): self.connect() if self.app.logger.level > logging.INFO: - print("⚡️ Bolt app is running!") + print(get_boot_message()) else: - self.app.logger.info("⚡️ Bolt app is running!") + self.app.logger.info(get_boot_message()) Event().wait() From 0cda62a4c4c05e842b43e5439b7d59d276472ced Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 10 Jan 2021 14:43:01 +0900 Subject: [PATCH 242/865] version 1.2.0rc1 --- scripts/uninstall_all.sh | 3 ++- setup.py | 2 +- slack_bolt/version.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) 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 74e401edc..42becd291 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.2.0b7,<3.3",], + install_requires=["slack_sdk>=3.2.0rc1,<3.3",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index c27610a64..e342e4999 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.2.0b4 " +__version__ = "1.2.0rc1" From 100f8473b46b4c706bbc60520cb855d3f6c53bad Mon Sep 17 00:00:00 2001 From: Steve Gill Date: Thu, 7 Jan 2021 16:14:24 -0800 Subject: [PATCH 243/865] added inital socket mode document --- docs/_basic/socket_mode.md | 55 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/_basic/socket_mode.md 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). From 5cb3162b847eddb6cb7885b0b2bb600d581771ad Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 12 Jan 2021 22:34:34 +0900 Subject: [PATCH 244/865] version 1.2.0rc2 --- setup.py | 2 +- slack_bolt/adapter/socket_mode/builtin/__init__.py | 14 +++++++++++--- slack_bolt/version.py | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 42becd291..b2ba603d1 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.2.0rc1,<3.3",], + 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/builtin/__init__.py b/slack_bolt/adapter/socket_mode/builtin/__init__.py index 54b4fbf9c..242a4d5fa 100644 --- a/slack_bolt/adapter/socket_mode/builtin/__init__.py +++ b/slack_bolt/adapter/socket_mode/builtin/__init__.py @@ -24,10 +24,14 @@ def __init__( # type: ignore app_token: Optional[str] = None, logger: Optional[Logger] = None, web_client: Optional[WebClient] = None, - ping_interval: float = 10, - concurrency: int = 10, 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"] @@ -36,9 +40,13 @@ def __init__( # type: ignore 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, - trace_enabled=trace_enabled, ) self.client.socket_mode_request_listeners.append(self.handle) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index e342e4999..4ab11ba1c 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.2.0rc1" +__version__ = "1.2.0rc2" From 1c57dff471d0fbb14e8806ca1c6624876efa0af4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 13 Jan 2021 07:11:44 +0900 Subject: [PATCH 245/865] version 1.2.0 --- docs/_basic/socket_mode.md | 2 +- setup.py | 2 +- slack_bolt/version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/_basic/socket_mode.md b/docs/_basic/socket_mode.md index 323a5e6be..f4191c696 100644 --- a/docs/_basic/socket_mode.md +++ b/docs/_basic/socket_mode.md @@ -6,7 +6,7 @@ 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. +With the introduction of [Socket Mode](https://api.slack.com/apis/connections/socket), 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.
    diff --git a/setup.py b/setup.py index b2ba603d1..4e34c5811 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.2.0rc2,<3.3",], + install_requires=["slack_sdk>=3.2.0,<3.3",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 4ab11ba1c..c68196d1c 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.2.0rc2" +__version__ = "1.2.0" From c18e0d84f4b3bd48d42628244c9f68a75e174514 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 13 Jan 2021 07:58:46 +0900 Subject: [PATCH 246/865] Fix Socket Mode document --- docs/_basic/socket_mode.md | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/_basic/socket_mode.md b/docs/_basic/socket_mode.md index f4191c696..da7f1d024 100644 --- a/docs/_basic/socket_mode.md +++ b/docs/_basic/socket_mode.md @@ -9,6 +9,16 @@ order: 16 With the introduction of [Socket Mode](https://api.slack.com/apis/connections/socket), 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. + +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)| + ```python @@ -19,23 +29,26 @@ 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"]) +# Add middleware / listeners here + if __name__ == "__main__": # export SLACK_APP_TOKEN=xapp-*** # export SLACK_BOT_TOKEN=xoxb-*** - SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() + handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + handler.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)| +
    + +

    Using Async

    +
    +
    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. +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). +
    + ```python from slack_bolt.app.async_app import AsyncApp # The default is the aiohttp based implementation @@ -43,6 +56,8 @@ from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) +# Add middleware / listeners here + async def main(): handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) await handler.start_async() @@ -52,4 +67,4 @@ if __name__ == "__main__": 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). +
    From dec80e35b31def6688cb17a08b27217cf4867ff7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 13 Jan 2021 08:39:19 +0900 Subject: [PATCH 247/865] Fix #198 bug where str subtype constraint does not work --- slack_bolt/listener_matcher/builtins.py | 4 + tests/scenario_tests/test_events.py | 157 +++++++++++++++++++++ tests/scenario_tests_async/test_events.py | 161 ++++++++++++++++++++++ 3 files changed, 322 insertions(+) diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 50b64fea6..ae1d52dc2 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -102,6 +102,10 @@ def func(body: Dict[str, Any]) -> bool: if expected_subtype is None: # "subtype" in constraints is intentionally None for this pattern return "subtype" not in event + elif isinstance(expected_subtype, (str, Pattern)): + return "subtype" in event and _matches( + expected_subtype, event["subtype"] + ) elif isinstance(expected_subtype, Sequence): subtypes: Sequence[ Optional[Union[str, Pattern]] diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index 12ec82742..0ce15d255 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -1,4 +1,5 @@ import json +import re from time import time, sleep from slack_sdk.signature import SignatureVerifier @@ -396,3 +397,159 @@ def handler2(say: Say): assert self.mock_received_requests["/auth.test"] == 1 sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 2 + + message_file_share_body = { + "token": "verification-token", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "message", + "text": "Here is your file!", + "files": [ + { + "id": "F111", + "created": 1610493713, + "timestamp": 1610493713, + "name": "test.png", + "title": "test.png", + "mimetype": "image/png", + "filetype": "png", + "pretty_type": "PNG", + "user": "U111", + "editable": False, + "size": 42706, + "mode": "hosted", + "is_external": False, + "external_type": "", + "is_public": False, + "public_url_shared": False, + "display_as_bot": False, + "username": "", + "url_private": "https://files.slack.com/files-pri/T111-F111/test.png", + "url_private_download": "https://files.slack.com/files-pri/T111-F111/download/test.png", + "thumb_64": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_64.png", + "thumb_80": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_80.png", + "thumb_360": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_360.png", + "thumb_360_w": 358, + "thumb_360_h": 360, + "thumb_480": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_480.png", + "thumb_480_w": 477, + "thumb_480_h": 480, + "thumb_160": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_160.png", + "thumb_720": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_720.png", + "thumb_720_w": 716, + "thumb_720_h": 720, + "original_w": 736, + "original_h": 740, + "thumb_tiny": "xxx", + "permalink": "https://xxx.slack.com/files/U111/F111/test.png", + "permalink_public": "https://slack-files.com/T111-F111-3e534ef8ca", + "has_rich_preview": False, + } + ], + "upload": False, + "blocks": [ + { + "type": "rich_text", + "block_id": "gvM", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "text", "text": "Here is your file!"} + ], + } + ], + } + ], + "user": "U111", + "display_as_bot": False, + "ts": "1610493715.001000", + "channel": "G111", + "subtype": "file_share", + "event_ts": "1610493715.001000", + "channel_type": "group", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610493715, + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T111", + "user_id": "U111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-G111", + } + + def test_message_subtypes_0(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app._client = WebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event({"type": "message", "subtype": "file_share"}) + def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + + def test_message_subtypes_1(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app._client = WebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event({"type": "message", "subtype": re.compile("file_.+")}) + def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + + def test_message_subtypes_2(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app._client = WebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event({"type": "message", "subtype": ["file_share"]}) + def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + + def test_message_subtypes_3(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app._client = WebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event("message") + def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 diff --git a/tests/scenario_tests_async/test_events.py b/tests/scenario_tests_async/test_events.py index 346883bee..9dda0b052 100644 --- a/tests/scenario_tests_async/test_events.py +++ b/tests/scenario_tests_async/test_events.py @@ -1,5 +1,6 @@ import asyncio import json +import re from random import random from time import time @@ -400,6 +401,166 @@ async def handler2(say: AsyncSay): await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 2 + message_file_share_body = { + "token": "verification-token", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "message", + "text": "Here is your file!", + "files": [ + { + "id": "F111", + "created": 1610493713, + "timestamp": 1610493713, + "name": "test.png", + "title": "test.png", + "mimetype": "image/png", + "filetype": "png", + "pretty_type": "PNG", + "user": "U111", + "editable": False, + "size": 42706, + "mode": "hosted", + "is_external": False, + "external_type": "", + "is_public": False, + "public_url_shared": False, + "display_as_bot": False, + "username": "", + "url_private": "https://files.slack.com/files-pri/T111-F111/test.png", + "url_private_download": "https://files.slack.com/files-pri/T111-F111/download/test.png", + "thumb_64": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_64.png", + "thumb_80": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_80.png", + "thumb_360": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_360.png", + "thumb_360_w": 358, + "thumb_360_h": 360, + "thumb_480": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_480.png", + "thumb_480_w": 477, + "thumb_480_h": 480, + "thumb_160": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_160.png", + "thumb_720": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_720.png", + "thumb_720_w": 716, + "thumb_720_h": 720, + "original_w": 736, + "original_h": 740, + "thumb_tiny": "xxx", + "permalink": "https://xxx.slack.com/files/U111/F111/test.png", + "permalink_public": "https://slack-files.com/T111-F111-3e534ef8ca", + "has_rich_preview": False, + } + ], + "upload": False, + "blocks": [ + { + "type": "rich_text", + "block_id": "gvM", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "text", "text": "Here is your file!"} + ], + } + ], + } + ], + "user": "U111", + "display_as_bot": False, + "ts": "1610493715.001000", + "channel": "G111", + "subtype": "file_share", + "event_ts": "1610493715.001000", + "channel_type": "group", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610493715, + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T111", + "user_id": "U111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-G111", + } + + @pytest.mark.asyncio + async def test_message_subtypes_0(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app._client = AsyncWebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event({"type": "message", "subtype": "file_share"}) + async def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_message_subtypes_1(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app._client = AsyncWebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event({"type": "message", "subtype": re.compile("file_.+")}) + async def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_message_subtypes_2(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app._client = AsyncWebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event({"type": "message", "subtype": ["file_share"]}) + async def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_message_subtypes_3(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app._client = AsyncWebClient( + token="uninstalled-revoked", base_url=self.mock_api_server_base_url + ) + + @app.event("message") + async def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + app_mention_body = { "token": "verification_token", From a11f6d1f4e4591de3215f40f04f66bbc2e1a226e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 13 Jan 2021 08:51:36 +0900 Subject: [PATCH 248/865] version 1.2.1 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index c68196d1c..a955fdae1 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.2.0" +__version__ = "1.2.1" From 16d2908a064f58083b2a3859f360e2404c0e7d94 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 18 Jan 2021 15:27:46 +0900 Subject: [PATCH 249/865] Fix #208 by updating AsyncOAuthFlow --- slack_bolt/oauth/async_oauth_flow.py | 14 +++++++++++++- slack_bolt/oauth/oauth_flow.py | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index dc2d0f094..42a98c9e5 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -308,6 +308,9 @@ async def run_installation(self, code: str) -> Optional[Installation]: installed_enterprise: Dict[str, str] = ( oauth_response.get("enterprise") or {} ) + is_enterprise_install: bool = ( + oauth_response.get("is_enterprise_install") or False + ) installed_team: Dict[str, str] = oauth_response.get("team") or {} installer: Dict[str, str] = oauth_response.get("authed_user") or {} incoming_webhook: Dict[str, str] = ( @@ -317,14 +320,20 @@ async def run_installation(self, code: str) -> Optional[Installation]: bot_token: Optional[str] = oauth_response.get("access_token") # NOTE: oauth.v2.access doesn't include bot_id in response bot_id: Optional[str] = None + enterprise_url: Optional[str] = None if bot_token is not None: auth_test = await self.client.auth_test(token=bot_token) bot_id = auth_test["bot_id"] + if is_enterprise_install is True: + enterprise_url = auth_test.get("url") return Installation( app_id=oauth_response.get("app_id"), enterprise_id=installed_enterprise.get("id"), + enterprise_name=installed_enterprise.get("name"), + enterprise_url=enterprise_url, team_id=installed_team.get("id"), + team_name=installed_team.get("name"), bot_token=bot_token, bot_id=bot_id, bot_user_id=oauth_response.get("bot_user_id"), @@ -333,10 +342,13 @@ async def run_installation(self, code: str) -> Optional[Installation]: user_token=installer.get("access_token"), user_scopes=installer.get("scope"), # comma-separated string incoming_webhook_url=incoming_webhook.get("url"), + incoming_webhook_channel=incoming_webhook.get("channel"), incoming_webhook_channel_id=incoming_webhook.get("channel_id"), incoming_webhook_configuration_url=incoming_webhook.get( - "configuration_url", None + "configuration_url" ), + is_enterprise_install=is_enterprise_install, + token_type=oauth_response.get("token_type"), ) except SlackApiError as e: diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index 867c90761..c03f406d2 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -340,7 +340,7 @@ def run_installation(self, code: str) -> Optional[Installation]: incoming_webhook_channel=incoming_webhook.get("channel"), incoming_webhook_channel_id=incoming_webhook.get("channel_id"), incoming_webhook_configuration_url=incoming_webhook.get( - "configuration_url", None + "configuration_url" ), is_enterprise_install=is_enterprise_install, token_type=oauth_response.get("token_type"), From 90bfe6ab19e7dff80c615ebe3857b237c34028c6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 18 Jan 2021 15:34:10 +0900 Subject: [PATCH 250/865] Fix #210 by correcting a type hint in async OAuth classes --- slack_bolt/oauth/async_oauth_settings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index 948bf7547..59aff5fae 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -19,8 +19,8 @@ AsyncAuthorize, ) from slack_bolt.error import BoltError +from slack_bolt.oauth.async_callback_options import AsyncCallbackOptions from slack_bolt.oauth.async_internals import get_or_create_default_installation_store -from slack_bolt.oauth.callback_options import CallbackOptions class AsyncOAuthSettings: @@ -34,7 +34,7 @@ class AsyncOAuthSettings: install_path: str install_page_rendering_enabled: bool redirect_uri_path: str - callback_options: Optional[CallbackOptions] = None + callback_options: Optional[AsyncCallbackOptions] = None success_url: Optional[str] failure_url: Optional[str] authorization_url: str # default: https://slack.com/oauth/v2/authorize @@ -66,7 +66,7 @@ def __init__( install_path: str = "/slack/install", install_page_rendering_enabled: bool = True, redirect_uri_path: str = "/slack/oauth_redirect", - callback_options: Optional[CallbackOptions] = None, + callback_options: Optional[AsyncCallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, From e0422735a918798af003186ce7eaae06fc831933 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 12 Jan 2021 07:51:30 +0900 Subject: [PATCH 251/865] Improvement related to #199: add event type name validation --- slack_bolt/listener_matcher/builtins.py | 10 ++++++++ slack_bolt/logger/messages.py | 8 ++++++ tests/scenario_tests/test_events.py | 28 ++++++++++++++++++++ tests/scenario_tests_async/test_events.py | 31 +++++++++++++++++++++++ 4 files changed, 77 insertions(+) diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index ae1d52dc2..cffcf95c1 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -21,6 +21,7 @@ to_action, is_workflow_step_save, ) +from ..logger.messages import error_message_event_type if sys.version_info.major == 3 and sys.version_info.minor <= 6: from re import _pattern_type as Pattern @@ -82,6 +83,7 @@ def event( ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): event_type: Union[str, Pattern] = constraints + _verify_message_event_type(event_type) def func(body: Dict[str, Any]) -> bool: return is_event(body) and _matches(event_type, body["event"]["type"]) @@ -89,6 +91,7 @@ def func(body: Dict[str, Any]) -> bool: return build_listener_matcher(func, asyncio) elif "type" in constraints: + _verify_message_event_type(constraints["type"]) def func(body: Dict[str, Any]) -> bool: if is_event(body): @@ -132,6 +135,13 @@ def func(body: Dict[str, Any]) -> bool: ) +def _verify_message_event_type(event_type: str) -> None: + if isinstance(event_type, str) and event_type.startswith("message."): + raise ValueError(error_message_event_type(event_type)) + if isinstance(event_type, Pattern) and "message\\." in event_type.pattern: + raise ValueError(error_message_event_type(event_type)) + + def workflow_step_execute( callback_id: Union[str, Pattern], asyncio: bool = False, diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 9be35c486..f7bc8af7e 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -50,6 +50,14 @@ def error_authorize_conflicts() -> str: return "`authorize` in the top-level arguments is not allowed when you pass either `oauth_settings` or `oauth_flow`" +def error_message_event_type(event_type: str) -> str: + return ( + f'Although the document mentions "{event_type}", ' + 'it is not a valid event type. Use "message" instead. ' + "If you want to filter message events, you can use `event.channel_type` for it." + ) + + # ------------------------------- # Warning # ------------------------------- diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index 0ce15d255..c80a23bff 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -2,6 +2,7 @@ import re from time import time, sleep +import pytest from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient @@ -553,3 +554,30 @@ def handler1(event): ) response = app.dispatch(request) assert response.status == 200 + + # https://github.com/slackapi/bolt-python/issues/199 + def test_invalid_message_events(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + def handle(): + pass + + # valid + app.event("message")(handle) + + with pytest.raises(ValueError): + app.event("message.channels")(handle) + with pytest.raises(ValueError): + app.event("message.groups")(handle) + with pytest.raises(ValueError): + app.event("message.im")(handle) + with pytest.raises(ValueError): + app.event("message.mpim")(handle) + + with pytest.raises(ValueError): + app.event(re.compile("message\\..*"))(handle) + + with pytest.raises(ValueError): + app.event({"type": "message.channels"})(handle) + with pytest.raises(ValueError): + app.event({"type": re.compile("message\\..*")})(handle) diff --git a/tests/scenario_tests_async/test_events.py b/tests/scenario_tests_async/test_events.py index 9dda0b052..16fb92ef3 100644 --- a/tests/scenario_tests_async/test_events.py +++ b/tests/scenario_tests_async/test_events.py @@ -561,6 +561,37 @@ async def handler1(event): response = await app.async_dispatch(request) assert response.status == 200 + # https://github.com/slackapi/bolt-python/issues/199 + @pytest.mark.asyncio + async def test_invalid_message_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def handle(): + pass + + # valid + app.event("message")(handle) + + with pytest.raises(ValueError): + app.event("message.channels")(handle) + with pytest.raises(ValueError): + app.event("message.groups")(handle) + with pytest.raises(ValueError): + app.event("message.im")(handle) + with pytest.raises(ValueError): + app.event("message.mpim")(handle) + + with pytest.raises(ValueError): + app.event(re.compile("message\\..*"))(handle) + + with pytest.raises(ValueError): + app.event({"type": "message.channels"})(handle) + with pytest.raises(ValueError): + app.event({"type": re.compile("message\\..*")})(handle) + app_mention_body = { "token": "verification_token", From 03a0ad4e562366a216e604fdd1b6cadf15705ee1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 19 Jan 2021 13:18:10 +0900 Subject: [PATCH 252/865] version 1.2.2 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index a955fdae1..bc86c944f 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.2.1" +__version__ = "1.2.2" From 117e04ba581d3f39da8ba0e52491eb56fd0431b0 Mon Sep 17 00:00:00 2001 From: Nicko van Someren Date: Tue, 19 Jan 2021 18:13:42 -0700 Subject: [PATCH 253/865] Fixed fetching of names for callable objects (#215) Added new function name_for_callable() in util.utils. Fixed name properties for CustomMiddleware and AsyncCustomMiddleware to use the new function. Modified listener/thread_runner.py and listener/asyncio_runner.py to use the new function to name lazy functions. Added test cases for middleware and lazy function cases. --- slack_bolt/listener/asyncio_runner.py | 8 +++--- slack_bolt/listener/thread_runner.py | 8 +++--- .../middleware/async_custom_middleware.py | 3 +- slack_bolt/middleware/custom_middleware.py | 4 +-- slack_bolt/util/utils.py | 7 +++++ tests/scenario_tests/test_lazy.py | 28 +++++++++++++++++++ .../test_listener_middleware.py | 17 +++++++++++ tests/scenario_tests/test_middleware.py | 17 +++++++++++ 8 files changed, 81 insertions(+), 11 deletions(-) diff --git a/slack_bolt/listener/asyncio_runner.py b/slack_bolt/listener/asyncio_runner.py index 1ea9de2a2..b35f05e23 100644 --- a/slack_bolt/listener/asyncio_runner.py +++ b/slack_bolt/listener/asyncio_runner.py @@ -15,7 +15,7 @@ ) from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import create_copy +from slack_bolt.util.utils import create_copy, name_for_callable class AsyncioListenerRunner: @@ -71,7 +71,7 @@ async def run( for lazy_func in listener.lazy_functions: if request.lazy_function_name: - func_name = lazy_func.__name__ + func_name = name_for_callable(lazy_func) if func_name == request.lazy_function_name: await self.lazy_listener_runner.run( function=lazy_func, request=request @@ -128,7 +128,7 @@ async def run_ack_function_asynchronously( for lazy_func in listener.lazy_functions: if request.lazy_function_name: - func_name = lazy_func.__name__ + func_name = name_for_callable(lazy_func) if func_name == request.lazy_function_name: await self.lazy_listener_runner.run( function=lazy_func, request=request @@ -163,7 +163,7 @@ def _start_lazy_function( self, lazy_func: Callable[..., Awaitable[None]], request: AsyncBoltRequest ) -> None: # Start a lazy function asynchronously - func_name: str = lazy_func.__name__ + func_name: str = name_for_callable(lazy_func) self.logger.debug(debug_running_lazy_listener(func_name)) copied_request = self._build_lazy_request(request, func_name) self.lazy_listener_runner.start(function=lazy_func, request=copied_request) diff --git a/slack_bolt/listener/thread_runner.py b/slack_bolt/listener/thread_runner.py index 2e60e1c41..05601a511 100644 --- a/slack_bolt/listener/thread_runner.py +++ b/slack_bolt/listener/thread_runner.py @@ -13,7 +13,7 @@ ) from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import create_copy +from slack_bolt.util.utils import create_copy, name_for_callable class ThreadListenerRunner: @@ -72,7 +72,7 @@ def run( # type: ignore for lazy_func in listener.lazy_functions: if request.lazy_function_name: - func_name = lazy_func.__name__ + func_name = name_for_callable(lazy_func) if func_name == request.lazy_function_name: self.lazy_listener_runner.run( function=lazy_func, request=request @@ -127,7 +127,7 @@ def run_ack_function_asynchronously(): for lazy_func in listener.lazy_functions: if request.lazy_function_name: - func_name = lazy_func.__name__ + func_name = name_for_callable(lazy_func) if func_name == request.lazy_function_name: self.lazy_listener_runner.run( function=lazy_func, request=request @@ -162,7 +162,7 @@ def _start_lazy_function( self, lazy_func: Callable[..., None], request: BoltRequest ) -> None: # Start a lazy function asynchronously - func_name: str = lazy_func.__name__ + func_name: str = name_for_callable(lazy_func) self.logger.debug(debug_running_lazy_listener(func_name)) copied_request = self._build_lazy_request(request, func_name) self.lazy_listener_runner.start(function=lazy_func, request=copied_request) diff --git a/slack_bolt/middleware/async_custom_middleware.py b/slack_bolt/middleware/async_custom_middleware.py index be44cf2db..5d6b6837c 100644 --- a/slack_bolt/middleware/async_custom_middleware.py +++ b/slack_bolt/middleware/async_custom_middleware.py @@ -7,6 +7,7 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from .async_middleware import AsyncMiddleware +from slack_bolt.util.utils import name_for_callable class AsyncCustomMiddleware(AsyncMiddleware): @@ -45,4 +46,4 @@ async def async_process( @property def name(self) -> str: - return f"AsyncCustomMiddleware(func={self.func.__name__})" + return f"AsyncCustomMiddleware(func={name_for_callable(self.func)})" diff --git a/slack_bolt/middleware/custom_middleware.py b/slack_bolt/middleware/custom_middleware.py index f3e4afff8..17c550417 100644 --- a/slack_bolt/middleware/custom_middleware.py +++ b/slack_bolt/middleware/custom_middleware.py @@ -7,7 +7,7 @@ from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from .middleware import Middleware - +from slack_bolt.util.utils import name_for_callable class CustomMiddleware(Middleware): app_name: str @@ -41,4 +41,4 @@ def process( @property def name(self) -> str: - return f"CustomMiddleware(func={self.func.__name__})" + return f"CustomMiddleware(func={name_for_callable(self.func)})" diff --git a/slack_bolt/util/utils.py b/slack_bolt/util/utils.py index e52a53674..cac7f0d0a 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -57,3 +57,10 @@ def get_boot_message(development_server: bool = False) -> str: return "⚡️ Bolt app is running! (development server)" else: return "⚡️ Bolt app is running!" + + +def name_for_callable(func): + if hasattr(func, "__name__"): + func_name = func.__name__ + else: + func_name = f"{func.__class__.__module__}.{func.__class__.__name__}" diff --git a/tests/scenario_tests/test_lazy.py b/tests/scenario_tests/test_lazy.py index 8a633f8cc..6a2c89607 100644 --- a/tests/scenario_tests/test_lazy.py +++ b/tests/scenario_tests/test_lazy.py @@ -110,3 +110,31 @@ def async2(say): assert response.status == 200 time.sleep(1) # wait a bit assert self.mock_received_requests["/chat.postMessage"] == 2 + + def test_lazy_class(self): + def just_ack(ack): + ack() + + class LazyClass: + def __call__(self, say): + time.sleep(0.3) + say(text="lazy function 1") + + def async2(say): + time.sleep(0.5) + say(text="lazy function 2") + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action("a")( + ack=just_ack, + lazy=[LazyClass(), async2], + ) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + time.sleep(1) # wait a bit + assert self.mock_received_requests["/chat.postMessage"] == 2 diff --git a/tests/scenario_tests/test_listener_middleware.py b/tests/scenario_tests/test_listener_middleware.py index 4906375d8..11ca31115 100644 --- a/tests/scenario_tests/test_listener_middleware.py +++ b/tests/scenario_tests/test_listener_middleware.py @@ -93,6 +93,23 @@ def handle(ack): response = app.dispatch(self.build_request()) assert response.status == 200 + def test_class_next(self): + class NextClass: + def __call__(self, next): + next() + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.shortcut(constraints="test-shortcut", middleware=[NextClass()]) + def handle(ack): + ack() + + response = app.dispatch(self.build_request()) + assert response.status == 200 + def listener_middleware_returning_response(): return BoltResponse(status=200, body="listener middleware") diff --git a/tests/scenario_tests/test_middleware.py b/tests/scenario_tests/test_middleware.py index 569dc6261..deb677d1d 100644 --- a/tests/scenario_tests/test_middleware.py +++ b/tests/scenario_tests/test_middleware.py @@ -86,6 +86,23 @@ def test_next_call(self): assert response.body == "acknowledged!" assert self.mock_received_requests["/auth.test"] == 1 + def test_class_call(self): + class NextClass: + def __call__(self, next): + next() + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.use(NextClass()) + app.shortcut("test-shortcut")(just_ack) + + response = app.dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "acknowledged!" + assert self.mock_received_requests["/auth.test"] == 1 + def just_ack(ack): ack("acknowledged!") From 67601746f413ad4ee8feb065ff72c8ddcef9a802 Mon Sep 17 00:00:00 2001 From: Nicko van Someren Date: Tue, 19 Jan 2021 19:46:10 -0700 Subject: [PATCH 254/865] Fix PEP8 formatting --- slack_bolt/middleware/custom_middleware.py | 1 + 1 file changed, 1 insertion(+) diff --git a/slack_bolt/middleware/custom_middleware.py b/slack_bolt/middleware/custom_middleware.py index 17c550417..a24513c24 100644 --- a/slack_bolt/middleware/custom_middleware.py +++ b/slack_bolt/middleware/custom_middleware.py @@ -9,6 +9,7 @@ from .middleware import Middleware from slack_bolt.util.utils import name_for_callable + class CustomMiddleware(Middleware): app_name: str func: Callable[..., Any] From f0ee5a1d17fa10d53b5a5809db07a5f3a1e507f9 Mon Sep 17 00:00:00 2001 From: Nicko van Someren Date: Tue, 19 Jan 2021 20:06:06 -0700 Subject: [PATCH 255/865] Fixed return error in name_for_callable and added typing hints. --- slack_bolt/util/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/slack_bolt/util/utils.py b/slack_bolt/util/utils.py index cac7f0d0a..272c7d382 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -1,6 +1,6 @@ import copy import sys -from typing import Optional, Union, Dict, Any, Sequence +from typing import Optional, Union, Dict, Any, Sequence, Callable from slack_sdk import WebClient from slack_sdk.models import JsonObject @@ -59,8 +59,9 @@ def get_boot_message(development_server: bool = False) -> str: return "⚡️ Bolt app is running!" -def name_for_callable(func): +def name_for_callable(func: Callable) -> str: if hasattr(func, "__name__"): func_name = func.__name__ else: func_name = f"{func.__class__.__module__}.{func.__class__.__name__}" + return func_name From bbcad6364df26cbb6fe80cca2f21855cb85be6c5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 20 Jan 2021 17:12:45 +0900 Subject: [PATCH 256/865] Apply more updates on #216 --- slack_bolt/app/app.py | 8 ++++++-- slack_bolt/app/async_app.py | 5 +++-- slack_bolt/listener/asyncio_runner.py | 8 ++++---- slack_bolt/listener/thread_runner.py | 8 ++++---- slack_bolt/middleware/async_custom_middleware.py | 4 ++-- slack_bolt/middleware/custom_middleware.py | 4 ++-- slack_bolt/util/utils.py | 12 ++++++++---- slack_bolt/workflows/step/async_step_middleware.py | 3 ++- slack_bolt/workflows/step/step_middleware.py | 3 ++- 9 files changed, 33 insertions(+), 22 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 690eb6d36..7d8678954 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -61,7 +61,11 @@ from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import create_web_client, get_boot_message +from slack_bolt.util.utils import ( + create_web_client, + get_boot_message, + get_name_for_callable, +) from slack_bolt.workflows.step import WorkflowStep, WorkflowStepMiddleware @@ -347,7 +351,7 @@ def middleware_next(): return resp for listener in self._listeners: - listener_name = listener.ack_function.__name__ + listener_name = get_name_for_callable(listener.ack_function) self._framework_logger.debug(debug_checking_listener(listener_name)) if listener.matches(req=req, resp=resp): # run all the middleware attached to this listener first diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index d08df8e2c..bf3b63b68 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -12,6 +12,7 @@ AsyncMessageListenerMatches, ) from slack_bolt.oauth.async_internals import select_consistent_installation_store +from slack_bolt.util.utils import get_name_for_callable from slack_bolt.workflows.step.async_step import AsyncWorkflowStep from slack_bolt.workflows.step.async_step_middleware import AsyncWorkflowStepMiddleware from slack_sdk.oauth.installation_store.async_installation_store import ( @@ -385,7 +386,7 @@ async def async_middleware_next(): return resp for listener in self._async_listeners: - listener_name = listener.ack_function.__name__ + listener_name = get_name_for_callable(listener.ack_function) self._framework_logger.debug(debug_checking_listener(listener_name)) if await listener.async_matches(req=req, resp=resp): # run all the middleware attached to this listener first @@ -917,7 +918,7 @@ def _register_listener( for func in functions: if not inspect.iscoroutinefunction(func): - name = func.__name__ + name = get_name_for_callable(func) raise BoltError(error_listener_function_must_be_coro_func(name)) listener_matchers = [ diff --git a/slack_bolt/listener/asyncio_runner.py b/slack_bolt/listener/asyncio_runner.py index b35f05e23..8ebcbde45 100644 --- a/slack_bolt/listener/asyncio_runner.py +++ b/slack_bolt/listener/asyncio_runner.py @@ -15,7 +15,7 @@ ) from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import create_copy, name_for_callable +from slack_bolt.util.utils import create_copy, get_name_for_callable class AsyncioListenerRunner: @@ -71,7 +71,7 @@ async def run( for lazy_func in listener.lazy_functions: if request.lazy_function_name: - func_name = name_for_callable(lazy_func) + func_name = get_name_for_callable(lazy_func) if func_name == request.lazy_function_name: await self.lazy_listener_runner.run( function=lazy_func, request=request @@ -128,7 +128,7 @@ async def run_ack_function_asynchronously( for lazy_func in listener.lazy_functions: if request.lazy_function_name: - func_name = name_for_callable(lazy_func) + func_name = get_name_for_callable(lazy_func) if func_name == request.lazy_function_name: await self.lazy_listener_runner.run( function=lazy_func, request=request @@ -163,7 +163,7 @@ def _start_lazy_function( self, lazy_func: Callable[..., Awaitable[None]], request: AsyncBoltRequest ) -> None: # Start a lazy function asynchronously - func_name: str = name_for_callable(lazy_func) + func_name: str = get_name_for_callable(lazy_func) self.logger.debug(debug_running_lazy_listener(func_name)) copied_request = self._build_lazy_request(request, func_name) self.lazy_listener_runner.start(function=lazy_func, request=copied_request) diff --git a/slack_bolt/listener/thread_runner.py b/slack_bolt/listener/thread_runner.py index 05601a511..8a02320e4 100644 --- a/slack_bolt/listener/thread_runner.py +++ b/slack_bolt/listener/thread_runner.py @@ -13,7 +13,7 @@ ) from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import create_copy, name_for_callable +from slack_bolt.util.utils import create_copy, get_name_for_callable class ThreadListenerRunner: @@ -72,7 +72,7 @@ def run( # type: ignore for lazy_func in listener.lazy_functions: if request.lazy_function_name: - func_name = name_for_callable(lazy_func) + func_name = get_name_for_callable(lazy_func) if func_name == request.lazy_function_name: self.lazy_listener_runner.run( function=lazy_func, request=request @@ -127,7 +127,7 @@ def run_ack_function_asynchronously(): for lazy_func in listener.lazy_functions: if request.lazy_function_name: - func_name = name_for_callable(lazy_func) + func_name = get_name_for_callable(lazy_func) if func_name == request.lazy_function_name: self.lazy_listener_runner.run( function=lazy_func, request=request @@ -162,7 +162,7 @@ def _start_lazy_function( self, lazy_func: Callable[..., None], request: BoltRequest ) -> None: # Start a lazy function asynchronously - func_name: str = name_for_callable(lazy_func) + func_name: str = get_name_for_callable(lazy_func) self.logger.debug(debug_running_lazy_listener(func_name)) copied_request = self._build_lazy_request(request, func_name) self.lazy_listener_runner.start(function=lazy_func, request=copied_request) diff --git a/slack_bolt/middleware/async_custom_middleware.py b/slack_bolt/middleware/async_custom_middleware.py index 5d6b6837c..ca50a23f9 100644 --- a/slack_bolt/middleware/async_custom_middleware.py +++ b/slack_bolt/middleware/async_custom_middleware.py @@ -7,7 +7,7 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from .async_middleware import AsyncMiddleware -from slack_bolt.util.utils import name_for_callable +from slack_bolt.util.utils import get_name_for_callable class AsyncCustomMiddleware(AsyncMiddleware): @@ -46,4 +46,4 @@ async def async_process( @property def name(self) -> str: - return f"AsyncCustomMiddleware(func={name_for_callable(self.func)})" + return f"AsyncCustomMiddleware(func={get_name_for_callable(self.func)})" diff --git a/slack_bolt/middleware/custom_middleware.py b/slack_bolt/middleware/custom_middleware.py index a24513c24..bff3a4f93 100644 --- a/slack_bolt/middleware/custom_middleware.py +++ b/slack_bolt/middleware/custom_middleware.py @@ -7,7 +7,7 @@ from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from .middleware import Middleware -from slack_bolt.util.utils import name_for_callable +from slack_bolt.util.utils import get_name_for_callable class CustomMiddleware(Middleware): @@ -42,4 +42,4 @@ def process( @property def name(self) -> str: - return f"CustomMiddleware(func={name_for_callable(self.func)})" + return f"CustomMiddleware(func={get_name_for_callable(self.func)})" diff --git a/slack_bolt/util/utils.py b/slack_bolt/util/utils.py index 272c7d382..15352367e 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -59,9 +59,13 @@ def get_boot_message(development_server: bool = False) -> str: return "⚡️ Bolt app is running!" -def name_for_callable(func: Callable) -> str: +def get_name_for_callable(func: Callable) -> str: + """Returns the name for the given Callable function object. + + :param func: either a Callable instance or a function, which as __name__ + :return: name of the given Callable object + """ if hasattr(func, "__name__"): - func_name = func.__name__ + return func.__name__ else: - func_name = f"{func.__class__.__module__}.{func.__class__.__name__}" - return func_name + return f"{func.__class__.__module__}.{func.__class__.__name__}" diff --git a/slack_bolt/workflows/step/async_step_middleware.py b/slack_bolt/workflows/step/async_step_middleware.py index c1583aa2f..cf8ceca9e 100644 --- a/slack_bolt/workflows/step/async_step_middleware.py +++ b/slack_bolt/workflows/step/async_step_middleware.py @@ -5,6 +5,7 @@ from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import get_name_for_callable from slack_bolt.workflows.step.async_step import AsyncWorkflowStep @@ -51,6 +52,6 @@ async def _run( return await self.listener_runner.run( request=req, response=resp, - listener_name=listener.ack_function.__name__, + listener_name=get_name_for_callable(listener.ack_function), listener=listener, ) diff --git a/slack_bolt/workflows/step/step_middleware.py b/slack_bolt/workflows/step/step_middleware.py index 5a562d30b..4fa2de101 100644 --- a/slack_bolt/workflows/step/step_middleware.py +++ b/slack_bolt/workflows/step/step_middleware.py @@ -5,6 +5,7 @@ from slack_bolt.middleware import Middleware from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import get_name_for_callable from slack_bolt.workflows.step.step import WorkflowStep @@ -49,6 +50,6 @@ def _run( return self.listener_runner.run( request=req, response=resp, - listener_name=listener.ack_function.__name__, + listener_name=get_name_for_callable(listener.ack_function), listener=listener, ) From 01894d78d8378690b4f5777846bb0a186aba8d92 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 20 Jan 2021 17:27:53 +0900 Subject: [PATCH 257/865] version 1.2.3 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index bc86c944f..10aa336ce 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.2.2" +__version__ = "1.2.3" From cf3985d3993ec27e402d2b9f1af4677d958aea0b Mon Sep 17 00:00:00 2001 From: pdontha <32086188+pdontha@users.noreply.github.com> Date: Fri, 22 Jan 2021 14:35:28 -0800 Subject: [PATCH 258/865] [#220] Fix docs code sample for listening to events * Minor documentation correction in event examples * Added back closing tag for details --- docs/_basic/listening_events.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/_basic/listening_events.md b/docs/_basic/listening_events.md index ddfcd7536..a8779d826 100644 --- a/docs/_basic/listening_events.md +++ b/docs/_basic/listening_events.md @@ -17,8 +17,8 @@ The `event()` method requires an `eventType` of type `str`. # When a user joins the team, send a message in a predefined channel asking them to introduce themselves @app.event("team_join") def ask_for_introduction(event, say): - welcome_channel_id = "C12345"; - user_id = event["user"]["id"] + welcome_channel_id = "C12345" + user_id = event["user"] text = f"Welcome to the team, <@{user_id}>! 🎉 You can introduce yourself in this channel." say(text=text, channel=welcome_channel_id) ``` @@ -46,5 +46,4 @@ def log_message_change(logger, event): user, text = event["user"], event["text"] logger.info(f"The user {user} changed the message to {text}") ``` - From 2d9afd39e24f66e1364c0009fcaa0ce84a0844ed Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 27 Jan 2021 13:15:35 +0900 Subject: [PATCH 259/865] Use slack-sdk 3.2.1 or higher --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4e34c5811..49f31e050 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.2.0,<3.3",], + install_requires=["slack_sdk>=3.2.1,<3.3",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", From 3475ae78d9b791e9893da958aeb5498d778b1549 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 27 Jan 2021 23:02:21 +0900 Subject: [PATCH 260/865] Upgrade chalice minor version --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 49f31e050..8cd53987a 100755 --- a/setup.py +++ b/setup.py @@ -52,7 +52,8 @@ "moto<=2", # For AWS tests "bottle>=0.12,<1", "boddle>=0.2,<0.3", # For Bottle app tests - "chalice>=1,<2", + # TODO: https://github.com/aws/chalice/issues/1627 + "chalice>=1.22,<2", "click>=7,<8", # for chalice "CherryPy>=18,<19", "Django>=3,<4", From 6ce7bdc6c6d90efa8d5ed854cde9738e101f6890 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 27 Jan 2021 16:26:59 +0900 Subject: [PATCH 261/865] Fix #157 Improve the stability of API mock servers in unit tests --- .github/workflows/ci-build.yml | 48 +++++- tests/adapter_tests/aws/__init__.py | 0 .../{ => aws}/test_aws_chalice.py | 9 +- .../{ => aws}/test_aws_lambda.py | 15 +- .../{ => aws}/test_lambda_s3_oauth_flow.py | 0 tests/adapter_tests/bottle/__init__.py | 0 .../adapter_tests/{ => bottle}/test_bottle.py | 7 +- .../{ => bottle}/test_bottle_oauth.py | 0 tests/adapter_tests/cherrypy/__init__.py | 0 .../{ => cherrypy}/test_cherrypy.py | 0 .../{ => cherrypy}/test_cherrypy_oauth.py | 0 tests/adapter_tests/django/__init__.py | 0 .../adapter_tests/{ => django}/test_django.py | 11 +- .../{ => django}/test_django_settings.py | 0 tests/adapter_tests/falcon/__init__.py | 0 .../adapter_tests/{ => falcon}/test_falcon.py | 7 +- tests/adapter_tests/flask/__init__.py | 0 tests/adapter_tests/{ => flask}/test_flask.py | 7 +- tests/adapter_tests/pyramid/__init__.py | 0 .../{ => pyramid}/test_pyramid.py | 7 +- .../socket_mode/mock_socket_mode_server.py | 95 ++++++++++- .../socket_mode/mock_web_api_server.py | 134 ++++++++++++++- .../socket_mode/test_interactions_builtin.py | 11 +- .../test_interactions_web_client.py | 11 +- tests/adapter_tests/starlette/__init__.py | 0 .../{ => starlette}/test_fastapi.py | 7 +- .../{ => starlette}/test_starlette.py | 7 +- tests/adapter_tests/tornado/__init__.py | 0 .../{ => tornado}/test_tornado.py | 16 +- .../{ => tornado}/test_tornado_oauth.py | 9 +- .../socket_mode/test_async_aiohttp.py | 9 +- .../socket_mode/test_async_websockets.py | 9 +- .../adapter_tests_async/test_async_fastapi.py | 7 +- tests/adapter_tests_async/test_async_sanic.py | 7 +- .../test_async_starlette.py | 7 +- tests/mock_web_api_server.py | 157 +++++++++++++++++- tests/scenario_tests/test_app_bot_only.py | 15 +- .../test_app_using_methods_in_class.py | 3 +- .../scenario_tests/test_attachment_actions.py | 21 +-- tests/scenario_tests/test_authorize.py | 7 +- tests/scenario_tests/test_block_actions.py | 21 +-- tests/scenario_tests/test_block_suggestion.py | 23 +-- tests/scenario_tests/test_dialogs.py | 67 ++++---- tests/scenario_tests/test_events.py | 15 +- tests/scenario_tests/test_events_org_apps.py | 7 +- .../test_events_shared_channels.py | 13 +- .../scenario_tests/test_events_socket_mode.py | 15 +- tests/scenario_tests/test_message.py | 13 +- tests/scenario_tests/test_message_bot.py | 3 +- tests/scenario_tests/test_middleware.py | 7 +- tests/scenario_tests/test_shortcut.py | 31 ++-- tests/scenario_tests/test_slash_command.py | 9 +- tests/scenario_tests/test_view_closed.py | 19 ++- tests/scenario_tests/test_view_submission.py | 15 +- tests/scenario_tests/test_workflow_steps.py | 13 +- .../scenario_tests_async/test_app_bot_only.py | 11 +- .../test_app_using_methods_in_class.py | 3 +- .../test_attachment_actions.py | 23 +-- tests/scenario_tests_async/test_authorize.py | 7 +- .../test_block_actions.py | 23 +-- .../test_block_suggestion.py | 23 +-- tests/scenario_tests_async/test_dialogs.py | 67 ++++---- tests/scenario_tests_async/test_events.py | 15 +- .../test_events_org_apps.py | 7 +- .../test_events_shared_channels.py | 15 +- .../test_events_socket_mode.py | 15 +- tests/scenario_tests_async/test_message.py | 13 +- .../scenario_tests_async/test_message_bot.py | 3 +- tests/scenario_tests_async/test_middleware.py | 5 +- tests/scenario_tests_async/test_shortcut.py | 31 ++-- .../test_slash_command.py | 9 +- .../scenario_tests_async/test_view_closed.py | 19 ++- .../test_view_submission.py | 15 +- .../test_workflow_steps.py | 13 +- .../authorization/test_authorize.py | 25 +-- tests/slack_bolt/oauth/test_oauth_flow.py | 3 +- .../authorization/test_async_authorize.py | 25 +-- .../oauth/test_async_oauth_flow.py | 3 +- tests/utils.py | 15 ++ 79 files changed, 872 insertions(+), 430 deletions(-) create mode 100644 tests/adapter_tests/aws/__init__.py rename tests/adapter_tests/{ => aws}/test_aws_chalice.py (97%) rename tests/adapter_tests/{ => aws}/test_aws_lambda.py (95%) rename tests/adapter_tests/{ => aws}/test_lambda_s3_oauth_flow.py (100%) create mode 100644 tests/adapter_tests/bottle/__init__.py rename tests/adapter_tests/{ => bottle}/test_bottle.py (96%) rename tests/adapter_tests/{ => bottle}/test_bottle_oauth.py (100%) create mode 100644 tests/adapter_tests/cherrypy/__init__.py rename tests/adapter_tests/{ => cherrypy}/test_cherrypy.py (100%) rename tests/adapter_tests/{ => cherrypy}/test_cherrypy_oauth.py (100%) create mode 100644 tests/adapter_tests/django/__init__.py rename tests/adapter_tests/{ => django}/test_django.py (95%) rename tests/adapter_tests/{ => django}/test_django_settings.py (100%) create mode 100644 tests/adapter_tests/falcon/__init__.py rename tests/adapter_tests/{ => falcon}/test_falcon.py (97%) create mode 100644 tests/adapter_tests/flask/__init__.py rename tests/adapter_tests/{ => flask}/test_flask.py (96%) create mode 100644 tests/adapter_tests/pyramid/__init__.py rename tests/adapter_tests/{ => pyramid}/test_pyramid.py (97%) create mode 100644 tests/adapter_tests/starlette/__init__.py rename tests/adapter_tests/{ => starlette}/test_fastapi.py (97%) rename tests/adapter_tests/{ => starlette}/test_starlette.py (97%) create mode 100644 tests/adapter_tests/tornado/__init__.py rename tests/adapter_tests/{ => tornado}/test_tornado.py (93%) rename tests/adapter_tests/{ => tornado}/test_tornado_oauth.py (84%) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 351f9257f..71e345620 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -9,12 +9,15 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 timeout-minutes: 10 strategy: matrix: python-version: ['3.6', '3.7', '3.8', '3.9'] - + env: + # default: multiprocessing + # threading is more stable on GitHub Actions + BOLT_PYTHON_MOCK_SERVER_MODE: threading steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -30,10 +33,41 @@ jobs: run: | pytest tests/slack_bolt/ pytest tests/scenario_tests/ - - name: Run tests for adapters + - name: Run tests for Socket Mode adapters run: | pip install -e ".[adapter]" - pytest tests/adapter_tests/ + pytest tests/adapter_tests/socket_mode/ + - name: Run tests for HTTP Mode adapters (AWS) + run: | + pytest tests/adapter_tests/aws/ + - name: Run tests for HTTP Mode adapters (Bottle) + run: | + pytest tests/adapter_tests/bottle/ + - name: Run tests for HTTP Mode adapters (CherryPy) + run: | + pytest tests/adapter_tests/cherrypy/ + - name: Run tests for HTTP Mode adapters (Django) + run: | + pytest tests/adapter_tests/django/ + - name: Run tests for HTTP Mode adapters (Falcon) + run: | + pytest tests/adapter_tests/falcon/ + - name: Run tests for HTTP Mode adapters (Flask) + run: | + pytest tests/adapter_tests/flask/ + - name: Run tests for HTTP Mode adapters (Pyramid) + run: | + pytest tests/adapter_tests/pyramid/ + - name: Run tests for HTTP Mode adapters (Starlette) + run: | + pytest tests/adapter_tests/starlette/ + - name: Run tests for HTTP Mode adapters (Tornado) + run: | + pytest tests/adapter_tests/tornado/ + - name: Run tests for HTTP Mode adapters (asyncio-based libraries) + run: | + pip install -e ".[async]" + pytest tests/adapter_tests_async/ - name: Run pytype verification (3.8 only) run: | python_version=`python -V` @@ -42,12 +76,10 @@ jobs: pip install -e ".[adapter]" pip install "pytype" && pytype slack_bolt/ fi - - name: Run all tests for codecov (3.6 only) + - name: Run all tests for codecov (3.9 only) run: | python_version=`python -V` - # TODO: As Python 3.6 works the most stably on GitHub Actions, - # we use the version for code coverage and async tests - if [ ${python_version:7:3} == "3.6" ]; then + if [ ${python_version:7:3} == "3.9" ]; then pip install -e ".[async]" pip install -e ".[testing]" pytest --cov=slack_bolt/ && bash <(curl -s https://codecov.io/bash) diff --git a/tests/adapter_tests/aws/__init__.py b/tests/adapter_tests/aws/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_aws_chalice.py b/tests/adapter_tests/aws/test_aws_chalice.py similarity index 97% rename from tests/adapter_tests/test_aws_chalice.py rename to tests/adapter_tests/aws/test_aws_chalice.py index ee80f62dd..bf65601d0 100644 --- a/tests/adapter_tests/test_aws_chalice.py +++ b/tests/adapter_tests/aws/test_aws_chalice.py @@ -19,6 +19,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -110,7 +111,7 @@ def events() -> Response: headers=self.build_headers(timestamp, body), ) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): app = App( @@ -157,7 +158,7 @@ def events() -> Response: headers=self.build_headers(timestamp, body), ) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): app = App( @@ -204,7 +205,7 @@ def events() -> Response: headers=self.build_headers(timestamp, body), ) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_lazy_listeners(self): app = App( @@ -261,7 +262,7 @@ def say_it(say): ) response: Response = slack_handler.handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) assert self.mock_received_requests["/chat.postMessage"] == 1 def test_oauth(self): diff --git a/tests/adapter_tests/test_aws_lambda.py b/tests/adapter_tests/aws/test_aws_lambda.py similarity index 95% rename from tests/adapter_tests/test_aws_lambda.py rename to tests/adapter_tests/aws/test_aws_lambda.py index 0c05e07d6..4c6a1bb14 100644 --- a/tests/adapter_tests/test_aws_lambda.py +++ b/tests/adapter_tests/aws/test_aws_lambda.py @@ -14,6 +14,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -123,7 +124,7 @@ def event_handler(): } response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) event = { "body": body, @@ -134,7 +135,7 @@ def event_handler(): } response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) @mock_lambda def test_shortcuts(self): @@ -173,7 +174,7 @@ def shortcut_handler(ack): } response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) event = { "body": body, @@ -184,7 +185,7 @@ def shortcut_handler(ack): } response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) @mock_lambda def test_commands(self): @@ -223,7 +224,7 @@ def command_handler(ack): } response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) event = { "body": body, @@ -234,7 +235,7 @@ def command_handler(ack): } response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) @mock_lambda def test_lazy_listeners(self): @@ -279,7 +280,7 @@ def say_it(say): } response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) assert self.mock_received_requests["/chat.postMessage"] == 1 @mock_lambda diff --git a/tests/adapter_tests/test_lambda_s3_oauth_flow.py b/tests/adapter_tests/aws/test_lambda_s3_oauth_flow.py similarity index 100% rename from tests/adapter_tests/test_lambda_s3_oauth_flow.py rename to tests/adapter_tests/aws/test_lambda_s3_oauth_flow.py diff --git a/tests/adapter_tests/bottle/__init__.py b/tests/adapter_tests/bottle/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_bottle.py b/tests/adapter_tests/bottle/test_bottle.py similarity index 96% rename from tests/adapter_tests/test_bottle.py rename to tests/adapter_tests/bottle/test_bottle.py index 2cfd5a7fb..bd37fcc74 100644 --- a/tests/adapter_tests/test_bottle.py +++ b/tests/adapter_tests/bottle/test_bottle.py @@ -11,6 +11,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -109,7 +110,7 @@ def test_events(self): response_body = slack_events() assert response.status_code == 200 assert response_body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): input = { @@ -138,7 +139,7 @@ def test_shortcuts(self): response_body = slack_events() assert response.status_code == 200 assert response_body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): input = ( @@ -167,4 +168,4 @@ def test_commands(self): response_body = slack_events() assert response.status_code == 200 assert response_body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests/test_bottle_oauth.py b/tests/adapter_tests/bottle/test_bottle_oauth.py similarity index 100% rename from tests/adapter_tests/test_bottle_oauth.py rename to tests/adapter_tests/bottle/test_bottle_oauth.py diff --git a/tests/adapter_tests/cherrypy/__init__.py b/tests/adapter_tests/cherrypy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_cherrypy.py b/tests/adapter_tests/cherrypy/test_cherrypy.py similarity index 100% rename from tests/adapter_tests/test_cherrypy.py rename to tests/adapter_tests/cherrypy/test_cherrypy.py diff --git a/tests/adapter_tests/test_cherrypy_oauth.py b/tests/adapter_tests/cherrypy/test_cherrypy_oauth.py similarity index 100% rename from tests/adapter_tests/test_cherrypy_oauth.py rename to tests/adapter_tests/cherrypy/test_cherrypy_oauth.py diff --git a/tests/adapter_tests/django/__init__.py b/tests/adapter_tests/django/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_django.py b/tests/adapter_tests/django/test_django.py similarity index 95% rename from tests/adapter_tests/test_django.py rename to tests/adapter_tests/django/test_django.py index d1510594f..c8254c52c 100644 --- a/tests/adapter_tests/test_django.py +++ b/tests/adapter_tests/django/test_django.py @@ -14,6 +14,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -28,7 +29,9 @@ class TestDjango(TestCase): base_url=mock_api_server_base_url, ) - os.environ["DJANGO_SETTINGS_MODULE"] = "tests.adapter_tests.test_django_settings" + os.environ[ + "DJANGO_SETTINGS_MODULE" + ] = "tests.adapter_tests.django.test_django_settings" rf = RequestFactory() def setUp(self): @@ -91,7 +94,7 @@ def event_handler(): response = SlackRequestHandler(app).handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): app = App( @@ -130,7 +133,7 @@ def shortcut_handler(ack): response = SlackRequestHandler(app).handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): app = App( @@ -169,7 +172,7 @@ def command_handler(ack): response = SlackRequestHandler(app).handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = App( diff --git a/tests/adapter_tests/test_django_settings.py b/tests/adapter_tests/django/test_django_settings.py similarity index 100% rename from tests/adapter_tests/test_django_settings.py rename to tests/adapter_tests/django/test_django_settings.py diff --git a/tests/adapter_tests/falcon/__init__.py b/tests/adapter_tests/falcon/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_falcon.py b/tests/adapter_tests/falcon/test_falcon.py similarity index 97% rename from tests/adapter_tests/test_falcon.py rename to tests/adapter_tests/falcon/test_falcon.py index 263a67275..2cd9cdd59 100644 --- a/tests/adapter_tests/test_falcon.py +++ b/tests/adapter_tests/falcon/test_falcon.py @@ -13,6 +13,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -97,7 +98,7 @@ def event_handler(): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): app = App( @@ -138,7 +139,7 @@ def shortcut_handler(ack): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): app = App( @@ -179,7 +180,7 @@ def command_handler(ack): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = App( diff --git a/tests/adapter_tests/flask/__init__.py b/tests/adapter_tests/flask/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_flask.py b/tests/adapter_tests/flask/test_flask.py similarity index 96% rename from tests/adapter_tests/test_flask.py rename to tests/adapter_tests/flask/test_flask.py index c6f320f6d..b8c198e68 100644 --- a/tests/adapter_tests/test_flask.py +++ b/tests/adapter_tests/flask/test_flask.py @@ -12,6 +12,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -98,7 +99,7 @@ def endpoint(): headers=self.build_headers(timestamp, body), ) assert rv.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): app = App( @@ -141,7 +142,7 @@ def endpoint(): headers=self.build_headers(timestamp, body), ) assert rv.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): app = App( @@ -184,7 +185,7 @@ def endpoint(): headers=self.build_headers(timestamp, body), ) assert rv.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = App( diff --git a/tests/adapter_tests/pyramid/__init__.py b/tests/adapter_tests/pyramid/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_pyramid.py b/tests/adapter_tests/pyramid/test_pyramid.py similarity index 97% rename from tests/adapter_tests/test_pyramid.py rename to tests/adapter_tests/pyramid/test_pyramid.py index bc88b37d0..7f487ce65 100644 --- a/tests/adapter_tests/test_pyramid.py +++ b/tests/adapter_tests/pyramid/test_pyramid.py @@ -15,6 +15,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -97,7 +98,7 @@ def event_handler(): request.headers = self.build_headers(timestamp, body) response: Response = SlackRequestHandler(app).handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): app = App( @@ -134,7 +135,7 @@ def shortcut_handler(ack): request.headers = self.build_headers(timestamp, body) response: Response = SlackRequestHandler(app).handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): app = App( @@ -171,7 +172,7 @@ def command_handler(ack): request.headers = self.build_headers(timestamp, body) response: Response = SlackRequestHandler(app).handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = App( diff --git a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py index 2b778910c..68e84db43 100644 --- a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py +++ b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py @@ -1,5 +1,12 @@ import logging -from typing import List +import sys +import threading +import time +from multiprocessing.context import Process +from typing import List, Optional +from unittest import TestCase + +from tests.utils import get_mock_server_mode 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}""", @@ -11,8 +18,8 @@ from flask_sockets import Sockets -def start_socket_mode_server(self, port: int): - def _start_socket_mode_server(): +def start_thread_socket_mode_server(test: TestCase, port: int): + def _start_thread_socket_mode_server(): logger = logging.getLogger(__name__) app: Flask = Flask(__name__) sockets: Sockets = Sockets(app) @@ -36,7 +43,85 @@ def link(ws): from geventwebsocket.handler import WebSocketHandler server = pywsgi.WSGIServer(("", port), app, handler_class=WebSocketHandler) - self.server = server + test.server = server server.serve_forever(stop_timeout=1) - return _start_socket_mode_server + return _start_thread_socket_mode_server + + +def start_process_socket_mode_server(port: int): + 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) + server.serve_forever(stop_timeout=1) + + +def start_socket_mode_server(test, port: int): + if get_mock_server_mode() == "threading": + test.sm_thread = threading.Thread( + target=start_thread_socket_mode_server(test, port) + ) + test.sm_thread.daemon = True + test.sm_thread.start() + time.sleep(2) # wait for the server + else: + test.sm_process = Process( + target=start_process_socket_mode_server, kwargs={"port": port} + ) + test.sm_process.start() + + +def stop_socket_mode_server(test): + if get_mock_server_mode() == "threading": + print(test) + test.server.stop() + test.server.close() + else: + # terminate the process + test.sm_process.terminate() + test.sm_process.join() + # Python 3.6 does not have these methods + if sys.version_info.major == 3 and sys.version_info.minor > 6: + # cleanup the process's resources + test.sm_process.kill() + test.sm_process.close() + + test.sm_process = None + + +async def stop_socket_mode_server_async(test: TestCase): + if get_mock_server_mode() == "threading": + test.server.stop() + test.server.close() + else: + # terminate the process + test.sm_process.terminate() + test.sm_process.join() + + # Python 3.6 does not have these methods + if sys.version_info.major == 3 and sys.version_info.minor > 6: + # cleanup the process's resources + test.sm_process.kill() + test.sm_process.close() + + test.sm_process = None diff --git a/tests/adapter_tests/socket_mode/mock_web_api_server.py b/tests/adapter_tests/socket_mode/mock_web_api_server.py index ac30148d7..05e6b205e 100644 --- a/tests/adapter_tests/socket_mode/mock_web_api_server.py +++ b/tests/adapter_tests/socket_mode/mock_web_api_server.py @@ -1,12 +1,18 @@ import json import logging import re +import sys import threading +import time from http import HTTPStatus from http.server import HTTPServer, SimpleHTTPRequestHandler +from multiprocessing.context import Process from typing import Type from unittest import TestCase from urllib.parse import urlparse, parse_qs +from urllib.request import Request, urlopen + +from tests.utils import get_mock_server_mode class MockHandler(SimpleHTTPRequestHandler): @@ -48,6 +54,12 @@ def set_common_headers(self): def _handle(self): try: + if self.path == "/received_requests.json": + self.send_response(200) + self.set_common_headers() + self.wfile.write(json.dumps(self.received_requests).encode("utf-8")) + return + if self.is_valid_token() and self.is_valid_user_agent(): parsed_path = urlparse(self.path) @@ -114,6 +126,63 @@ def do_POST(self): self._handle() +# +# multiprocessing +# + + +class MockServerProcessTarget: + def __init__(self, handler: Type[SimpleHTTPRequestHandler] = MockHandler): + self.handler = handler + + def run(self): + self.handler.received_requests = {} + self.server = HTTPServer(("localhost", 8888), self.handler) + try: + self.server.serve_forever(0.05) + finally: + self.server.server_close() + + def stop(self): + self.handler.received_requests = {} + self.server.shutdown() + self.join() + + +class MonitorThread(threading.Thread): + def __init__( + self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler + ): + threading.Thread.__init__(self, daemon=True) + self.handler = handler + self.test = test + self.test.mock_received_requests = None + self.is_running = True + + def run(self) -> None: + while self.is_running: + try: + req = Request(f"{self.test.server_url}/received_requests.json") + resp = urlopen(req, timeout=1) + self.test.mock_received_requests = json.loads( + resp.read().decode("utf-8") + ) + except Exception as e: + # skip logging for the initial request + if self.test.mock_received_requests is not None: + logging.getLogger(__name__).exception(e) + time.sleep(0.01) + + def stop(self): + self.is_running = False + self.join() + + +# +# threading +# + + class MockServerThread(threading.Thread): def __init__( self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler @@ -140,14 +209,67 @@ def stop(self): def setup_mock_web_api_server(test: TestCase): - test.server_started = threading.Event() - test.thread = MockServerThread(test) - test.thread.start() + if get_mock_server_mode() == "threading": + test.server_started = threading.Event() + test.thread = MockServerThread(test) + test.thread.start() + test.server_started.wait() + else: + # start a mock server as another process + target = MockServerProcessTarget() + test.server_url = "http://localhost:8888" + test.host, test.port = "localhost", 8888 + test.process = Process(target=target.run, daemon=True) + test.process.start() + time.sleep(0.1) - test.server_started.wait() + # start a thread in the current process + # this thread fetches mock_received_requests from the remote process + test.monitor_thread = MonitorThread(test) + test.monitor_thread.start() + count = 0 + # wait until the first successful data retrieval + while test.mock_received_requests is None: + time.sleep(0.01) + count += 1 + if count >= 100: + raise Exception("The mock server is not yet running!") def cleanup_mock_web_api_server(test: TestCase): - test.thread.stop() + if get_mock_server_mode() == "threading": + test.thread.stop() + test.thread = None + else: + # stop the thread to fetch mock_received_requests from the remote process + test.monitor_thread.stop() + + # terminate the process + test.process.terminate() + test.process.join() + + # Python 3.6 does not have these methods + if sys.version_info.major == 3 and sys.version_info.minor > 6: + # cleanup the process's resources + test.process.kill() + test.process.close() + + test.process = None + + +def assert_auth_test_count(test: TestCase, expected_count: int): + time.sleep(0.1) + retry_count = 0 + error = None + while retry_count < 3: + try: + test.mock_received_requests["/auth.test"] == expected_count + break + except Exception as e: + error = e + retry_count += 1 + # waiting for mock_received_requests updates + time.sleep(0.1) - test.thread = None + if error is not None: + raise error diff --git a/tests/adapter_tests/socket_mode/test_interactions_builtin.py b/tests/adapter_tests/socket_mode/test_interactions_builtin.py index cee5e3248..4730ece81 100644 --- a/tests/adapter_tests/socket_mode/test_interactions_builtin.py +++ b/tests/adapter_tests/socket_mode/test_interactions_builtin.py @@ -1,6 +1,5 @@ import logging import time -from threading import Thread from slack_sdk import WebClient @@ -8,6 +7,7 @@ from slack_bolt.adapter.socket_mode import SocketModeHandler from .mock_socket_mode_server import ( start_socket_mode_server, + stop_socket_mode_server, ) from .mock_web_api_server import ( setup_mock_web_api_server, @@ -26,16 +26,15 @@ def setup_method(self): token="xoxb-api_test", base_url="http://localhost:8888", ) + start_socket_mode_server(self, 3011) + time.sleep(2) # wait for the server def teardown_method(self): cleanup_mock_web_api_server(self) restore_os_env(self.old_os_env) + stop_socket_mode_server(self) 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) @@ -71,5 +70,3 @@ def command_handler(ack): 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 index 7fd3ca0fc..b6f8d27f3 100644 --- a/tests/adapter_tests/socket_mode/test_interactions_web_client.py +++ b/tests/adapter_tests/socket_mode/test_interactions_web_client.py @@ -1,6 +1,5 @@ import logging import time -from threading import Thread from slack_sdk import WebClient @@ -8,6 +7,7 @@ from slack_bolt.adapter.socket_mode.websocket_client import SocketModeHandler from .mock_socket_mode_server import ( start_socket_mode_server, + stop_socket_mode_server, ) from .mock_web_api_server import ( setup_mock_web_api_server, @@ -26,16 +26,15 @@ def setup_method(self): token="xoxb-api_test", base_url="http://localhost:8888", ) + start_socket_mode_server(self, 3012) + time.sleep(2) # wait for the server def teardown_method(self): cleanup_mock_web_api_server(self) restore_os_env(self.old_os_env) + stop_socket_mode_server(self) 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) @@ -70,5 +69,3 @@ def command_handler(ack): assert result["command"] is True finally: handler.client.close() - self.server.stop() - self.server.close() diff --git a/tests/adapter_tests/starlette/__init__.py b/tests/adapter_tests/starlette/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_fastapi.py b/tests/adapter_tests/starlette/test_fastapi.py similarity index 97% rename from tests/adapter_tests/test_fastapi.py rename to tests/adapter_tests/starlette/test_fastapi.py index 3e94542d0..29282bc24 100644 --- a/tests/adapter_tests/test_fastapi.py +++ b/tests/adapter_tests/starlette/test_fastapi.py @@ -14,6 +14,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -101,7 +102,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): app = App( @@ -145,7 +146,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): app = App( @@ -189,7 +190,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = App( diff --git a/tests/adapter_tests/test_starlette.py b/tests/adapter_tests/starlette/test_starlette.py similarity index 97% rename from tests/adapter_tests/test_starlette.py rename to tests/adapter_tests/starlette/test_starlette.py index 4dc4059f9..033d80f51 100644 --- a/tests/adapter_tests/test_starlette.py +++ b/tests/adapter_tests/starlette/test_starlette.py @@ -15,6 +15,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -104,7 +105,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): app = App( @@ -150,7 +151,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): app = App( @@ -196,7 +197,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = App( diff --git a/tests/adapter_tests/tornado/__init__.py b/tests/adapter_tests/tornado/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_tornado.py b/tests/adapter_tests/tornado/test_tornado.py similarity index 93% rename from tests/adapter_tests/test_tornado.py rename to tests/adapter_tests/tornado/test_tornado.py index 87c4ccc09..e8dc5f5fa 100644 --- a/tests/adapter_tests/test_tornado.py +++ b/tests/adapter_tests/tornado/test_tornado.py @@ -2,11 +2,10 @@ from time import time from urllib.parse import quote -import tornado from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient from tornado.httpclient import HTTPRequest, HTTPResponse -from tornado.testing import AsyncHTTPTestCase +from tornado.testing import AsyncHTTPTestCase, gen_test from tornado.web import Application from slack_bolt.adapter.tornado import SlackEventsHandler @@ -14,6 +13,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -81,7 +81,7 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": timestamp, } - @tornado.testing.gen_test + @gen_test async def test_events(self): input = { "token": "verification_token", @@ -113,9 +113,9 @@ async def test_events(self): ) response: HTTPResponse = await self.http_client.fetch(request) assert response.code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) - @tornado.testing.gen_test + @gen_test async def test_shortcuts(self): input = { "type": "shortcut", @@ -142,9 +142,9 @@ async def test_shortcuts(self): ) response: HTTPResponse = await self.http_client.fetch(request) assert response.code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) - @tornado.testing.gen_test + @gen_test async def test_commands(self): input = ( "token=verification_token" @@ -171,4 +171,4 @@ async def test_commands(self): ) response: HTTPResponse = await self.http_client.fetch(request) assert response.code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests/test_tornado_oauth.py b/tests/adapter_tests/tornado/test_tornado_oauth.py similarity index 84% rename from tests/adapter_tests/test_tornado_oauth.py rename to tests/adapter_tests/tornado/test_tornado_oauth.py index ae98a6ac6..5fbd25aa9 100644 --- a/tests/adapter_tests/test_tornado_oauth.py +++ b/tests/adapter_tests/tornado/test_tornado_oauth.py @@ -1,6 +1,5 @@ -import tornado -from tornado.httpclient import HTTPRequest, HTTPResponse -from tornado.testing import AsyncHTTPTestCase +from tornado.httpclient import HTTPRequest, HTTPResponse, HTTPClientError +from tornado.testing import AsyncHTTPTestCase, gen_test from tornado.web import Application from slack_bolt.adapter.tornado import SlackOAuthHandler @@ -32,7 +31,7 @@ def tearDown(self): AsyncHTTPTestCase.tearDown(self) restore_os_env(self.old_os_env) - @tornado.testing.gen_test + @gen_test async def test_oauth(self): request = HTTPRequest( url=self.get_url("/slack/install"), method="GET", follow_redirects=False @@ -40,5 +39,5 @@ async def test_oauth(self): try: response: HTTPResponse = await self.http_client.fetch(request) assert response.code == 200 - except tornado.httpclient.HTTPClientError as e: + except HTTPClientError as e: assert e.code == 200 diff --git a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py index c8eb5b5ab..1877514e0 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py +++ b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py @@ -1,5 +1,4 @@ import asyncio -from threading import Thread import pytest from slack_sdk.web.async_client import AsyncWebClient @@ -13,6 +12,7 @@ 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, + stop_socket_mode_server_async, ) @@ -38,9 +38,7 @@ def event_loop(self): @pytest.mark.asyncio async def test_events(self): - t = Thread(target=start_socket_mode_server(self, 3021)) - t.daemon = True - t.start() + start_socket_mode_server(self, 3021) await asyncio.sleep(1) # wait for the server app = AsyncApp(client=self.web_client) @@ -74,5 +72,4 @@ async def command_handler(ack): assert result["command"] is True finally: await handler.client.close() - self.server.stop() - self.server.close() + await stop_socket_mode_server_async(self) diff --git a/tests/adapter_tests_async/socket_mode/test_async_websockets.py b/tests/adapter_tests_async/socket_mode/test_async_websockets.py index c47d2b7fe..b8574e623 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_websockets.py +++ b/tests/adapter_tests_async/socket_mode/test_async_websockets.py @@ -1,5 +1,4 @@ import asyncio -from threading import Thread import pytest from slack_sdk.web.async_client import AsyncWebClient @@ -13,6 +12,7 @@ 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, + stop_socket_mode_server_async, ) @@ -38,9 +38,7 @@ def event_loop(self): @pytest.mark.asyncio async def test_events(self): - t = Thread(target=start_socket_mode_server(self, 3022)) - t.daemon = True - t.start() + start_socket_mode_server(self, 3022) await asyncio.sleep(1) # wait for the server app = AsyncApp(client=self.web_client) @@ -74,5 +72,4 @@ async def command_handler(ack): assert result["command"] is True finally: await handler.client.close() - self.server.stop() - self.server.close() + await stop_socket_mode_server_async(self) diff --git a/tests/adapter_tests_async/test_async_fastapi.py b/tests/adapter_tests_async/test_async_fastapi.py index f9c2c842b..a0ebeb81f 100644 --- a/tests/adapter_tests_async/test_async_fastapi.py +++ b/tests/adapter_tests_async/test_async_fastapi.py @@ -14,6 +14,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -101,7 +102,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): app = AsyncApp( @@ -145,7 +146,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): app = AsyncApp( @@ -189,7 +190,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = AsyncApp( diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py index 38f0fbb00..6bdb62ab4 100644 --- a/tests/adapter_tests_async/test_async_sanic.py +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -15,6 +15,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -110,7 +111,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) @pytest.mark.asyncio async def test_shortcuts(self): @@ -154,7 +155,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) @pytest.mark.asyncio async def test_commands(self): @@ -198,7 +199,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) @pytest.mark.asyncio async def test_oauth(self): diff --git a/tests/adapter_tests_async/test_async_starlette.py b/tests/adapter_tests_async/test_async_starlette.py index 3faba8b12..70a8f670a 100644 --- a/tests/adapter_tests_async/test_async_starlette.py +++ b/tests/adapter_tests_async/test_async_starlette.py @@ -15,6 +15,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -104,7 +105,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): app = AsyncApp( @@ -150,7 +151,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): app = AsyncApp( @@ -196,7 +197,7 @@ async def endpoint(req: Request): headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = AsyncApp( diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index db204f00b..799e38266 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -1,12 +1,20 @@ +import asyncio import json import logging +import sys import threading +import time 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 +from multiprocessing import Process +from urllib.request import urlopen, Request + +from tests.utils import get_mock_server_mode + class MockHandler(SimpleHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -84,6 +92,12 @@ def set_common_headers(self): def _handle(self): self.received_requests[self.path] = self.received_requests.get(self.path, 0) + 1 try: + if self.path == "/received_requests.json": + self.send_response(200) + self.set_common_headers() + self.wfile.write(json.dumps(self.received_requests).encode("utf-8")) + return + body = {"ok": True} if self.path == "/oauth.v2.access": self.send_response(200) @@ -156,6 +170,63 @@ def do_POST(self): self._handle() +# +# multiprocessing +# + + +class MockServerProcessTarget: + def __init__(self, handler: Type[SimpleHTTPRequestHandler] = MockHandler): + self.handler = handler + + def run(self): + self.handler.received_requests = {} + self.server = HTTPServer(("localhost", 8888), self.handler) + try: + self.server.serve_forever(0.05) + finally: + self.server.server_close() + + def stop(self): + self.handler.received_requests = {} + self.server.shutdown() + self.join() + + +class MonitorThread(threading.Thread): + def __init__( + self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler + ): + threading.Thread.__init__(self, daemon=True) + self.handler = handler + self.test = test + self.test.mock_received_requests = None + self.is_running = True + + def run(self) -> None: + while self.is_running: + try: + req = Request(f"{self.test.server_url}/received_requests.json") + resp = urlopen(req, timeout=1) + self.test.mock_received_requests = json.loads( + resp.read().decode("utf-8") + ) + except Exception as e: + # skip logging for the initial request + if self.test.mock_received_requests is not None: + logging.getLogger(__name__).exception(e) + time.sleep(0.01) + + def stop(self): + self.is_running = False + self.join() + + +# +# threading +# + + class MockServerThread(threading.Thread): def __init__( self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler @@ -184,12 +255,86 @@ def stop(self): def setup_mock_web_api_server(test: TestCase): - test.server_started = threading.Event() - test.thread = MockServerThread(test) - test.thread.start() - test.server_started.wait() + if get_mock_server_mode() == "threading": + test.server_started = threading.Event() + test.thread = MockServerThread(test) + test.thread.start() + test.server_started.wait() + else: + # start a mock server as another process + target = MockServerProcessTarget() + test.server_url = "http://localhost:8888" + test.host, test.port = "localhost", 8888 + test.process = Process(target=target.run, daemon=True) + test.process.start() + + time.sleep(0.1) + + # start a thread in the current process + # this thread fetches mock_received_requests from the remote process + test.monitor_thread = MonitorThread(test) + test.monitor_thread.start() + count = 0 + # wait until the first successful data retrieval + while test.mock_received_requests is None: + time.sleep(0.01) + count += 1 + if count >= 100: + raise Exception("The mock server is not yet running!") def cleanup_mock_web_api_server(test: TestCase): - test.thread.stop() - test.thread = None + if get_mock_server_mode() == "threading": + test.thread.stop() + test.thread = None + else: + # stop the thread to fetch mock_received_requests from the remote process + test.monitor_thread.stop() + + # terminate the process + test.process.terminate() + test.process.join() + + # Python 3.6 does not have these methods + if sys.version_info.major == 3 and sys.version_info.minor > 6: + # cleanup the process's resources + test.process.kill() + test.process.close() + + test.process = None + + +def assert_auth_test_count(test: TestCase, expected_count: int): + time.sleep(0.1) + retry_count = 0 + error = None + while retry_count < 3: + try: + test.mock_received_requests["/auth.test"] == expected_count + break + except Exception as e: + error = e + retry_count += 1 + # waiting for mock_received_requests updates + time.sleep(0.1) + + if error is not None: + raise error + + +async def assert_auth_test_count_async(test: TestCase, expected_count: int): + await asyncio.sleep(0.1) + retry_count = 0 + error = None + while retry_count < 3: + try: + test.mock_received_requests["/auth.test"] == expected_count + break + except Exception as e: + error = e + retry_count += 1 + # waiting for mock_received_requests updates + await asyncio.sleep(0.1) + + if error is not None: + raise error diff --git a/tests/scenario_tests/test_app_bot_only.py b/tests/scenario_tests/test_app_bot_only.py index 53da026f9..c85590e39 100644 --- a/tests/scenario_tests/test_app_bot_only.py +++ b/tests/scenario_tests/test_app_bot_only.py @@ -16,6 +16,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -171,7 +172,7 @@ def test_installation_store_bot_only_default(self): app.event("app_mention")(self.handle_app_mention) response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -187,7 +188,7 @@ def test_installation_store_bot_only_false(self): app.event("app_mention")(self.handle_app_mention) response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -202,7 +203,7 @@ def test_installation_store_bot_only(self): app.event("app_mention")(self.handle_app_mention) response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -216,7 +217,7 @@ def test_installation_store_bot_only_oauth_settings(self): app.event("app_mention")(self.handle_app_mention) response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -231,7 +232,7 @@ def test_installation_store_bot_only_oauth_settings_conflicts(self): app.event("app_mention")(self.handle_app_mention) response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -245,7 +246,7 @@ def test_installation_store_bot_only_oauth_flow(self): app.event("app_mention")(self.handle_app_mention) response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -260,6 +261,6 @@ def test_installation_store_bot_only_oauth_flow_conflicts(self): app.event("app_mention")(self.handle_app_mention) response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 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 642d0641d..c16f19884 100644 --- a/tests/scenario_tests/test_app_using_methods_in_class.py +++ b/tests/scenario_tests/test_app_using_methods_in_class.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -113,7 +114,7 @@ def run_app_and_verify(self, app: App): ) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(0.5) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 diff --git a/tests/scenario_tests/test_attachment_actions.py b/tests/scenario_tests/test_attachment_actions.py index c520a11ad..816db7cdf 100644 --- a/tests/scenario_tests/test_attachment_actions.py +++ b/tests/scenario_tests/test_attachment_actions.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -65,7 +66,7 @@ def test_success_without_type(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success(self): app = App( @@ -82,7 +83,7 @@ def test_success(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_2(self): app = App( @@ -94,7 +95,7 @@ def test_success_2(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -112,7 +113,7 @@ def test_process_before_response(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure_without_type(self): app = App( @@ -122,12 +123,12 @@ def test_failure_without_type(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.action("unknown")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): app = App( @@ -137,7 +138,7 @@ def test_failure(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.action( { @@ -147,7 +148,7 @@ def test_failure(self): )(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure_2(self): app = App( @@ -157,12 +158,12 @@ def test_failure_2(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.attachment_action("unknown")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) # https://api.slack.com/legacy/interactive-messages diff --git a/tests/scenario_tests/test_authorize.py b/tests/scenario_tests/test_authorize.py index 6bb4345e8..494c505ed 100644 --- a/tests/scenario_tests/test_authorize.py +++ b/tests/scenario_tests/test_authorize.py @@ -11,6 +11,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -95,7 +96,7 @@ def test_success(self): response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): app = App( @@ -123,7 +124,7 @@ def test_bot_context_attributes(self): response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_user_context_attributes(self): app = App( @@ -137,7 +138,7 @@ def test_user_context_attributes(self): response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) body = { diff --git a/tests/scenario_tests/test_block_actions.py b/tests/scenario_tests/test_block_actions.py index 6280bf6ed..b7bd82199 100644 --- a/tests/scenario_tests/test_block_actions.py +++ b/tests/scenario_tests/test_block_actions.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -65,7 +66,7 @@ def test_success(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_2(self): app = App( @@ -77,7 +78,7 @@ def test_success_2(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -90,7 +91,7 @@ def test_process_before_response(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_default_type(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @@ -99,7 +100,7 @@ def test_default_type(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_default_type_no_block_id(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @@ -108,7 +109,7 @@ def test_default_type_no_block_id(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_default_type_and_unmatched_block_id(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @@ -117,7 +118,7 @@ def test_default_type_and_unmatched_block_id(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): app = App( @@ -127,12 +128,12 @@ def test_failure(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.action("aaa")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure_2(self): app = App( @@ -142,12 +143,12 @@ def test_failure_2(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.block_action("aaa")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) body = { diff --git a/tests/scenario_tests/test_block_suggestion.py b/tests/scenario_tests/test_block_suggestion.py index 67c5428a8..9c3a4fb3f 100644 --- a/tests/scenario_tests/test_block_suggestion.py +++ b/tests/scenario_tests/test_block_suggestion.py @@ -11,6 +11,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -74,7 +75,7 @@ def test_success(self): assert response.status == 200 assert response.body == expected_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_2(self): app = App( @@ -88,7 +89,7 @@ def test_success_2(self): assert response.status == 200 assert response.body == expected_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_multi(self): app = App( @@ -102,7 +103,7 @@ def test_success_multi(self): assert response.status == 200 assert response.body == expected_multi_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -117,7 +118,7 @@ def test_process_before_response(self): assert response.status == 200 assert response.body == expected_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response_multi(self): app = App( @@ -132,7 +133,7 @@ def test_process_before_response_multi(self): assert response.status == 200 assert response.body == expected_multi_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): app = App( @@ -142,12 +143,12 @@ def test_failure(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.options("mes_a")(show_multi_options) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure_2(self): app = App( @@ -157,12 +158,12 @@ def test_failure_2(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.block_suggestion("mes_a")(show_multi_options) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure_multi(self): app = App( @@ -172,12 +173,12 @@ def test_failure_multi(self): request = self.build_valid_multi_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.options("es_a")(show_options) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) body = { diff --git a/tests/scenario_tests/test_dialogs.py b/tests/scenario_tests/test_dialogs.py index 671ca723e..558ede787 100644 --- a/tests/scenario_tests/test_dialogs.py +++ b/tests/scenario_tests/test_dialogs.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -66,19 +67,19 @@ def test_success_without_type(self): assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(submission_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success(self): app = App( @@ -100,19 +101,19 @@ def test_success(self): assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(submission_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_2(self): app = App( @@ -128,19 +129,19 @@ def test_success_2(self): assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(submission_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -163,19 +164,19 @@ def test_process_before_response(self): assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(submission_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response_2(self): app = App( @@ -192,19 +193,19 @@ def test_process_before_response_2(self): assert response.status == 200 assert response.body == json.dumps(options_response) assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(submission_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_suggestion_failure_without_type(self): app = App( @@ -214,12 +215,12 @@ def test_suggestion_failure_without_type(self): request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.options("dialog-callback-iddddd")(handle_suggestion) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_suggestion_failure(self): app = App( @@ -229,12 +230,12 @@ def test_suggestion_failure(self): request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.dialog_suggestion("dialog-callback-iddddd")(handle_suggestion) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_suggestion_failure_2(self): app = App( @@ -244,14 +245,14 @@ def test_suggestion_failure_2(self): request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.options( {"type": "dialog_suggestion", "callback_id": "dialog-callback-iddddd"} )(handle_suggestion) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_submission_failure_without_type(self): app = App( @@ -261,12 +262,12 @@ def test_submission_failure_without_type(self): request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.action("dialog-callback-iddddd")(handle_submission) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_submission_failure(self): app = App( @@ -276,12 +277,12 @@ def test_submission_failure(self): request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.dialog_submission("dialog-callback-iddddd")(handle_submission) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_submission_failure_2(self): app = App( @@ -291,14 +292,14 @@ def test_submission_failure_2(self): request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.action( {"type": "dialog_submission", "callback_id": "dialog-callback-iddddd"} )(handle_submission) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_cancellation_failure_without_type(self): app = App( @@ -308,12 +309,12 @@ def test_cancellation_failure_without_type(self): request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.action("dialog-callback-iddddd")(handle_cancellation) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_cancellation_failure(self): app = App( @@ -323,12 +324,12 @@ def test_cancellation_failure(self): request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.dialog_cancellation("dialog-callback-iddddd")(handle_cancellation) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_cancellation_failure_2(self): app = App( @@ -338,14 +339,14 @@ def test_cancellation_failure_2(self): request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.action( {"type": "dialog_cancellation", "callback_id": "dialog-callback-iddddd"} )(handle_cancellation) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) suggestion_body = { diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index c80a23bff..e5a1e9f49 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -86,7 +87,7 @@ def handle_app_mention(body, say, payload, event): ) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -109,7 +110,7 @@ def handle_app_mention(body, logger, payload, event): ) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) valid_reaction_added_body = { "token": "verification_token", @@ -146,7 +147,7 @@ def handle_app_mention(body, say, payload, event): ) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -204,7 +205,7 @@ def handle_app_mention(say): ) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() # The listener should not be executed assert self.mock_received_requests.get("/chat.postMessage") is None @@ -263,7 +264,7 @@ def handle_member_left_channel(say): ) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) timestamp, body = str(int(time())), json.dumps(left_event_body) request: BoltRequest = BoltRequest( @@ -329,7 +330,7 @@ def handle_app_mention(say): ) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) timestamp, body = str(int(time())), json.dumps(left_event_body) request: BoltRequest = BoltRequest( @@ -395,7 +396,7 @@ def handler2(say: Say): response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 2 diff --git a/tests/scenario_tests/test_events_org_apps.py b/tests/scenario_tests/test_events_org_apps.py index c9c2e737b..732e23f8b 100644 --- a/tests/scenario_tests/test_events_org_apps.py +++ b/tests/scenario_tests/test_events_org_apps.py @@ -11,6 +11,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -107,7 +108,7 @@ def handle_app_mention(body): response = app.dispatch(request) assert response.status == 200 # auth.test API call must be skipped - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert result.called is True @@ -198,7 +199,7 @@ def handle_app_mention(body): response = app.dispatch(request) assert response.status == 200 # auth.test API call must be skipped - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert result.called is True @@ -256,6 +257,6 @@ def handle_app_mention(body): response = app.dispatch(request) assert response.status == 200 # auth.test API call must be skipped - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert result.called is True diff --git a/tests/scenario_tests/test_events_shared_channels.py b/tests/scenario_tests/test_events_shared_channels.py index 9829d91bc..55414d10f 100644 --- a/tests/scenario_tests/test_events_shared_channels.py +++ b/tests/scenario_tests/test_events_shared_channels.py @@ -9,6 +9,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -108,7 +109,7 @@ def handle_app_mention(body, say: Say, payload, event): ) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -135,7 +136,7 @@ def handle_app_mention(body, logger, payload, event): ) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) valid_reaction_added_body = { "token": "verification_token", @@ -184,7 +185,7 @@ def handle_app_mention(body, say, payload, event): ) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -258,7 +259,7 @@ def handle_app_mention(say): ) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() # The listener should not be executed assert self.mock_received_requests.get("/chat.postMessage") is None @@ -337,7 +338,7 @@ def handle_member_left_channel(say): ) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) timestamp, body = str(int(time())), json.dumps(left_event_body) request: BoltRequest = BoltRequest( @@ -423,7 +424,7 @@ def handle_app_mention(say): ) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) timestamp, body = str(int(time())), json.dumps(left_event_body) request: BoltRequest = BoltRequest( diff --git a/tests/scenario_tests/test_events_socket_mode.py b/tests/scenario_tests/test_events_socket_mode.py index 2bb8709e4..0c15f23ce 100644 --- a/tests/scenario_tests/test_events_socket_mode.py +++ b/tests/scenario_tests/test_events_socket_mode.py @@ -8,6 +8,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -72,7 +73,7 @@ def handle_app_mention(body, say, payload, event): ) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -94,7 +95,7 @@ def handle_app_mention(body, logger, payload, event): ) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) valid_reaction_added_body = { "token": "verification_token", @@ -130,7 +131,7 @@ def handle_app_mention(body, say, payload, event): ) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -181,7 +182,7 @@ def handle_app_mention(say): request: BoltRequest = BoltRequest(body=event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() # The listener should not be executed assert self.mock_received_requests.get("/chat.postMessage") is None @@ -237,7 +238,7 @@ def handle_member_left_channel(say): request: BoltRequest = BoltRequest(body=join_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request: BoltRequest = BoltRequest(body=left_event_body, mode="socket_mode") response = app.dispatch(request) @@ -297,7 +298,7 @@ def handle_app_mention(say): request: BoltRequest = BoltRequest(body=join_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request: BoltRequest = BoltRequest(body=left_event_body, mode="socket_mode") response = app.dispatch(request) @@ -356,6 +357,6 @@ def handler2(say: Say): response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 2 diff --git a/tests/scenario_tests/test_message.py b/tests/scenario_tests/test_message.py index d99edffc1..e750bfd40 100644 --- a/tests/scenario_tests/test_message.py +++ b/tests/scenario_tests/test_message.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -63,7 +64,7 @@ def test_string_keyword(self): request = self.build_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) time.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -77,7 +78,7 @@ def test_string_keyword_capturing(self): request = self.build_request2() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) time.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -93,7 +94,7 @@ def test_string_keyword_capturing2(self): request = self.build_request2() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) time.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -107,7 +108,7 @@ def test_string_keyword_unmatched(self): request = self.build_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_regexp_keyword(self): app = App( @@ -119,7 +120,7 @@ def test_regexp_keyword(self): request = self.build_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) time.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -133,7 +134,7 @@ def test_regexp_keyword_unmatched(self): request = self.build_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) message_body = { diff --git a/tests/scenario_tests/test_message_bot.py b/tests/scenario_tests/test_message_bot.py index be24e0fb4..94eae4440 100644 --- a/tests/scenario_tests/test_message_bot.py +++ b/tests/scenario_tests/test_message_bot.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -74,7 +75,7 @@ def handle_messages(event, logger): response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) time.sleep(1) # wait a bit after auto ack() assert result["call_count"] == 3 diff --git a/tests/scenario_tests/test_middleware.py b/tests/scenario_tests/test_middleware.py index deb677d1d..3d623fc02 100644 --- a/tests/scenario_tests/test_middleware.py +++ b/tests/scenario_tests/test_middleware.py @@ -9,6 +9,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -71,7 +72,7 @@ def test_no_next_call(self): response = app.dispatch(self.build_request()) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_next_call(self): app = App( @@ -84,7 +85,7 @@ def test_next_call(self): response = app.dispatch(self.build_request()) assert response.status == 200 assert response.body == "acknowledged!" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_class_call(self): class NextClass: @@ -101,7 +102,7 @@ def __call__(self, next): response = app.dispatch(self.build_request()) assert response.status == 200 assert response.body == "acknowledged!" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def just_ack(ack): diff --git a/tests/scenario_tests/test_shortcut.py b/tests/scenario_tests/test_shortcut.py index 42143942a..2310e7c45 100644 --- a/tests/scenario_tests/test_shortcut.py +++ b/tests/scenario_tests/test_shortcut.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -64,12 +65,12 @@ def test_success_both_global_and_message(self): request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_global(self): app = App( @@ -81,7 +82,7 @@ def test_success_global(self): request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_global_2(self): app = App( @@ -93,12 +94,12 @@ def test_success_global_2(self): request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_message(self): app = App( @@ -112,12 +113,12 @@ def test_success_message(self): request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_message_2(self): app = App( @@ -129,12 +130,12 @@ def test_success_message_2(self): request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response_global(self): app = App( @@ -147,7 +148,7 @@ def test_process_before_response_global(self): request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): app = App( @@ -157,12 +158,12 @@ def test_failure(self): request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.shortcut("another-one")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure_2(self): app = App( @@ -172,17 +173,17 @@ def test_failure_2(self): request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.global_shortcut("another-one")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) global_shortcut_body = { diff --git a/tests/scenario_tests/test_slash_command.py b/tests/scenario_tests/test_slash_command.py index 23f07dbc0..1db13ecca 100644 --- a/tests/scenario_tests/test_slash_command.py +++ b/tests/scenario_tests/test_slash_command.py @@ -9,6 +9,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -62,7 +63,7 @@ def test_success(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -75,7 +76,7 @@ def test_process_before_response(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): app = App( @@ -85,12 +86,12 @@ def test_failure(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.command("/another-one")(commander) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) slash_command_body = ( diff --git a/tests/scenario_tests/test_view_closed.py b/tests/scenario_tests/test_view_closed.py index 8294108f1..babf6f911 100644 --- a/tests/scenario_tests/test_view_closed.py +++ b/tests/scenario_tests/test_view_closed.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -65,7 +66,7 @@ def test_success(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_2(self): app = App( @@ -77,7 +78,7 @@ def test_success_2(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -90,7 +91,7 @@ def test_process_before_response(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): app = App( @@ -100,12 +101,12 @@ def test_failure(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.view("view-idddd")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): app = App( @@ -115,12 +116,12 @@ def test_failure(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.view({"type": "view_closed", "callback_id": "view-idddd"})(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure_2(self): app = App( @@ -130,12 +131,12 @@ def test_failure_2(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.view_closed("view-idddd")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) body = { diff --git a/tests/scenario_tests/test_view_submission.py b/tests/scenario_tests/test_view_submission.py index ced03e181..9ea3c367b 100644 --- a/tests/scenario_tests/test_view_submission.py +++ b/tests/scenario_tests/test_view_submission.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -65,7 +66,7 @@ def test_success(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_2(self): app = App( @@ -77,7 +78,7 @@ def test_success_2(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -90,7 +91,7 @@ def test_process_before_response(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): app = App( @@ -100,12 +101,12 @@ def test_failure(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.view("view-idddd")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure_2(self): app = App( @@ -115,12 +116,12 @@ def test_failure_2(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.view_submission("view-idddd")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) body = { diff --git a/tests/scenario_tests/test_workflow_steps.py b/tests/scenario_tests/test_workflow_steps.py index b41d32e12..38cc803c3 100644 --- a/tests/scenario_tests/test_workflow_steps.py +++ b/tests/scenario_tests/test_workflow_steps.py @@ -11,6 +11,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -67,7 +68,7 @@ def test_edit(self): request: BoltRequest = BoltRequest(body=body, headers=headers) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app = self.build_app("copy_review___") response = app.dispatch(request) @@ -85,7 +86,7 @@ def test_edit_process_before_response(self): request: BoltRequest = BoltRequest(body=body, headers=headers) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app = self.build_process_before_response_app("copy_review___") response = app.dispatch(request) @@ -103,7 +104,7 @@ def test_save(self): request: BoltRequest = BoltRequest(body=body, headers=headers) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app = self.build_app("copy_review___") response = app.dispatch(request) @@ -121,7 +122,7 @@ def test_save_process_before_response(self): request: BoltRequest = BoltRequest(body=body, headers=headers) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app = self.build_process_before_response_app("copy_review___") response = app.dispatch(request) @@ -139,7 +140,7 @@ def test_execute(self): request: BoltRequest = BoltRequest(body=body, headers=headers) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) time_module.sleep(0.5) assert self.mock_received_requests["/workflows.stepCompleted"] == 1 @@ -159,7 +160,7 @@ def test_execute_process_before_response(self): request: BoltRequest = BoltRequest(body=body, headers=headers) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) time_module.sleep(0.5) assert self.mock_received_requests["/workflows.stepCompleted"] == 1 diff --git a/tests/scenario_tests_async/test_app_bot_only.py b/tests/scenario_tests_async/test_app_bot_only.py index 58685393a..a94fd497d 100644 --- a/tests/scenario_tests_async/test_app_bot_only.py +++ b/tests/scenario_tests_async/test_app_bot_only.py @@ -20,6 +20,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -89,7 +90,7 @@ async def test_bot_only(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -106,7 +107,7 @@ async def test_bot_only_oauth_settings_conflicts(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -122,7 +123,7 @@ async def test_bot_only_oauth_settings(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -139,7 +140,7 @@ async def test_bot_only_oauth_flow_conflicts(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -155,7 +156,7 @@ async def test_bot_only_oauth_flow(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 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 f58be3af5..3396090a8 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 @@ -16,6 +16,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -123,7 +124,7 @@ async def run_app_and_verify(self, app: AsyncApp): ) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(0.5) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 diff --git a/tests/scenario_tests_async/test_attachment_actions.py b/tests/scenario_tests_async/test_attachment_actions.py index 00338c68e..4dfc00cda 100644 --- a/tests/scenario_tests_async/test_attachment_actions.py +++ b/tests/scenario_tests_async/test_attachment_actions.py @@ -12,6 +12,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -73,7 +74,7 @@ async def test_success_without_type(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success(self): @@ -91,7 +92,7 @@ async def test_success(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_2(self): @@ -104,7 +105,7 @@ async def test_success_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -123,7 +124,7 @@ async def test_process_before_response(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response_2(self): @@ -137,7 +138,7 @@ async def test_process_before_response_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_without_type(self): @@ -148,12 +149,12 @@ async def test_failure_without_type(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.action("unknown")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): @@ -164,7 +165,7 @@ async def test_failure(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.action( { @@ -174,7 +175,7 @@ async def test_failure(self): )(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_2(self): @@ -185,12 +186,12 @@ async def test_failure_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.attachment_action("unknown")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) # https://api.slack.com/legacy/interactive-messages diff --git a/tests/scenario_tests_async/test_authorize.py b/tests/scenario_tests_async/test_authorize.py index 01a3c4331..bea81df92 100644 --- a/tests/scenario_tests_async/test_authorize.py +++ b/tests/scenario_tests_async/test_authorize.py @@ -13,6 +13,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -102,7 +103,7 @@ async def test_success(self): response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): @@ -132,7 +133,7 @@ async def test_bot_context_attributes(self): response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_user_context_attributes(self): @@ -147,7 +148,7 @@ async def test_user_context_attributes(self): response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) body = { diff --git a/tests/scenario_tests_async/test_block_actions.py b/tests/scenario_tests_async/test_block_actions.py index 4ee1b1fb1..c2e371c08 100644 --- a/tests/scenario_tests_async/test_block_actions.py +++ b/tests/scenario_tests_async/test_block_actions.py @@ -13,6 +13,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -74,7 +75,7 @@ async def test_success(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_2(self): @@ -87,7 +88,7 @@ async def test_success_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_default_type(self): @@ -100,7 +101,7 @@ async def test_default_type(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_default_type_no_block_id(self): @@ -113,7 +114,7 @@ async def test_default_type_no_block_id(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_default_type_unmatched_block_id(self): @@ -126,7 +127,7 @@ async def test_default_type_unmatched_block_id(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -140,7 +141,7 @@ async def test_process_before_response(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response_2(self): @@ -154,7 +155,7 @@ async def test_process_before_response_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): @@ -165,12 +166,12 @@ async def test_failure(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.action("aaa")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_2(self): @@ -181,12 +182,12 @@ async def test_failure_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.block_action("aaa")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) body = { diff --git a/tests/scenario_tests_async/test_block_suggestion.py b/tests/scenario_tests_async/test_block_suggestion.py index 7351b290b..dc67391e0 100644 --- a/tests/scenario_tests_async/test_block_suggestion.py +++ b/tests/scenario_tests_async/test_block_suggestion.py @@ -13,6 +13,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -82,7 +83,7 @@ async def test_success(self): assert response.status == 200 assert response.body == expected_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_2(self): @@ -97,7 +98,7 @@ async def test_success_2(self): assert response.status == 200 assert response.body == expected_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_multi(self): @@ -112,7 +113,7 @@ async def test_success_multi(self): assert response.status == 200 assert response.body == expected_multi_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -128,7 +129,7 @@ async def test_process_before_response(self): assert response.status == 200 assert response.body == expected_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response_multi(self): @@ -144,7 +145,7 @@ async def test_process_before_response_multi(self): assert response.status == 200 assert response.body == expected_multi_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): @@ -155,12 +156,12 @@ async def test_failure(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.options("mes_a")(show_multi_options) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_2(self): @@ -171,12 +172,12 @@ async def test_failure_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.block_suggestion("mes_a")(show_multi_options) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_multi(self): @@ -187,12 +188,12 @@ async def test_failure_multi(self): request = self.build_valid_multi_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.options("es_a")(show_options) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) body = { diff --git a/tests/scenario_tests_async/test_dialogs.py b/tests/scenario_tests_async/test_dialogs.py index 1cad07ff4..cb1757b2b 100644 --- a/tests/scenario_tests_async/test_dialogs.py +++ b/tests/scenario_tests_async/test_dialogs.py @@ -12,6 +12,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -74,19 +75,19 @@ async def test_success_without_type(self): assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(submission_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success(self): @@ -109,19 +110,19 @@ async def test_success(self): assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(submission_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_2(self): @@ -138,19 +139,19 @@ async def test_success_2(self): assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(submission_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -174,19 +175,19 @@ async def test_process_before_response(self): assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(submission_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response_2(self): @@ -204,19 +205,19 @@ async def test_process_before_response_2(self): assert response.status == 200 assert response.body == json.dumps(options_response) assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(submission_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_suggestion_failure_without_type(self): @@ -227,12 +228,12 @@ async def test_suggestion_failure_without_type(self): request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.options("dialog-callback-iddddd")(handle_suggestion) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_suggestion_failure(self): @@ -243,12 +244,12 @@ async def test_suggestion_failure(self): request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.dialog_suggestion("dialog-callback-iddddd")(handle_suggestion) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_suggestion_failure_2(self): @@ -259,14 +260,14 @@ async def test_suggestion_failure_2(self): request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.options( {"type": "dialog_suggestion", "callback_id": "dialog-callback-iddddd"} )(handle_suggestion) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_submission_failure_without_type(self): @@ -277,12 +278,12 @@ async def test_submission_failure_without_type(self): request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.action("dialog-callback-iddddd")(handle_submission) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_submission_failure(self): @@ -293,12 +294,12 @@ async def test_submission_failure(self): request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.dialog_submission("dialog-callback-iddddd")(handle_submission) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_submission_failure_2(self): @@ -309,14 +310,14 @@ async def test_submission_failure_2(self): request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.action( {"type": "dialog_submission", "callback_id": "dialog-callback-iddddd"} )(handle_submission) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_cancellation_failure_without_type(self): @@ -327,12 +328,12 @@ async def test_cancellation_failure_without_type(self): request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.action("dialog-callback-iddddd")(handle_cancellation) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_cancellation_failure(self): @@ -343,12 +344,12 @@ async def test_cancellation_failure(self): request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.dialog_cancellation("dialog-callback-iddddd")(handle_cancellation) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_cancellation_failure_2(self): @@ -359,14 +360,14 @@ async def test_cancellation_failure_2(self): request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.action( {"type": "dialog_cancellation", "callback_id": "dialog-callback-iddddd"} )(handle_cancellation) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) suggestion_body = { diff --git a/tests/scenario_tests_async/test_events.py b/tests/scenario_tests_async/test_events.py index 16fb92ef3..7e6f25a9c 100644 --- a/tests/scenario_tests_async/test_events.py +++ b/tests/scenario_tests_async/test_events.py @@ -14,6 +14,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -73,7 +74,7 @@ async def test_app_mention(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -89,7 +90,7 @@ async def test_process_before_response(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) # no sleep here assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -101,7 +102,7 @@ async def test_middleware_skip(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_simultaneous_requests(self): @@ -140,7 +141,7 @@ async def test_reaction_added(self): request = self.build_valid_reaction_added_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -193,7 +194,7 @@ async def test_self_events(self): ) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() # The listener should not be executed assert self.mock_received_requests.get("/chat.postMessage") is None @@ -257,7 +258,7 @@ async def handle_member_left_channel(say): ) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) timestamp, body = str(int(time())), json.dumps(left_event_body) request = AsyncBoltRequest( @@ -329,7 +330,7 @@ async def handle_member_left_channel(say): ) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) timestamp, body = str(int(time())), json.dumps(left_event_body) request = AsyncBoltRequest( diff --git a/tests/scenario_tests_async/test_events_org_apps.py b/tests/scenario_tests_async/test_events_org_apps.py index 596927879..9ec48ff7e 100644 --- a/tests/scenario_tests_async/test_events_org_apps.py +++ b/tests/scenario_tests_async/test_events_org_apps.py @@ -16,6 +16,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -115,7 +116,7 @@ async def handle_app_mention(body): response = await app.async_dispatch(request) assert response.status == 200 # auth.test API call must be skipped - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert result.called is True @@ -208,7 +209,7 @@ async def handle_app_mention(body): response = await app.async_dispatch(request) assert response.status == 200 # auth.test API call must be skipped - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert result.called is True @@ -267,6 +268,6 @@ async def handle_app_mention(body): response = await app.async_dispatch(request) assert response.status == 200 # auth.test API call must be skipped - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert result.called is True diff --git a/tests/scenario_tests_async/test_events_shared_channels.py b/tests/scenario_tests_async/test_events_shared_channels.py index b7877cf15..8f3b4cea7 100644 --- a/tests/scenario_tests_async/test_events_shared_channels.py +++ b/tests/scenario_tests_async/test_events_shared_channels.py @@ -14,6 +14,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -83,7 +84,7 @@ async def test_app_mention(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -100,7 +101,7 @@ async def test_process_before_response(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) # no sleep here assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -116,7 +117,7 @@ async def test_middleware_skip(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_simultaneous_requests(self): @@ -157,7 +158,7 @@ async def test_reaction_added(self): request = self.build_valid_reaction_added_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -220,7 +221,7 @@ async def test_self_events(self): ) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() # The listener should not be executed assert self.mock_received_requests.get("/chat.postMessage") is None @@ -301,7 +302,7 @@ async def handle_member_left_channel(say): ) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) timestamp, body = str(int(time())), json.dumps(left_event_body) request = AsyncBoltRequest( @@ -390,7 +391,7 @@ async def handle_member_left_channel(say): ) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) timestamp, body = str(int(time())), json.dumps(left_event_body) request = AsyncBoltRequest( diff --git a/tests/scenario_tests_async/test_events_socket_mode.py b/tests/scenario_tests_async/test_events_socket_mode.py index ce6485f41..107af53d4 100644 --- a/tests/scenario_tests_async/test_events_socket_mode.py +++ b/tests/scenario_tests_async/test_events_socket_mode.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -50,7 +51,7 @@ async def test_app_mention(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -65,7 +66,7 @@ async def test_process_before_response(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) # no sleep here assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -77,7 +78,7 @@ async def test_middleware_skip(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_simultaneous_requests(self): @@ -109,7 +110,7 @@ async def test_reaction_added(self): request = self.build_valid_reaction_added_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -153,7 +154,7 @@ async def test_self_events(self): request = AsyncBoltRequest(body=self_event, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() # The listener should not be executed assert self.mock_received_requests.get("/chat.postMessage") is None @@ -211,7 +212,7 @@ async def handle_member_left_channel(say): request = AsyncBoltRequest(body=join_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = AsyncBoltRequest(body=left_event_body, mode="socket_mode") response = await app.async_dispatch(request) @@ -274,7 +275,7 @@ async def handle_member_left_channel(say): request = AsyncBoltRequest(body=join_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = AsyncBoltRequest(body=left_event_body, mode="socket_mode") response = await app.async_dispatch(request) diff --git a/tests/scenario_tests_async/test_message.py b/tests/scenario_tests_async/test_message.py index 60ed2e458..b69e849f6 100644 --- a/tests/scenario_tests_async/test_message.py +++ b/tests/scenario_tests_async/test_message.py @@ -12,6 +12,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -70,7 +71,7 @@ async def test_string_keyword(self): request = self.build_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -85,7 +86,7 @@ async def test_string_keyword_capturing(self): request = self.build_request2() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -102,7 +103,7 @@ async def test_string_keyword_capturing2(self): request = self.build_request2() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -117,7 +118,7 @@ async def test_string_keyword_unmatched(self): request = self.build_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_regexp_keyword(self): @@ -130,7 +131,7 @@ async def test_regexp_keyword(self): request = self.build_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 @@ -145,7 +146,7 @@ async def test_regexp_keyword_unmatched(self): request = self.build_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) message_body = { diff --git a/tests/scenario_tests_async/test_message_bot.py b/tests/scenario_tests_async/test_message_bot.py index 599848bc5..0dfe8c07e 100644 --- a/tests/scenario_tests_async/test_message_bot.py +++ b/tests/scenario_tests_async/test_message_bot.py @@ -11,6 +11,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -79,7 +80,7 @@ async def handle_messages(event, logger): response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(1) # wait a bit after auto ack() assert result["call_count"] == 3 diff --git a/tests/scenario_tests_async/test_middleware.py b/tests/scenario_tests_async/test_middleware.py index bcc8d4baf..176d89fb2 100644 --- a/tests/scenario_tests_async/test_middleware.py +++ b/tests/scenario_tests_async/test_middleware.py @@ -11,6 +11,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -78,7 +79,7 @@ async def test_no_next_call(self): response = await app.async_dispatch(self.build_request()) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_next_call(self): @@ -92,7 +93,7 @@ async def test_next_call(self): response = await app.async_dispatch(self.build_request()) assert response.status == 200 assert response.body == "acknowledged!" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) async def just_ack(ack): diff --git a/tests/scenario_tests_async/test_shortcut.py b/tests/scenario_tests_async/test_shortcut.py index 5627198af..61197311e 100644 --- a/tests/scenario_tests_async/test_shortcut.py +++ b/tests/scenario_tests_async/test_shortcut.py @@ -12,6 +12,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -72,12 +73,12 @@ async def test_success_both_global_and_message(self): request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_global(self): @@ -90,7 +91,7 @@ async def test_success_global(self): request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_global_2(self): @@ -103,12 +104,12 @@ async def test_success_global_2(self): request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_message(self): @@ -123,12 +124,12 @@ async def test_success_message(self): request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_message_2(self): @@ -141,12 +142,12 @@ async def test_success_message_2(self): request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response_global(self): @@ -160,7 +161,7 @@ async def test_process_before_response_global(self): request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): @@ -171,12 +172,12 @@ async def test_failure(self): request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.shortcut("another-one")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_2(self): @@ -187,17 +188,17 @@ async def test_failure_2(self): request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.global_shortcut("another-one")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) global_shortcut_body = { diff --git a/tests/scenario_tests_async/test_slash_command.py b/tests/scenario_tests_async/test_slash_command.py index 44c881b9a..42e427db0 100644 --- a/tests/scenario_tests_async/test_slash_command.py +++ b/tests/scenario_tests_async/test_slash_command.py @@ -11,6 +11,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -70,7 +71,7 @@ async def test_success(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -84,7 +85,7 @@ async def test_process_before_response(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): @@ -95,12 +96,12 @@ async def test_failure(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.command("/another-one")(commander) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) slash_command_body = ( diff --git a/tests/scenario_tests_async/test_view_closed.py b/tests/scenario_tests_async/test_view_closed.py index d5af6167c..4f3afdbe7 100644 --- a/tests/scenario_tests_async/test_view_closed.py +++ b/tests/scenario_tests_async/test_view_closed.py @@ -12,6 +12,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -73,7 +74,7 @@ async def test_success(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_2(self): @@ -86,7 +87,7 @@ async def test_success_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -100,7 +101,7 @@ async def test_process_before_response(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): @@ -111,12 +112,12 @@ async def test_failure(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.view("view-idddd")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): @@ -127,12 +128,12 @@ async def test_failure(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.view({"type": "view_closed", "callback_id": "view-idddd"})(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_2(self): @@ -143,12 +144,12 @@ async def test_failure_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.view_closed("view-idddd")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) body = { diff --git a/tests/scenario_tests_async/test_view_submission.py b/tests/scenario_tests_async/test_view_submission.py index 2d7e1d993..b9538537e 100644 --- a/tests/scenario_tests_async/test_view_submission.py +++ b/tests/scenario_tests_async/test_view_submission.py @@ -12,6 +12,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -73,7 +74,7 @@ async def test_success(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_2(self): @@ -86,7 +87,7 @@ async def test_success_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -100,7 +101,7 @@ async def test_process_before_response(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): @@ -111,12 +112,12 @@ async def test_failure(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.view("view-idddd")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_2(self): @@ -127,12 +128,12 @@ async def test_failure_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.view_submission("view-idddd")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) body = { diff --git a/tests/scenario_tests_async/test_workflow_steps.py b/tests/scenario_tests_async/test_workflow_steps.py index 0d36ef99b..cf88d46b4 100644 --- a/tests/scenario_tests_async/test_workflow_steps.py +++ b/tests/scenario_tests_async/test_workflow_steps.py @@ -18,6 +18,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -82,7 +83,7 @@ async def test_edit(self): request = AsyncBoltRequest(body=body, headers=headers) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app = self.build_app("copy_review___") response = await app.async_dispatch(request) @@ -101,7 +102,7 @@ async def test_edit_process_before_response(self): request = AsyncBoltRequest(body=body, headers=headers) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app = self.build_process_before_response_app("copy_review___") response = await app.async_dispatch(request) @@ -120,7 +121,7 @@ async def test_save(self): request = AsyncBoltRequest(body=body, headers=headers) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app = self.build_app("copy_review___") response = await app.async_dispatch(request) @@ -139,7 +140,7 @@ async def test_save_process_before_response(self): request = AsyncBoltRequest(body=body, headers=headers) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app = self.build_process_before_response_app("copy_review___") response = await app.async_dispatch(request) @@ -158,7 +159,7 @@ async def test_execute(self): request = AsyncBoltRequest(body=body, headers=headers) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(0.5) assert self.mock_received_requests["/workflows.stepCompleted"] == 1 @@ -179,7 +180,7 @@ async def test_execute_process_before_response(self): request = AsyncBoltRequest(body=body, headers=headers) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) await asyncio.sleep(0.5) assert self.mock_received_requests["/workflows.stepCompleted"] == 1 diff --git a/tests/slack_bolt/authorization/test_authorize.py b/tests/slack_bolt/authorization/test_authorize.py index 9dc613ded..eb5c02087 100644 --- a/tests/slack_bolt/authorization/test_authorize.py +++ b/tests/slack_bolt/authorization/test_authorize.py @@ -13,6 +13,7 @@ from tests.mock_web_api_server import ( cleanup_mock_web_api_server, setup_mock_web_api_server, + assert_auth_test_count, ) @@ -50,7 +51,7 @@ def test_installation_store_legacy(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) result = authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" @@ -58,7 +59,7 @@ def test_installation_store_legacy(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 2) def test_installation_store_cached_legacy(self): installation_store = LegacyMemoryInstallationStore() @@ -77,7 +78,7 @@ def test_installation_store_cached_legacy(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) result = authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" @@ -85,7 +86,7 @@ def test_installation_store_cached_legacy(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 1 # cached + assert_auth_test_count(self, 1) # cached def test_installation_store_bot_only(self): installation_store = BotOnlyMemoryInstallationStore() @@ -105,7 +106,7 @@ def test_installation_store_bot_only(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) result = authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" @@ -113,7 +114,7 @@ def test_installation_store_bot_only(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 2) def test_installation_store_cached_bot_only(self): installation_store = BotOnlyMemoryInstallationStore() @@ -134,7 +135,7 @@ def test_installation_store_cached_bot_only(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) result = authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" @@ -142,7 +143,7 @@ def test_installation_store_cached_bot_only(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 1 # cached + assert_auth_test_count(self, 1) # cached def test_installation_store(self): installation_store = MemoryInstallationStore() @@ -158,7 +159,7 @@ def test_installation_store(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) result = authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" @@ -166,7 +167,7 @@ def test_installation_store(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 2) def test_installation_store_cached(self): installation_store = MemoryInstallationStore() @@ -184,7 +185,7 @@ def test_installation_store_cached(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) result = authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" @@ -192,7 +193,7 @@ def test_installation_store_cached(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" - assert self.mock_received_requests["/auth.test"] == 1 # cached + assert_auth_test_count(self, 1) # cached class LegacyMemoryInstallationStore(InstallationStore): diff --git a/tests/slack_bolt/oauth/test_oauth_flow.py b/tests/slack_bolt/oauth/test_oauth_flow.py index cbb119753..9be907b66 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow.py +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -14,6 +14,7 @@ from tests.mock_web_api_server import ( cleanup_mock_web_api_server, setup_mock_web_api_server, + assert_auth_test_count, ) @@ -139,7 +140,7 @@ def test_handle_callback(self): request = BoltRequest(body=body, headers=headers) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_handle_callback_invalid_state(self): oauth_flow = OAuthFlow( diff --git a/tests/slack_bolt_async/authorization/test_async_authorize.py b/tests/slack_bolt_async/authorization/test_async_authorize.py index b1dfd4685..a19190ae5 100644 --- a/tests/slack_bolt_async/authorization/test_async_authorize.py +++ b/tests/slack_bolt_async/authorization/test_async_authorize.py @@ -19,6 +19,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -68,7 +69,7 @@ async def test_installation_store_legacy(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) result = await authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" @@ -76,7 +77,7 @@ async def test_installation_store_legacy(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 2) @pytest.mark.asyncio async def test_installation_store_cached_legacy(self): @@ -96,7 +97,7 @@ async def test_installation_store_cached_legacy(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) result = await authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" @@ -104,7 +105,7 @@ async def test_installation_store_cached_legacy(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 1 # cached + await assert_auth_test_count_async(self, 1) # cached @pytest.mark.asyncio async def test_installation_store_bot_only(self): @@ -125,7 +126,7 @@ async def test_installation_store_bot_only(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) result = await authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" @@ -133,7 +134,7 @@ async def test_installation_store_bot_only(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 2) @pytest.mark.asyncio async def test_installation_store_cached_bot_only(self): @@ -155,7 +156,7 @@ async def test_installation_store_cached_bot_only(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) result = await authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" @@ -163,7 +164,7 @@ async def test_installation_store_cached_bot_only(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None - assert self.mock_received_requests["/auth.test"] == 1 # cached + await assert_auth_test_count_async(self, 1) # cached @pytest.mark.asyncio async def test_installation_store(self): @@ -181,7 +182,7 @@ async def test_installation_store(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) result = await authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" @@ -189,7 +190,7 @@ async def test_installation_store(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 2) @pytest.mark.asyncio async def test_installation_store_cached(self): @@ -209,7 +210,7 @@ async def test_installation_store_cached(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) result = await authorize( context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111" @@ -217,7 +218,7 @@ async def test_installation_store_cached(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" - assert self.mock_received_requests["/auth.test"] == 1 # cached + await assert_auth_test_count_async(self, 1) # cached class LegacyMemoryInstallationStore(AsyncInstallationStore): diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index 090429bd3..2aecaf424 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -24,6 +24,7 @@ from tests.mock_web_api_server import ( cleanup_mock_web_api_server, setup_mock_web_api_server, + assert_auth_test_count_async, ) @@ -179,7 +180,7 @@ async def test_handle_callback(self): request = AsyncBoltRequest(body=body, headers=headers) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_handle_callback_invalid_state(self): diff --git a/tests/utils.py b/tests/utils.py index 185e41b42..bf642fbd9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,8 +4,23 @@ def remove_os_env_temporarily() -> dict: old_env = os.environ.copy() os.environ.clear() + for key, value in old_env.items(): + if key.startswith("BOLT_PYTHON_"): + os.environ[key] = value return old_env def restore_os_env(old_env: dict) -> None: os.environ.update(old_env) + + +def get_mock_server_mode() -> str: + """Returns a str representing the mode. + + :return: threading/multiprocessing + """ + mode = os.environ.get("BOLT_PYTHON_MOCK_SERVER_MODE") + if mode is None: + return "multiprocessing" + else: + return mode From ab9619630281b0563c7ae95068f89fd72602bf76 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 27 Jan 2021 14:13:48 +0900 Subject: [PATCH 262/865] Add Workflow step decorator --- .../async_steps_from_apps.py | 2 +- .../async_steps_from_apps_decorator.py | 201 ++++++++ .../async_steps_from_apps_primitive.py | 2 +- .../{ => workflow_steps}/steps_from_apps.py | 2 +- .../steps_from_apps_decorator.py | 200 ++++++++ .../steps_from_apps_primitive.py | 2 +- slack_bolt/app/app.py | 12 +- slack_bolt/app/async_app.py | 16 +- slack_bolt/workflows/step/async_step.py | 320 +++++++++++-- slack_bolt/workflows/step/internals.py | 6 + slack_bolt/workflows/step/step.py | 299 ++++++++++-- .../test_workflow_steps_decorator_simple.py | 425 +++++++++++++++++ ...test_workflow_steps_decorator_with_args.py | 429 +++++++++++++++++ .../test_workflow_steps_decorator_simple.py | 447 +++++++++++++++++ ...test_workflow_steps_decorator_with_args.py | 451 ++++++++++++++++++ 15 files changed, 2720 insertions(+), 94 deletions(-) rename examples/{ => workflow_steps}/async_steps_from_apps.py (99%) create mode 100644 examples/workflow_steps/async_steps_from_apps_decorator.py rename examples/{ => workflow_steps}/async_steps_from_apps_primitive.py (99%) rename examples/{ => workflow_steps}/steps_from_apps.py (99%) create mode 100644 examples/workflow_steps/steps_from_apps_decorator.py rename examples/{ => workflow_steps}/steps_from_apps_primitive.py (99%) create mode 100644 slack_bolt/workflows/step/internals.py create mode 100644 tests/scenario_tests/test_workflow_steps_decorator_simple.py create mode 100644 tests/scenario_tests/test_workflow_steps_decorator_with_args.py create mode 100644 tests/scenario_tests_async/test_workflow_steps_decorator_simple.py create mode 100644 tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py diff --git a/examples/async_steps_from_apps.py b/examples/workflow_steps/async_steps_from_apps.py similarity index 99% rename from examples/async_steps_from_apps.py rename to examples/workflow_steps/async_steps_from_apps.py index 061012eb0..05b7801a7 100644 --- a/examples/async_steps_from_apps.py +++ b/examples/workflow_steps/async_steps_from_apps.py @@ -2,7 +2,7 @@ # instead of slack_bolt in requirements.txt import sys -sys.path.insert(1, "..") +sys.path.insert(1, "../..") # ------------------------------------------------ import logging diff --git a/examples/workflow_steps/async_steps_from_apps_decorator.py b/examples/workflow_steps/async_steps_from_apps_decorator.py new file mode 100644 index 000000000..e4f6b3194 --- /dev/null +++ b/examples/workflow_steps/async_steps_from_apps_decorator.py @@ -0,0 +1,201 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "../..") +# ------------------------------------------------ + +import asyncio +import logging + +from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient +from slack_bolt.async_app import AsyncApp, AsyncAck +from slack_bolt.workflows.step.async_step import ( + AsyncConfigure, + AsyncUpdate, + AsyncComplete, + AsyncFail, + AsyncWorkflowStep, +) + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = AsyncApp() + + +# https://api.slack.com/tutorials/workflow-builder-steps + +copy_review_step = AsyncWorkflowStep.builder("copy_review") + + +@copy_review_step.edit +async def edit(ack: AsyncAck, step: dict, configure: AsyncConfigure): + await ack() + await configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save +async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): + state_values = view["state"]["values"] + await update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + await ack() + + +pseudo_database = {} + + +async def additional_matcher(step): + email = str(step.get("inputs", {}).get("taskAuthorEmail")) + if "@" not in email: + return False + return True + + +async def noop_middleware(next): + return await next() + + +async def notify_execution(client: AsyncWebClient, step: dict): + await asyncio.sleep(5) + await client.chat_postMessage( + channel="#random", text=f"Step execution: ```{step}```" + ) + + +@copy_review_step.execute( + matchers=[additional_matcher], + middleware=[noop_middleware], + lazy=[notify_execution], +) +async def execute( + step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail +): + try: + await complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + user_lookup: AsyncSlackResponse = await client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user_lookup["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) + + +app.step(copy_review_step) + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/examples/async_steps_from_apps_primitive.py b/examples/workflow_steps/async_steps_from_apps_primitive.py similarity index 99% rename from examples/async_steps_from_apps_primitive.py rename to examples/workflow_steps/async_steps_from_apps_primitive.py index 905fc38a8..b9da584e9 100644 --- a/examples/async_steps_from_apps_primitive.py +++ b/examples/workflow_steps/async_steps_from_apps_primitive.py @@ -2,7 +2,7 @@ # instead of slack_bolt in requirements.txt import sys -sys.path.insert(1, "..") +sys.path.insert(1, "../..") # ------------------------------------------------ import logging diff --git a/examples/steps_from_apps.py b/examples/workflow_steps/steps_from_apps.py similarity index 99% rename from examples/steps_from_apps.py rename to examples/workflow_steps/steps_from_apps.py index 26cc9665a..a34807d99 100644 --- a/examples/steps_from_apps.py +++ b/examples/workflow_steps/steps_from_apps.py @@ -2,7 +2,7 @@ # instead of slack_bolt in requirements.txt import sys -sys.path.insert(1, "..") +sys.path.insert(1, "../..") # ------------------------------------------------ import logging diff --git a/examples/workflow_steps/steps_from_apps_decorator.py b/examples/workflow_steps/steps_from_apps_decorator.py new file mode 100644 index 000000000..64946b4a3 --- /dev/null +++ b/examples/workflow_steps/steps_from_apps_decorator.py @@ -0,0 +1,200 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "../..") +# ------------------------------------------------ + +import time +import logging + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt import App, Ack +from slack_bolt.workflows.step import Configure, Update, Complete, Fail, WorkflowStep + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = App() + + +@app.middleware # or app.use(log_request) +def log_request(logger, body, next): + logger.debug(body) + return next() + + +# https://api.slack.com/tutorials/workflow-builder-steps + +copy_review_step = WorkflowStep.builder("copy_review") + + +@copy_review_step.edit +def edit(ack: Ack, step, configure: Configure): + ack() + configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save +def save(ack: Ack, step: dict, view: dict, update: Update): + state_values = view["state"]["values"] + update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + ack() + + +pseudo_database = {} + + +def additional_matcher(step): + email = str(step.get("inputs", {}).get("taskAuthorEmail")) + if "@" not in email: + return False + return True + + +def noop_middleware(next): + return next() + + +def notify_execution(client: WebClient, step: dict): + time.sleep(5) + client.chat_postMessage(channel="#random", text=f"Step execution: ```{step}```") + + +@copy_review_step.execute( + matchers=[additional_matcher], + middleware=[noop_middleware], + lazy=[notify_execution], +) +def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): + try: + complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user_lookup: SlackResponse = client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user_lookup["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) + + +app.step(copy_review_step) + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/examples/steps_from_apps_primitive.py b/examples/workflow_steps/steps_from_apps_primitive.py similarity index 99% rename from examples/steps_from_apps_primitive.py rename to examples/workflow_steps/steps_from_apps_primitive.py index efd206eda..1777a784c 100644 --- a/examples/steps_from_apps_primitive.py +++ b/examples/workflow_steps/steps_from_apps_primitive.py @@ -2,7 +2,7 @@ # instead of slack_bolt in requirements.txt import sys -sys.path.insert(1, "..") +sys.path.insert(1, "../..") # ------------------------------------------------ import logging diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 7d8678954..fa5ec277e 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -67,6 +67,7 @@ get_name_for_callable, ) from slack_bolt.workflows.step import WorkflowStep, WorkflowStepMiddleware +from slack_bolt.workflows.step.step import WorkflowStepBuilder class App: @@ -422,7 +423,7 @@ def middleware(self, *args) -> Optional[Callable]: def step( self, - callback_id: Union[str, Pattern, WorkflowStep], + callback_id: Union[str, Pattern, WorkflowStep, WorkflowStepBuilder], edit: Optional[ Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]] ] = None, @@ -433,7 +434,10 @@ def step( Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]] ] = None, ): - """Registers a new Workflow Step listener""" + """Registers a new Workflow Step listener + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step + by a decorator, use WorkflowStepBuilder's methods. + """ step = callback_id if isinstance(callback_id, (str, Pattern)): step = WorkflowStep( @@ -442,8 +446,10 @@ def step( save=save, execute=execute, ) + elif isinstance(step, WorkflowStepBuilder): + step = step.build() elif not isinstance(step, WorkflowStep): - raise BoltError("Invalid step object") + raise BoltError(f"Invalid step object ({type(step)})") self.use(WorkflowStepMiddleware(step, self.listener_runner)) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index bf3b63b68..f28a4b7ff 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -13,7 +13,10 @@ ) from slack_bolt.oauth.async_internals import select_consistent_installation_store from slack_bolt.util.utils import get_name_for_callable -from slack_bolt.workflows.step.async_step import AsyncWorkflowStep +from slack_bolt.workflows.step.async_step import ( + AsyncWorkflowStep, + AsyncWorkflowStepBuilder, +) from slack_bolt.workflows.step.async_step_middleware import AsyncWorkflowStepMiddleware from slack_sdk.oauth.installation_store.async_installation_store import ( AsyncInstallationStore, @@ -462,7 +465,7 @@ def middleware(self, *args) -> Optional[Callable]: def step( self, - callback_id: Union[str, Pattern, AsyncWorkflowStep], + callback_id: Union[str, Pattern, AsyncWorkflowStep, AsyncWorkflowStepBuilder], edit: Optional[ Union[ Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable] @@ -479,7 +482,10 @@ def step( ] ] = None, ): - """Registers a new Workflow Step listener""" + """Registers a new Workflow Step listener + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step + by a decorator, use AsyncWorkflowStepBuilder's methods. + """ step = callback_id if isinstance(callback_id, (str, Pattern)): step = AsyncWorkflowStep( @@ -488,8 +494,10 @@ def step( save=save, execute=execute, ) + elif isinstance(step, AsyncWorkflowStepBuilder): + step = step.build() elif not isinstance(step, AsyncWorkflowStep): - raise BoltError("Invalid step object") + raise BoltError(f"Invalid step object ({type(step)})") self.use(AsyncWorkflowStepMiddleware(step, self._async_listener_runner)) diff --git a/slack_bolt/workflows/step/async_step.py b/slack_bolt/workflows/step/async_step.py index a8b4573d7..8f6c5afdf 100644 --- a/slack_bolt/workflows/step/async_step.py +++ b/slack_bolt/workflows/step/async_step.py @@ -1,4 +1,7 @@ -from typing import Callable, Union, Optional, Awaitable, Sequence +from functools import wraps +from typing import Callable, Union, Optional, Awaitable, Sequence, List, Pattern + +from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -9,17 +12,241 @@ ) from slack_bolt.middleware.async_custom_middleware import AsyncCustomMiddleware from slack_bolt.response import BoltResponse -from slack_sdk.web.async_client import AsyncWebClient +from .internals import _is_used_without_argument +from .utilities.async_complete import AsyncComplete from .utilities.async_configure import AsyncConfigure from .utilities.async_fail import AsyncFail -from .utilities.async_complete import AsyncComplete from .utilities.async_update import AsyncUpdate -from ...listener_matcher.async_listener_matcher import AsyncListenerMatcher +from ...error import BoltError +from ...listener_matcher.async_listener_matcher import ( + AsyncListenerMatcher, + AsyncCustomListenerMatcher, +) from ...middleware.async_middleware import AsyncMiddleware +class AsyncWorkflowStepBuilder: + callback_id: Union[str, Pattern] + _edit: Optional[AsyncListener] + _save: Optional[AsyncListener] + _execute: Optional[AsyncListener] + + def __init__( + self, + callback_id: Union[str, Pattern], + app_name: Optional[str] = None, + ): + """This builder is supposed to be used as decorator. + my_step = AsyncWorkflowStep.builder("my_step") + @my_step.edit + async def edit_my_step(ack, configure): + pass + @my_step.save + async def save_my_step(ack, step, update): + pass + @my_step.execute + async def execute_my_step(step, complete, fail): + pass + app.step(my_step) + :param callback_id: the callback_id for the workflow + :param app_name: the application name mainly for logging + """ + self.callback_id = callback_id + self.app_name = app_name or __name__ + self._edit = None + self._save = None + self._execute = None + + def edit( + self, + *args, + matchers: Optional[ + Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher] + ] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, + ): + """Register a new edit listener with details. + You can use this method as decorator as well. + @my_step.edit + def edit_my_step(ack, configure): + pass + It's also possible to add additional listener matchers and/or middleware + @my_step.edit(matchers=[is_valid], middleware=[update_context]) + def edit_my_step(ack, configure): + pass + """ + if _is_used_without_argument(args): + func = args[0] + self._edit = self._to_listener("edit", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._edit = self._to_listener("edit", functions, matchers, middleware) + + @wraps(func) + async def _wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return _wrapper + + return _inner + + def save( + self, + *args, + matchers: Optional[ + Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher] + ] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, + ): + """Register a new save listener with details. + You can use this method as decorator as well. + @my_step.save + def save_my_step(ack, step, update): + pass + It's also possible to add additional listener matchers and/or middleware + @my_step.save(matchers=[is_valid], middleware=[update_context]) + def save_my_step(ack, step, update): + pass + """ + + if _is_used_without_argument(args): + func = args[0] + self._save = self._to_listener("save", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._save = self._to_listener("save", functions, matchers, middleware) + + @wraps(func) + async def _wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return _wrapper + + return _inner + + def execute( + self, + *args, + matchers: Optional[ + Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher] + ] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, + ): + """Register a new execute listener with details. + You can use this method as decorator as well. + @my_step.execute + def execute_my_step(step, complete, fail): + pass + It's also possible to add additional listener matchers and/or middleware + @my_step.save(matchers=[is_valid], middleware=[update_context]) + def execute_my_step(step, complete, fail): + pass + """ + + if _is_used_without_argument(args): + func = args[0] + self._execute = self._to_listener("execute", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._execute = self._to_listener( + "execute", functions, matchers, middleware + ) + + @wraps(func) + async def _wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return _wrapper + + return _inner + + def build(self) -> "AsyncWorkflowStep": + """Constructs a WorkflowStep object. This method may raise an exception + if the builder doesn't have enough configurations to build the object. + :return: WorkflowStep object + """ + if self._edit is None: + raise BoltError(f"edit listener is not registered") + if self._save is None: + raise BoltError(f"save listener is not registered") + if self._execute is None: + raise BoltError(f"execute listener is not registered") + + return AsyncWorkflowStep( + callback_id=self.callback_id, + edit=self._edit, + save=self._save, + execute=self._execute, + app_name=self.app_name, + ) + + # --------------------------------------- + + def _to_listener( + self, + name: str, + listener_or_functions: Union[AsyncListener, Callable, List[Callable]], + matchers: Optional[ + Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher] + ] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + ) -> AsyncListener: + return AsyncWorkflowStep.build_listener( + callback_id=self.callback_id, + app_name=self.app_name, + listener_or_functions=listener_or_functions, + name=name, + matchers=self.to_listener_matchers(self.app_name, matchers), + middleware=self.to_listener_middleware(self.app_name, middleware), + ) + + @staticmethod + def to_listener_matchers( + app_name: str, + matchers: Optional[ + List[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]] + ], + ) -> List[AsyncListenerMatcher]: + _matchers = [] + if matchers is not None: + for m in matchers: + if isinstance(m, AsyncListenerMatcher): + _matchers.append(m) + elif isinstance(m, Callable): + _matchers.append( + AsyncCustomListenerMatcher(app_name=app_name, func=m) + ) + else: + raise ValueError(f"Invalid matcher: {type(m)}") + return _matchers # type: ignore + + @staticmethod + def to_listener_middleware( + app_name: str, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] + ) -> List[AsyncMiddleware]: + _middleware = [] + if middleware is not None: + for m in middleware: + if isinstance(m, AsyncMiddleware): + _middleware.append(m) + elif isinstance(m, Callable): + _middleware.append(AsyncCustomMiddleware(app_name=app_name, func=m)) + else: + raise ValueError(f"Invalid middleware: {type(m)}") + return _middleware # type: ignore + + class AsyncWorkflowStep: - callback_id: str + callback_id: Union[str, Pattern] edit: AsyncListener save: AsyncListener execute: AsyncListener @@ -27,7 +254,7 @@ class AsyncWorkflowStep: def __init__( self, *, - callback_id: str, + callback_id: Union[str, Pattern], edit: Union[ Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable] ], @@ -41,66 +268,73 @@ def __init__( ): self.callback_id = callback_id app_name = app_name or __name__ - self.edit = self._build_listener(callback_id, app_name, edit, "edit") - self.save = self._build_listener(callback_id, app_name, save, "save") - self.execute = self._build_listener(callback_id, app_name, execute, "execute") + self.edit = self.build_listener(callback_id, app_name, edit, "edit") + self.save = self.build_listener(callback_id, app_name, save, "save") + self.execute = self.build_listener(callback_id, app_name, execute, "execute") + + @classmethod + def builder(cls, callback_id: Union[str, Pattern]) -> AsyncWorkflowStepBuilder: + return AsyncWorkflowStepBuilder(callback_id) @classmethod - def _build_listener( + def build_listener( cls, - callback_id: str, + callback_id: Union[str, Pattern], app_name: str, - listener: AsyncListener, + listener_or_functions: Union[AsyncListener, Callable, List[Callable]], name: str, - ) -> AsyncListener: - if isinstance(listener, AsyncListener): - return listener - elif isinstance(listener, Callable): - return AsyncCustomListener( - app_name=app_name, - matchers=cls._build_matchers(name, callback_id), - middleware=cls._build_middleware(name, callback_id), - ack_function=listener, - lazy_functions=[], - auto_acknowledgement=name == "execute", - ) - elif isinstance(listener, list) and len(listener) > 0: - ack = listener.pop(0) - lazy = listener + matchers: Optional[List[AsyncListenerMatcher]] = None, + middleware: Optional[List[AsyncMiddleware]] = None, + ): + if listener_or_functions is None: + raise BoltError(f"{name} listener is required (callback_id: {callback_id})") + + if isinstance(listener_or_functions, Callable): + listener_or_functions = [listener_or_functions] + + if isinstance(listener_or_functions, AsyncListener): + return listener_or_functions + elif isinstance(listener_or_functions, list): + matchers = matchers if matchers else [] + matchers.insert(0, cls._build_primary_matcher(name, callback_id)) + middleware = middleware if middleware else [] + middleware.insert(0, cls._build_single_middleware(name, callback_id)) + functions = listener_or_functions + ack_function = functions.pop(0) return AsyncCustomListener( app_name=app_name, - matchers=cls._build_matchers(name, callback_id), - middleware=cls._build_middleware(name, callback_id), - ack_function=ack, - lazy_functions=lazy, + matchers=matchers, + middleware=middleware, + ack_function=ack_function, + lazy_functions=functions, auto_acknowledgement=name == "execute", ) else: - raise ValueError(f"Invalid `{name}` listener") + raise BoltError( + f"Invalid {name} listener: {type(listener_or_functions)} detected (callback_id: {callback_id})" + ) @classmethod - def _build_matchers( + def _build_primary_matcher( cls, name: str, callback_id: str - ) -> Sequence[AsyncListenerMatcher]: + ) -> AsyncListenerMatcher: if name == "edit": - return [workflow_step_edit(callback_id, asyncio=True)] + return workflow_step_edit(callback_id, asyncio=True) elif name == "save": - return [workflow_step_save(callback_id, asyncio=True)] + return workflow_step_save(callback_id, asyncio=True) elif name == "execute": - return [workflow_step_execute(callback_id, asyncio=True)] + return workflow_step_execute(callback_id, asyncio=True) else: raise ValueError(f"Invalid name {name}") @classmethod - def _build_middleware( - cls, name: str, callback_id: str - ) -> Sequence[AsyncMiddleware]: + def _build_single_middleware(cls, name: str, callback_id: str) -> AsyncMiddleware: if name == "edit": - return [_build_edit_listener_middleware(callback_id)] + return _build_edit_listener_middleware(callback_id) elif name == "save": - return [_build_save_listener_middleware()] + return _build_save_listener_middleware() elif name == "execute": - return [_build_execute_listener_middleware()] + return _build_execute_listener_middleware() else: raise ValueError(f"Invalid name {name}") diff --git a/slack_bolt/workflows/step/internals.py b/slack_bolt/workflows/step/internals.py new file mode 100644 index 000000000..24363cf16 --- /dev/null +++ b/slack_bolt/workflows/step/internals.py @@ -0,0 +1,6 @@ +def _is_used_without_argument(args): + """Tests if a decorator invocation is without () or (args). + :param args: arguments + :return: True if it's an invocation without args + """ + return len(args) == 1 diff --git a/slack_bolt/workflows/step/step.py b/slack_bolt/workflows/step/step.py index 0e1954294..286bc0b9c 100644 --- a/slack_bolt/workflows/step/step.py +++ b/slack_bolt/workflows/step/step.py @@ -1,8 +1,10 @@ -from typing import Callable, Union, Optional, Sequence +from functools import wraps +from typing import Callable, Union, Optional, Sequence, Pattern, List -from slack_bolt.context import BoltContext +from slack_bolt.context.context import BoltContext +from slack_bolt.error import BoltError from slack_bolt.listener import Listener, CustomListener -from slack_bolt.listener_matcher import ListenerMatcher +from slack_bolt.listener_matcher import ListenerMatcher, CustomListenerMatcher from slack_bolt.listener_matcher.builtins import ( workflow_step_edit, workflow_step_save, @@ -10,6 +12,7 @@ ) from slack_bolt.middleware import CustomMiddleware, Middleware from slack_bolt.response import BoltResponse +from slack_bolt.workflows.step.internals import _is_used_without_argument from slack_bolt.workflows.step.utilities.complete import Complete from slack_bolt.workflows.step.utilities.configure import Configure from slack_bolt.workflows.step.utilities.fail import Fail @@ -17,8 +20,217 @@ from slack_sdk.web import WebClient +class WorkflowStepBuilder: + callback_id: Union[str, Pattern] + _edit: Optional[Listener] + _save: Optional[Listener] + _execute: Optional[Listener] + + def __init__( + self, + callback_id: Union[str, Pattern], + app_name: Optional[str] = None, + ): + """This builder is supposed to be used as decorator. + my_step = WorkflowStep.builder("my_step") + @my_step.edit + def edit_my_step(ack, configure): + pass + @my_step.save + def save_my_step(ack, step, update): + pass + @my_step.execute + def execute_my_step(step, complete, fail): + pass + app.step(my_step) + :param callback_id: the callback_id for the workflow + :param app_name: the application name mainly for logging + """ + self.callback_id = callback_id + self.app_name = app_name or __name__ + self._edit = None + self._save = None + self._execute = None + + def edit( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + """Register a new edit listener with details. + You can use this method as decorator as well. + @my_step.edit + def edit_my_step(ack, configure): + pass + It's also possible to add additional listener matchers and/or middleware + @my_step.edit(matchers=[is_valid], middleware=[update_context]) + def edit_my_step(ack, configure): + pass + """ + + if _is_used_without_argument(args): + func = args[0] + self._edit = self._to_listener("edit", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._edit = self._to_listener("edit", functions, matchers, middleware) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def save( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + """Register a new save listener with details. + You can use this method as decorator as well. + @my_step.save + def save_my_step(ack, step, update): + pass + It's also possible to add additional listener matchers and/or middleware + @my_step.save(matchers=[is_valid], middleware=[update_context]) + def save_my_step(ack, step, update): + pass + """ + + if _is_used_without_argument(args): + func = args[0] + self._save = self._to_listener("save", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._save = self._to_listener("save", functions, matchers, middleware) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def execute( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + """Register a new execute listener with details. + You can use this method as decorator as well. + @my_step.execute + def execute_my_step(step, complete, fail): + pass + It's also possible to add additional listener matchers and/or middleware + @my_step.save(matchers=[is_valid], middleware=[update_context]) + def execute_my_step(step, complete, fail): + pass + """ + + if _is_used_without_argument(args): + func = args[0] + self._execute = self._to_listener("execute", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._execute = self._to_listener( + "execute", functions, matchers, middleware + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def build(self) -> "WorkflowStep": + """Constructs a WorkflowStep object. This method may raise an exception + if the builder doesn't have enough configurations to build the object. + :return: WorkflowStep object + """ + if self._edit is None: + raise BoltError(f"edit listener is not registered") + if self._save is None: + raise BoltError(f"save listener is not registered") + if self._execute is None: + raise BoltError(f"execute listener is not registered") + + return WorkflowStep( + callback_id=self.callback_id, + edit=self._edit, + save=self._save, + execute=self._execute, + app_name=self.app_name, + ) + + # --------------------------------------- + + def _to_listener( + self, + name: str, + listener_or_functions: Union[Listener, Callable, List[Callable]], + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + ) -> Listener: + return WorkflowStep.build_listener( + callback_id=self.callback_id, + app_name=self.app_name, + listener_or_functions=listener_or_functions, + name=name, + matchers=self.to_listener_matchers(self.app_name, matchers), + middleware=self.to_listener_middleware(self.app_name, middleware), + ) + + @staticmethod + def to_listener_matchers( + app_name: str, + matchers: Optional[List[Union[Callable[..., bool], ListenerMatcher]]], + ) -> List[ListenerMatcher]: + _matchers = [] + if matchers is not None: + for m in matchers: + if isinstance(m, ListenerMatcher): + _matchers.append(m) + elif isinstance(m, Callable): + _matchers.append(CustomListenerMatcher(app_name=app_name, func=m)) + else: + raise ValueError(f"Invalid matcher: {type(m)}") + return _matchers # type: ignore + + @staticmethod + def to_listener_middleware( + app_name: str, middleware: Optional[List[Union[Callable, Middleware]]] + ) -> List[Middleware]: + _middleware = [] + if middleware is not None: + for m in middleware: + if isinstance(m, Middleware): + _middleware.append(m) + elif isinstance(m, Callable): + _middleware.append(CustomMiddleware(app_name=app_name, func=m)) + else: + raise ValueError(f"Invalid middleware: {type(m)}") + return _middleware # type: ignore + + class WorkflowStep: - callback_id: str + callback_id: Union[str, Pattern] edit: Listener save: Listener execute: Listener @@ -26,7 +238,7 @@ class WorkflowStep: def __init__( self, *, - callback_id: str, + callback_id: Union[str, Pattern], edit: Union[ Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable] ], @@ -40,64 +252,71 @@ def __init__( ): self.callback_id = callback_id app_name = app_name or __name__ - self.edit = self._build_listener(callback_id, app_name, edit, "edit") - self.save = self._build_listener(callback_id, app_name, save, "save") - self.execute = self._build_listener(callback_id, app_name, execute, "execute") + self.edit = self.build_listener(callback_id, app_name, edit, "edit") + self.save = self.build_listener(callback_id, app_name, save, "save") + self.execute = self.build_listener(callback_id, app_name, execute, "execute") @classmethod - def _build_listener( + def builder(cls, callback_id: Union[str, Pattern]) -> WorkflowStepBuilder: + return WorkflowStepBuilder(callback_id) + + @classmethod + def build_listener( cls, - callback_id: str, + callback_id: Union[str, Pattern], app_name: str, - listener: Union[ - Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable] - ], + listener_or_functions: Union[Listener, Callable, List[Callable]], name: str, + matchers: Optional[List[ListenerMatcher]] = None, + middleware: Optional[List[Middleware]] = None, ) -> Listener: - if isinstance(listener, Listener): - return listener - elif isinstance(listener, Callable): - return CustomListener( - app_name=app_name, - matchers=cls._build_matchers(name, callback_id), - middleware=cls._build_middleware(name, callback_id), - ack_function=listener, - lazy_functions=[], - auto_acknowledgement=name == "execute", - ) - elif isinstance(listener, list) and len(listener) > 0: - ack = listener.pop(0) - lazy = listener + if listener_or_functions is None: + raise BoltError(f"{name} listener is required (callback_id: {callback_id})") + + if isinstance(listener_or_functions, Callable): + listener_or_functions = [listener_or_functions] + + if isinstance(listener_or_functions, Listener): + return listener_or_functions + elif isinstance(listener_or_functions, list): + matchers = matchers if matchers else [] + matchers.insert(0, cls._build_primary_matcher(name, callback_id)) + middleware = middleware if middleware else [] + middleware.insert(0, cls._build_single_middleware(name, callback_id)) + functions = listener_or_functions + ack_function = functions.pop(0) return CustomListener( app_name=app_name, - matchers=cls._build_matchers(name, callback_id), - middleware=cls._build_middleware(name, callback_id), - ack_function=ack, - lazy_functions=lazy, + matchers=matchers, + middleware=middleware, + ack_function=ack_function, + lazy_functions=functions, auto_acknowledgement=name == "execute", ) else: - raise ValueError(f"Invalid `{name}` listener") + raise BoltError( + f"Invalid {name} listener: {type(listener_or_functions)} detected (callback_id: {callback_id})" + ) @classmethod - def _build_matchers(cls, name: str, callback_id: str) -> Sequence[ListenerMatcher]: + def _build_primary_matcher(cls, name, callback_id) -> ListenerMatcher: if name == "edit": - return [workflow_step_edit(callback_id)] + return workflow_step_edit(callback_id) elif name == "save": - return [workflow_step_save(callback_id)] + return workflow_step_save(callback_id) elif name == "execute": - return [workflow_step_execute(callback_id)] + return workflow_step_execute(callback_id) else: raise ValueError(f"Invalid name {name}") @classmethod - def _build_middleware(cls, name: str, callback_id: str) -> Sequence[Middleware]: + def _build_single_middleware(cls, name, callback_id) -> Middleware: if name == "edit": - return [_build_edit_listener_middleware(callback_id)] + return _build_edit_listener_middleware(callback_id) elif name == "save": - return [_build_save_listener_middleware()] + return _build_save_listener_middleware() elif name == "execute": - return [_build_execute_listener_middleware()] + return _build_execute_listener_middleware() else: raise ValueError(f"Invalid name {name}") diff --git a/tests/scenario_tests/test_workflow_steps_decorator_simple.py b/tests/scenario_tests/test_workflow_steps_decorator_simple.py new file mode 100644 index 000000000..e11f4a6bf --- /dev/null +++ b/tests/scenario_tests/test_workflow_steps_decorator_simple.py @@ -0,0 +1,425 @@ +import json +import time as time_module +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient, SlackResponse + +from slack_bolt import App, BoltRequest, Ack +from slack_bolt.workflows.step import Complete, Fail, Update, Configure, WorkflowStep +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 TestWorkflowStepsDecorator: + 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) + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(copy_review_step) + + 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 test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + time_module.sleep(0.5) + assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +copy_review_step = WorkflowStep.builder("copy_review") + + +@copy_review_step.edit +def edit(ack: Ack, step, configure: Configure): + assert step is not None + ack() + configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save +def save(ack: Ack, step: dict, view: dict, update: Update): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + ack() + + +pseudo_database = {} + + +@copy_review_step.execute +def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): + assert step is not None + try: + complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) diff --git a/tests/scenario_tests/test_workflow_steps_decorator_with_args.py b/tests/scenario_tests/test_workflow_steps_decorator_with_args.py new file mode 100644 index 000000000..32f334c14 --- /dev/null +++ b/tests/scenario_tests/test_workflow_steps_decorator_with_args.py @@ -0,0 +1,429 @@ +import json +import time as time_module +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient, SlackResponse + +from slack_bolt import App, BoltRequest, Ack +from slack_bolt.workflows.step import Complete, Fail, Update, Configure, WorkflowStep +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 TestWorkflowStepsDecorator: + 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) + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(copy_review_step) + + 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 test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + time_module.sleep(0.5) + assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +copy_review_step = WorkflowStep.builder("copy_review") + + +def noop_middleware(next): + return next() + + +@copy_review_step.edit(middleware=[noop_middleware]) +def edit(ack: Ack, step, configure: Configure): + assert step is not None + ack() + configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save(middleware=[noop_middleware]) +def save(ack: Ack, step: dict, view: dict, update: Update): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + ack() + + +pseudo_database = {} + + +@copy_review_step.execute(middleware=[noop_middleware]) +def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): + assert step is not None + try: + complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) diff --git a/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py b/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py new file mode 100644 index 000000000..593dd0d14 --- /dev/null +++ b/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py @@ -0,0 +1,447 @@ +import asyncio +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import SlackResponse +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep +from slack_bolt.workflows.step.utilities.async_complete import AsyncComplete +from slack_bolt.workflows.step.utilities.async_configure import AsyncConfigure +from slack_bolt.workflows.step.utilities.async_fail import AsyncFail +from slack_bolt.workflows.step.utilities.async_update import AsyncUpdate +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 TestAsyncWorkflowStepsDecorator: + 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, + ) + + @pytest.fixture + def event_loop(self): + old_os_env = remove_os_env_temporarily() + try: + setup_mock_web_api_server(self) + self.app = AsyncApp( + client=self.web_client, signing_secret=self.signing_secret + ) + self.app.step(copy_review_step) + + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + finally: + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + @pytest.mark.asyncio + async def test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(0.5) + assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + +copy_review_step = AsyncWorkflowStep.builder("copy_review") + + +@copy_review_step.edit +async def edit(ack: AsyncAck, step, configure: AsyncConfigure): + assert step is not None + await ack() + await configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save +async def save(ack: AsyncAck, step: dict, view: dict, update: AsyncUpdate): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + await update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + await ack() + + +pseudo_database = {} + + +@copy_review_step.execute +async def execute( + step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail +): + assert step is not None + try: + await complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = await client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) diff --git a/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py b/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py new file mode 100644 index 000000000..1db6c8b2d --- /dev/null +++ b/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py @@ -0,0 +1,451 @@ +import asyncio +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import SlackResponse +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep +from slack_bolt.workflows.step.utilities.async_complete import AsyncComplete +from slack_bolt.workflows.step.utilities.async_configure import AsyncConfigure +from slack_bolt.workflows.step.utilities.async_fail import AsyncFail +from slack_bolt.workflows.step.utilities.async_update import AsyncUpdate +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 TestAsyncWorkflowStepsDecorator: + 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, + ) + + @pytest.fixture + def event_loop(self): + old_os_env = remove_os_env_temporarily() + try: + setup_mock_web_api_server(self) + self.app = AsyncApp( + client=self.web_client, signing_secret=self.signing_secret + ) + self.app.step(copy_review_step) + + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + finally: + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + @pytest.mark.asyncio + async def test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(0.5) + assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + +copy_review_step = AsyncWorkflowStep.builder("copy_review") + + +async def noop_middleware(next): + return await next() + + +@copy_review_step.edit(middleware=[noop_middleware]) +async def edit(ack: AsyncAck, step, configure: AsyncConfigure): + assert step is not None + await ack() + await configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save(middleware=[noop_middleware]) +async def save(ack: AsyncAck, step: dict, view: dict, update: AsyncUpdate): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + await update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + await ack() + + +pseudo_database = {} + + +@copy_review_step.execute(middleware=[noop_middleware]) +async def execute( + step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail +): + assert step is not None + try: + await complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = await client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) From 52e1380c7699b8a3a5f71946997bc72061149fca Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 4 Feb 2021 12:04:58 +0900 Subject: [PATCH 263/865] Revert the defualt testing mdoe to threading mode for better macOS Big Sur compatibility --- .gitignore | 2 ++ tests/utils.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9e80867aa..f4aaf5133 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ __pycache__/ # virtualenv env*/ venv/ +.venv/ +.env/ # codecov / coverage .coverage diff --git a/tests/utils.py b/tests/utils.py index bf642fbd9..e6c75903d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -21,6 +21,9 @@ def get_mock_server_mode() -> str: """ mode = os.environ.get("BOLT_PYTHON_MOCK_SERVER_MODE") if mode is None: - return "multiprocessing" + # We used to use "multiprocessing"" for macOS until Big Sur 11.1 + # Since 11.1, the "multiprocessing" mode started failing a lot... + # Therefore, we switched the default mode back to "threading". + return "threading" else: return mode From f264dacb38214c210f206e268b1f8707c2b6c9c4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 4 Feb 2021 14:43:23 +0900 Subject: [PATCH 264/865] version 1.3.0rc1 --- setup.py | 2 +- slack_bolt/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 8cd53987a..7f4c37d2d 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.2.1,<3.3",], + install_requires=["slack_sdk>=3.3.0rc1,<3.4",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 10aa336ce..4db542d29 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.2.3" +__version__ = "1.3.0rc1" From bc1471071f1ee0865b417ef4126d0e8d2cc0b7c2 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 4 Feb 2021 15:20:16 +0900 Subject: [PATCH 265/865] version 1.3.0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 4db542d29..67bc602ab 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.3.0rc1" +__version__ = "1.3.0" From 2fd86749f79ee73af98c7c5c35963945edb2f0e7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 5 Feb 2021 11:47:50 +0900 Subject: [PATCH 266/865] version 1.3.0 (final) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7f4c37d2d..3b2b16a21 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.3.0rc1,<3.4",], + install_requires=["slack_sdk>=3.3.0,<3.4",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", From 2d67290ae9417ff091f4140535eb1e5b9a0b8d78 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 7 Feb 2021 19:56:39 +0900 Subject: [PATCH 267/865] Fix #232 Unmatched message listener middleware can be called --- slack_bolt/app/app.py | 2 +- slack_bolt/app/async_app.py | 2 +- tests/scenario_tests/test_message.py | 58 ++++++++++++++++++-- tests/scenario_tests_async/test_message.py | 62 ++++++++++++++++++++-- 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index fa5ec277e..fcbbe1c51 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -517,7 +517,7 @@ def __call__(*args, **kwargs): # By contrast, messages posted using class app's bot token still have the subtype. constraints = {"type": "message", "subtype": (None, "bot_message")} primary_matcher = builtin_matchers.event(constraints=constraints) - middleware.append(MessageListenerMatches(keyword)) + middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True ) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index f28a4b7ff..c82412cf5 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -567,7 +567,7 @@ def __call__(*args, **kwargs): primary_matcher = builtin_matchers.event( constraints=constraints, asyncio=True ) - middleware.append(AsyncMessageListenerMatches(keyword)) + middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True ) diff --git a/tests/scenario_tests/test_message.py b/tests/scenario_tests/test_message.py index e750bfd40..fe16f8245 100644 --- a/tests/scenario_tests/test_message.py +++ b/tests/scenario_tests/test_message.py @@ -5,6 +5,7 @@ from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient +from slack_bolt import BoltResponse from slack_bolt.app import App from slack_bolt.request import BoltRequest from tests.mock_web_api_server import ( @@ -46,13 +47,15 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_request(self) -> BoltRequest: + def build_request_from_body(self, message_body: dict) -> BoltRequest: timestamp, body = str(int(time.time())), json.dumps(message_body) return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + def build_request(self) -> BoltRequest: + return self.build_request_from_body(message_body) + def build_request2(self) -> BoltRequest: - timestamp, body = str(int(time.time())), json.dumps(message_body2) - return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + return self.build_request_from_body(message_body2) def test_string_keyword(self): app = App( @@ -136,6 +139,55 @@ def test_regexp_keyword_unmatched(self): assert response.status == 404 assert_auth_test_count(self, 1) + # https://github.com/slackapi/bolt-python/issues/232 + def test_issue_232_message_listener_middleware(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + called = { + "first": False, + "second": False, + } + + def this_should_be_skipped(): + return BoltResponse(status=500, body="failed") + + @app.message("first", middleware=[this_should_be_skipped]) + def first(): + called["first"] = True + + @app.message("second", middleware=[]) + def second(): + called["second"] = True + + request = self.build_request_from_body( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "a8744611-0210-4f85-9f15-5faf7fb225c8", + "type": "message", + "text": "This message should match the second listener only", + "user": "W111", + "ts": "1596183880.004200", + "team": "T111", + "channel": "C111", + "event_ts": "1596183880.004200", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1596183880, + } + ) + response = app.dispatch(request) + assert response.status == 200 + assert called["first"] == False + assert called["second"] == True + message_body = { "token": "verification_token", diff --git a/tests/scenario_tests_async/test_message.py b/tests/scenario_tests_async/test_message.py index b69e849f6..b1504d550 100644 --- a/tests/scenario_tests_async/test_message.py +++ b/tests/scenario_tests_async/test_message.py @@ -7,6 +7,7 @@ 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.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( @@ -52,13 +53,15 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_request(self) -> AsyncBoltRequest: + def build_request_from_body(self, message_body: dict) -> AsyncBoltRequest: timestamp, body = str(int(time())), json.dumps(message_body) return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + def build_request(self) -> AsyncBoltRequest: + return self.build_request_from_body(message_body) + def build_request2(self) -> AsyncBoltRequest: - timestamp, body = str(int(time())), json.dumps(message_body2) - return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + return self.build_request_from_body(message_body2) @pytest.mark.asyncio async def test_string_keyword(self): @@ -148,6 +151,59 @@ async def test_regexp_keyword_unmatched(self): assert response.status == 404 await assert_auth_test_count_async(self, 1) + # https://github.com/slackapi/bolt-python/issues/232 + @pytest.mark.asyncio + async def test_issue_232_message_listener_middleware(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + called = { + "first": False, + "second": False, + } + + async def this_should_be_skipped(): + return BoltResponse(status=500, body="failed") + + @app.message("first", middleware=[this_should_be_skipped]) + async def first(): + called["first"] = True + + @app.message("second", middleware=[]) + async def second(): + called["second"] = True + + request = self.build_request_from_body( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "a8744611-0210-4f85-9f15-5faf7fb225c8", + "type": "message", + "text": "This message should match the second listener only", + "user": "W111", + "ts": "1596183880.004200", + "team": "T111", + "channel": "C111", + "event_ts": "1596183880.004200", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1596183880, + } + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + await asyncio.sleep(0.3) + assert called["first"] == False + assert called["second"] == True + message_body = { "token": "verification_token", From 794d0acb6942f26448fc6c4a944276c3dacaaf5b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 9 Feb 2021 16:31:09 +0900 Subject: [PATCH 268/865] version 1.3.1 --- setup.py | 2 +- slack_bolt/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 3b2b16a21..38cfdbacd 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.3.0,<3.4",], + install_requires=["slack_sdk>=3.3.1,<3.4",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 67bc602ab..9c73af26b 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.3.0" +__version__ = "1.3.1" From c8ce2911317a711e4d1791aa9e09e230bef9309a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 9 Feb 2021 22:03:18 +0900 Subject: [PATCH 269/865] Update GitHub Actions settings --- .github/workflows/ci-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 71e345620..dae01f300 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -10,7 +10,7 @@ on: jobs: build: runs-on: ubuntu-20.04 - timeout-minutes: 10 + timeout-minutes: 15 strategy: matrix: python-version: ['3.6', '3.7', '3.8', '3.9'] From 15b729d0bf36f59a3655916e46e971f84c3a7928 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 12 Feb 2021 08:53:52 +0900 Subject: [PATCH 270/865] version 1.3.2 --- examples/socket_mode_proxy.py | 40 +++++++++++++++++++ setup.py | 2 +- .../adapter/socket_mode/builtin/__init__.py | 4 +- slack_bolt/version.py | 2 +- 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 examples/socket_mode_proxy.py diff --git a/examples/socket_mode_proxy.py b/examples/socket_mode_proxy.py new file mode 100644 index 000000000..25498ed25 --- /dev/null +++ b/examples/socket_mode_proxy.py @@ -0,0 +1,40 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging + +logging.basicConfig(level=logging.DEBUG) + +import os + +from slack_sdk import WebClient +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# pip3 install proxy.py +# proxy --port 9000 --log-level d +proxy_url = "http://localhost:9000" + +# Install the Slack app and get xoxb- token in advance +app = App( + client=WebClient(token=os.environ["SLACK_BOT_TOKEN"], proxy=proxy_url) +) + + +@app.event("app_mention") +def event_test(event, say): + say(f"Hi there, <@{event['user']}>!") + + +if __name__ == "__main__": + # export SLACK_APP_TOKEN=xapp-*** + # export SLACK_BOT_TOKEN=xoxb-*** + SocketModeHandler( + app=app, + app_token=os.environ["SLACK_APP_TOKEN"], + proxy=proxy_url, + ).start() diff --git a/setup.py b/setup.py index 38cfdbacd..e090e1e32 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.3.1,<3.4",], + install_requires=["slack_sdk>=3.3.2,<3.4",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/adapter/socket_mode/builtin/__init__.py b/slack_bolt/adapter/socket_mode/builtin/__init__.py index 242a4d5fa..f857098ba 100644 --- a/slack_bolt/adapter/socket_mode/builtin/__init__.py +++ b/slack_bolt/adapter/socket_mode/builtin/__init__.py @@ -1,7 +1,7 @@ import os from logging import Logger from time import time -from typing import Optional +from typing import Optional, Dict from slack_sdk import WebClient from slack_sdk.socket_mode.request import SocketModeRequest @@ -25,6 +25,7 @@ def __init__( # type: ignore logger: Optional[Logger] = None, web_client: Optional[WebClient] = None, proxy: Optional[str] = None, + proxy_headers: Optional[Dict[str, str]] = None, auto_reconnect_enabled: bool = True, trace_enabled: bool = False, all_message_trace_enabled: bool = False, @@ -40,6 +41,7 @@ def __init__( # type: ignore 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, + proxy_headers=proxy_headers, auto_reconnect_enabled=auto_reconnect_enabled, trace_enabled=trace_enabled, all_message_trace_enabled=all_message_trace_enabled, diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 9c73af26b..f708a9b20 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.3.1" +__version__ = "1.3.2" From 5fbf5634b35dfbb2892618c6d12b34220de42183 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 15 Feb 2021 07:53:31 +0900 Subject: [PATCH 271/865] Correct comments in App/AsyncApp --- slack_bolt/app/app.py | 2 +- slack_bolt/app/async_app.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index fcbbe1c51..2d2c8a116 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -514,7 +514,7 @@ def message( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) # As of Jan 2021, most bot messages no longer have the subtype bot_message. - # By contrast, messages posted using class app's bot token still have the subtype. + # By contrast, messages posted using classic app's bot token still have the subtype. constraints = {"type": "message", "subtype": (None, "bot_message")} primary_matcher = builtin_matchers.event(constraints=constraints) middleware.insert(0, MessageListenerMatches(keyword)) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index c82412cf5..83242ca90 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -562,7 +562,7 @@ def message( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) # As of Jan 2021, most bot messages no longer have the subtype bot_message. - # By contrast, messages posted using class app's bot token still have the subtype. + # By contrast, messages posted using classic app's bot token still have the subtype. constraints = {"type": "message", "subtype": (None, "bot_message")} primary_matcher = builtin_matchers.event( constraints=constraints, asyncio=True From 3bde6df839895bb294ebdffcb4e3d77117839609 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 18 Feb 2021 21:49:32 +0900 Subject: [PATCH 272/865] Remove sys.path modification from examples --- examples/aiohttp_devtools/async_app.py | 7 --- examples/app.py | 7 --- examples/app_authorize.py | 8 ---- examples/async_app.py | 7 --- examples/async_app_authorize.py | 7 --- examples/aws_lambda/aws_lambda.py | 7 --- examples/aws_lambda/aws_lambda_oauth.py | 7 --- examples/aws_lambda/lazy_aws_lambda.py | 8 ---- examples/bottle/app.py | 7 --- examples/bottle/oauth_app.py | 7 --- examples/cherrypy/app.py | 7 --- examples/cherrypy/oauth_app.py | 7 --- examples/dialogs_app.py | 7 --- examples/django/manage.py | 10 ---- examples/events_app.py | 7 --- examples/falcon/app.py | 7 --- examples/falcon/oauth_app.py | 7 --- examples/fastapi/app.py | 7 --- examples/fastapi/async_app.py | 7 --- examples/fastapi/async_oauth_app.py | 7 --- examples/fastapi/oauth_app.py | 7 --- examples/flask/app.py | 7 --- examples/flask/oauth_app.py | 7 --- examples/lazy_async_modals_app.py | 7 --- examples/lazy_modals_app.py | 7 --- examples/message_events.py | 7 --- examples/modals_app.py | 7 --- examples/modals_app_typed.py | 7 --- examples/oauth_app.py | 7 --- examples/oauth_app_settings.py | 7 --- examples/oauth_sqlite3_app.py | 7 --- examples/oauth_sqlite3_app_org_level.py | 7 --- examples/pyramid/app.py | 8 ---- examples/pyramid/oauth_app.py | 8 ---- examples/sanic/async_app.py | 7 --- examples/sanic/async_oauth_app.py | 7 --- examples/socket_mode.py | 47 ++++++++++++------ examples/socket_mode_async.py | 48 ++++++++++++------- examples/socket_mode_oauth.py | 47 ++++++++++++------ examples/socket_mode_proxy.py | 11 +---- examples/starlette/app.py | 7 --- examples/starlette/async_app.py | 7 --- examples/starlette/async_oauth_app.py | 7 --- examples/starlette/oauth_app.py | 7 --- examples/tornado/app.py | 7 --- examples/tornado/oauth_app.py | 7 --- .../workflow_steps/async_steps_from_apps.py | 7 --- .../async_steps_from_apps_decorator.py | 7 --- .../async_steps_from_apps_primitive.py | 7 --- examples/workflow_steps/steps_from_apps.py | 7 --- .../steps_from_apps_decorator.py | 7 --- .../steps_from_apps_primitive.py | 7 --- scripts/install_all_and_run_tests.sh | 2 + 53 files changed, 99 insertions(+), 399 deletions(-) diff --git a/examples/aiohttp_devtools/async_app.py b/examples/aiohttp_devtools/async_app.py index 605b90e31..2b4c289e2 100644 --- a/examples/aiohttp_devtools/async_app.py +++ b/examples/aiohttp_devtools/async_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/app.py b/examples/app.py index f8759c799..6e5a10a0f 100644 --- a/examples/app.py +++ b/examples/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/app_authorize.py b/examples/app_authorize.py index 3f4ab5b01..527e2325a 100644 --- a/examples/app_authorize.py +++ b/examples/app_authorize.py @@ -1,11 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/async_app.py b/examples/async_app.py index c50416a2a..dd5172964 100644 --- a/examples/async_app.py +++ b/examples/async_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/async_app_authorize.py b/examples/async_app_authorize.py index cb62fe925..218c3ef8a 100644 --- a/examples/async_app_authorize.py +++ b/examples/async_app_authorize.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/aws_lambda/aws_lambda.py b/examples/aws_lambda/aws_lambda.py index e5ffc37f2..8ca79a82c 100644 --- a/examples/aws_lambda/aws_lambda.py +++ b/examples/aws_lambda/aws_lambda.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "vendor") -# ------------------------------------------------ - import logging from slack_bolt import App diff --git a/examples/aws_lambda/aws_lambda_oauth.py b/examples/aws_lambda/aws_lambda_oauth.py index 348da4b2e..f30609a1c 100644 --- a/examples/aws_lambda/aws_lambda_oauth.py +++ b/examples/aws_lambda/aws_lambda_oauth.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "vendor") -# ------------------------------------------------ - import logging from slack_bolt import App diff --git a/examples/aws_lambda/lazy_aws_lambda.py b/examples/aws_lambda/lazy_aws_lambda.py index 5e99942e9..82ef3ae98 100644 --- a/examples/aws_lambda/lazy_aws_lambda.py +++ b/examples/aws_lambda/lazy_aws_lambda.py @@ -1,11 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys -import time - -sys.path.insert(1, "vendor") -# ------------------------------------------------ - import logging from slack_bolt import App diff --git a/examples/bottle/app.py b/examples/bottle/app.py index 20959217a..8cab4d697 100644 --- a/examples/bottle/app.py +++ b/examples/bottle/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/bottle/oauth_app.py b/examples/bottle/oauth_app.py index 63d2c5db4..8d42327c4 100644 --- a/examples/bottle/oauth_app.py +++ b/examples/bottle/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../../src") -# ------------------------------------------------ - import logging from slack_bolt import App from slack_bolt.adapter.bottle import SlackRequestHandler diff --git a/examples/cherrypy/app.py b/examples/cherrypy/app.py index 42b335b1f..85eb27797 100644 --- a/examples/cherrypy/app.py +++ b/examples/cherrypy/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/cherrypy/oauth_app.py b/examples/cherrypy/oauth_app.py index 180f9b43e..17c697e97 100644 --- a/examples/cherrypy/oauth_app.py +++ b/examples/cherrypy/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../../src") -# ------------------------------------------------ - import logging from slack_bolt import App from slack_bolt.adapter.cherrypy import SlackRequestHandler diff --git a/examples/dialogs_app.py b/examples/dialogs_app.py index e2f23e3ed..5f2509b1a 100644 --- a/examples/dialogs_app.py +++ b/examples/dialogs_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/django/manage.py b/examples/django/manage.py index c7ccae1c5..38488e78b 100755 --- a/examples/django/manage.py +++ b/examples/django/manage.py @@ -2,16 +2,6 @@ """Django's command-line utility for administrative tasks.""" import os -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt - -import sys - -sys.path.insert(1, "../../..") - - -# ------------------------------------------------ - def main(): os.environ.setdefault("DJANGO_SETTINGS_MODULE", "slackapp.settings") diff --git a/examples/events_app.py b/examples/events_app.py index f15fab9d0..1f678f0fb 100644 --- a/examples/events_app.py +++ b/examples/events_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import re import logging diff --git a/examples/falcon/app.py b/examples/falcon/app.py index 1dbf39ffb..83bb5e460 100644 --- a/examples/falcon/app.py +++ b/examples/falcon/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import falcon import logging import re diff --git a/examples/falcon/oauth_app.py b/examples/falcon/oauth_app.py index 900ffca99..7d9ad7f1f 100644 --- a/examples/falcon/oauth_app.py +++ b/examples/falcon/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import falcon import logging import re diff --git a/examples/fastapi/app.py b/examples/fastapi/app.py index f1b4c7782..3bd275ce9 100644 --- a/examples/fastapi/app.py +++ b/examples/fastapi/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt import App from slack_bolt.adapter.fastapi import SlackRequestHandler diff --git a/examples/fastapi/async_app.py b/examples/fastapi/async_app.py index 5f2a77625..265cb3b3e 100644 --- a/examples/fastapi/async_app.py +++ b/examples/fastapi/async_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/fastapi/async_oauth_app.py b/examples/fastapi/async_oauth_app.py index a19ba402c..f6cdbade5 100644 --- a/examples/fastapi/async_oauth_app.py +++ b/examples/fastapi/async_oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/fastapi/oauth_app.py b/examples/fastapi/oauth_app.py index dccad57ea..1ce71fc59 100644 --- a/examples/fastapi/oauth_app.py +++ b/examples/fastapi/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt import App from slack_bolt.adapter.fastapi import SlackRequestHandler diff --git a/examples/flask/app.py b/examples/flask/app.py index ce239280d..ec37fddd4 100644 --- a/examples/flask/app.py +++ b/examples/flask/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/flask/oauth_app.py b/examples/flask/oauth_app.py index c20ef5e67..d48391897 100644 --- a/examples/flask/oauth_app.py +++ b/examples/flask/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging from slack_bolt import App from slack_bolt.adapter.flask import SlackRequestHandler diff --git a/examples/lazy_async_modals_app.py b/examples/lazy_async_modals_app.py index 7f25d5a73..d37292603 100644 --- a/examples/lazy_async_modals_app.py +++ b/examples/lazy_async_modals_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import asyncio import logging from slack_bolt.async_app import AsyncApp diff --git a/examples/lazy_modals_app.py b/examples/lazy_modals_app.py index 9b668a859..c27c8cb44 100644 --- a/examples/lazy_modals_app.py +++ b/examples/lazy_modals_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging import time diff --git a/examples/message_events.py b/examples/message_events.py index e0693f7b6..11e196683 100644 --- a/examples/message_events.py +++ b/examples/message_events.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging import re from typing import Callable diff --git a/examples/modals_app.py b/examples/modals_app.py index f0da8d9f8..edd162a90 100644 --- a/examples/modals_app.py +++ b/examples/modals_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/modals_app_typed.py b/examples/modals_app_typed.py index ffad223cf..ccd5e69ee 100644 --- a/examples/modals_app_typed.py +++ b/examples/modals_app_typed.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging from typing import Callable from logging import Logger diff --git a/examples/oauth_app.py b/examples/oauth_app.py index 26b772a77..ac05bde2e 100644 --- a/examples/oauth_app.py +++ b/examples/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging from slack_bolt import App diff --git a/examples/oauth_app_settings.py b/examples/oauth_app_settings.py index 977ec0e49..1877b07fa 100644 --- a/examples/oauth_app_settings.py +++ b/examples/oauth_app_settings.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging import os from slack_bolt import App, BoltResponse diff --git a/examples/oauth_sqlite3_app.py b/examples/oauth_sqlite3_app.py index a62b4f595..ee5edc10f 100644 --- a/examples/oauth_sqlite3_app.py +++ b/examples/oauth_sqlite3_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/oauth_sqlite3_app_org_level.py b/examples/oauth_sqlite3_app_org_level.py index 34117e268..d5dad1f26 100644 --- a/examples/oauth_sqlite3_app_org_level.py +++ b/examples/oauth_sqlite3_app_org_level.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/pyramid/app.py b/examples/pyramid/app.py index f3befa086..5cea72bc7 100644 --- a/examples/pyramid/app.py +++ b/examples/pyramid/app.py @@ -1,11 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - - import logging from slack_bolt import App from slack_bolt.adapter.pyramid.handler import SlackRequestHandler diff --git a/examples/pyramid/oauth_app.py b/examples/pyramid/oauth_app.py index e229c85b6..c29f7370b 100644 --- a/examples/pyramid/oauth_app.py +++ b/examples/pyramid/oauth_app.py @@ -1,11 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - - import logging from slack_bolt import App from slack_bolt.adapter.pyramid.handler import SlackRequestHandler diff --git a/examples/sanic/async_app.py b/examples/sanic/async_app.py index 6a4e8c19c..c10643640 100644 --- a/examples/sanic/async_app.py +++ b/examples/sanic/async_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import os from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.sanic import AsyncSlackRequestHandler diff --git a/examples/sanic/async_oauth_app.py b/examples/sanic/async_oauth_app.py index cbadb8cab..c65c085c7 100644 --- a/examples/sanic/async_oauth_app.py +++ b/examples/sanic/async_oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import os from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.sanic import AsyncSlackRequestHandler diff --git a/examples/socket_mode.py b/examples/socket_mode.py index e4b2f10ba..bef5a6a95 100644 --- a/examples/socket_mode.py +++ b/examples/socket_mode.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -39,15 +32,30 @@ def open_modal(body, client): 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",}, + "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",}, + "label": { + "type": "plain_text", + "text": "Write anything here!", + }, + "element": { + "action_id": "feedback", + "type": "plain_text_input", + }, }, { "type": "input", @@ -75,9 +83,18 @@ def open_modal(body, client): 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",}, + { + "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", + }, ] diff --git a/examples/socket_mode_async.py b/examples/socket_mode_async.py index 99bb0ae69..d252fbe54 100644 --- a/examples/socket_mode_async.py +++ b/examples/socket_mode_async.py @@ -1,11 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -40,15 +32,30 @@ async def open_modal(body, client): 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",}, + "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",}, + "label": { + "type": "plain_text", + "text": "Write anything here!", + }, + "element": { + "action_id": "feedback", + "type": "plain_text_input", + }, }, { "type": "input", @@ -76,9 +83,18 @@ async def open_modal(body, client): 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",}, + { + "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", + }, ] diff --git a/examples/socket_mode_oauth.py b/examples/socket_mode_oauth.py index fd51b3150..5e1816de3 100644 --- a/examples/socket_mode_oauth.py +++ b/examples/socket_mode_oauth.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -45,15 +38,30 @@ def open_modal(body, client): 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",}, + "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",}, + "label": { + "type": "plain_text", + "text": "Write anything here!", + }, + "element": { + "action_id": "feedback", + "type": "plain_text_input", + }, }, { "type": "input", @@ -81,9 +89,18 @@ def open_modal(body, client): 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",}, + { + "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", + }, ] diff --git a/examples/socket_mode_proxy.py b/examples/socket_mode_proxy.py index 25498ed25..e1e802957 100644 --- a/examples/socket_mode_proxy.py +++ b/examples/socket_mode_proxy.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -20,9 +13,7 @@ proxy_url = "http://localhost:9000" # Install the Slack app and get xoxb- token in advance -app = App( - client=WebClient(token=os.environ["SLACK_BOT_TOKEN"], proxy=proxy_url) -) +app = App(client=WebClient(token=os.environ["SLACK_BOT_TOKEN"], proxy=proxy_url)) @app.event("app_mention") diff --git a/examples/starlette/app.py b/examples/starlette/app.py index 14842c691..51976457d 100644 --- a/examples/starlette/app.py +++ b/examples/starlette/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt import App from slack_bolt.adapter.starlette import SlackRequestHandler diff --git a/examples/starlette/async_app.py b/examples/starlette/async_app.py index 61fc2ee39..05a8410dd 100644 --- a/examples/starlette/async_app.py +++ b/examples/starlette/async_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler diff --git a/examples/starlette/async_oauth_app.py b/examples/starlette/async_oauth_app.py index 433b2c3e5..9ea92e1a6 100644 --- a/examples/starlette/async_oauth_app.py +++ b/examples/starlette/async_oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler diff --git a/examples/starlette/oauth_app.py b/examples/starlette/oauth_app.py index 64791adb3..704921298 100644 --- a/examples/starlette/oauth_app.py +++ b/examples/starlette/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt import App from slack_bolt.adapter.starlette import SlackRequestHandler diff --git a/examples/tornado/app.py b/examples/tornado/app.py index a73eeb7ff..391d818fc 100644 --- a/examples/tornado/app.py +++ b/examples/tornado/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/tornado/oauth_app.py b/examples/tornado/oauth_app.py index 2ae762eb1..ec997125e 100644 --- a/examples/tornado/oauth_app.py +++ b/examples/tornado/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging from slack_bolt import App from slack_bolt.adapter.tornado import SlackEventsHandler, SlackOAuthHandler diff --git a/examples/workflow_steps/async_steps_from_apps.py b/examples/workflow_steps/async_steps_from_apps.py index 05b7801a7..06307be7d 100644 --- a/examples/workflow_steps/async_steps_from_apps.py +++ b/examples/workflow_steps/async_steps_from_apps.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient diff --git a/examples/workflow_steps/async_steps_from_apps_decorator.py b/examples/workflow_steps/async_steps_from_apps_decorator.py index e4f6b3194..7c88a39a0 100644 --- a/examples/workflow_steps/async_steps_from_apps_decorator.py +++ b/examples/workflow_steps/async_steps_from_apps_decorator.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import asyncio import logging diff --git a/examples/workflow_steps/async_steps_from_apps_primitive.py b/examples/workflow_steps/async_steps_from_apps_primitive.py index b9da584e9..a343d0005 100644 --- a/examples/workflow_steps/async_steps_from_apps_primitive.py +++ b/examples/workflow_steps/async_steps_from_apps_primitive.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient diff --git a/examples/workflow_steps/steps_from_apps.py b/examples/workflow_steps/steps_from_apps.py index a34807d99..147aa5d05 100644 --- a/examples/workflow_steps/steps_from_apps.py +++ b/examples/workflow_steps/steps_from_apps.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging from slack_sdk import WebClient diff --git a/examples/workflow_steps/steps_from_apps_decorator.py b/examples/workflow_steps/steps_from_apps_decorator.py index 64946b4a3..6ccccd975 100644 --- a/examples/workflow_steps/steps_from_apps_decorator.py +++ b/examples/workflow_steps/steps_from_apps_decorator.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import time import logging diff --git a/examples/workflow_steps/steps_from_apps_primitive.py b/examples/workflow_steps/steps_from_apps_primitive.py index 1777a784c..96ad0c97b 100644 --- a/examples/workflow_steps/steps_from_apps_primitive.py +++ b/examples/workflow_steps/steps_from_apps_primitive.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging from slack_sdk import WebClient diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index e861401c3..facbde4aa 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -11,6 +11,8 @@ pip uninstall python-lambda test_target="$1" +pip install -e . + if [[ $test_target != "" ]] then pip install -e ".[testing]" && \ From 0b7f28cc011fd68bee2a6862ecc52d683dac967c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 19 Feb 2021 14:07:00 +0900 Subject: [PATCH 273/865] Upgrade the minimum slack-sdk version to 3.4 --- setup.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index e090e1e32..55867b415 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.3.2,<3.4",], + install_requires=["slack_sdk>=3.4,<4",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", @@ -52,8 +52,7 @@ "moto<=2", # For AWS tests "bottle>=0.12,<1", "boddle>=0.2,<0.3", # For Bottle app tests - # TODO: https://github.com/aws/chalice/issues/1627 - "chalice>=1.22,<2", + "chalice>=1.22.1,<2", "click>=7,<8", # for chalice "CherryPy>=18,<19", "Django>=3,<4", From a69abb5a80a3c34bcda441537b7b6934a45d8cbf Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 19 Feb 2021 14:40:15 +0900 Subject: [PATCH 274/865] version 1.4.0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index f708a9b20..3e8d9f946 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.3.2" +__version__ = "1.4.0" From 32f47c45e53104d2a93f2334bc9c2efad8cd3635 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 21 Feb 2021 11:56:50 +0900 Subject: [PATCH 275/865] Fix an issue where different user's token may exist in context --- slack_bolt/authorization/async_authorize.py | 7 +++++++ slack_bolt/authorization/authorize.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index 335aff82c..80cfbd415 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -131,6 +131,8 @@ async def __call__( if not self.bot_only and self.find_installation_available: # since v1.1, this is the default way try: + # Note that this is the latest information for the org/workspace. + # The installer may not be the user associated with this incoming request. installation: Optional[ Installation ] = await self.installation_store.async_find_installation( @@ -143,6 +145,10 @@ async def __call__( return None if installation.user_id != user_id: + # First off, remove the user token as the installer is a different user + installation.user_token = None + installation.user_scopes = [] + # try to fetch the request user's installation # to reflect the user's access token if exists user_installation = ( @@ -154,6 +160,7 @@ async def __call__( ) ) if user_installation is not None: + # Overwrite the installation with the one for this user installation = user_installation bot_token, user_token = installation.bot_token, installation.user_token diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 56cd04161..5b533b576 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -131,6 +131,8 @@ def __call__( if not self.bot_only and self.find_installation_available: # since v1.1, this is the default way try: + # Note that this is the latest information for the org/workspace. + # The installer may not be the user associated with this incoming request. installation: Optional[ Installation ] = self.installation_store.find_installation( @@ -143,6 +145,10 @@ def __call__( return None if installation.user_id != user_id: + # First off, remove the user token as the installer is a different user + installation.user_token = None + installation.user_scopes = [] + # try to fetch the request user's installation # to reflect the user's access token if exists user_installation = self.installation_store.find_installation( @@ -152,6 +158,7 @@ def __call__( is_enterprise_install=context.is_enterprise_install, ) if user_installation is not None: + # Overwrite the installation with the one for this user installation = user_installation bot_token, user_token = installation.bot_token, installation.user_token From f2f8532c2d363ceb6ae97c1d60253ccb59dba933 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 21 Feb 2021 12:42:09 +0900 Subject: [PATCH 276/865] version 1.4.1 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 3e8d9f946..bf2561596 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.4.0" +__version__ = "1.4.1" From ea8e54ca04aa30993b07c0234c92fdf7b5682689 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 24 Feb 2021 08:40:16 +0900 Subject: [PATCH 277/865] Resolve build failures --- setup.py | 4 ++++ ...ns_web_client.py => test_interactions_websocket_client.py} | 0 2 files changed, 4 insertions(+) rename tests/adapter_tests/socket_mode/{test_interactions_web_client.py => test_interactions_websocket_client.py} (100%) diff --git a/setup.py b/setup.py index 55867b415..e527d4604 100755 --- a/setup.py +++ b/setup.py @@ -43,6 +43,8 @@ "async": [ # async features heavily depends on aiohttp "aiohttp>=3,<4", + # Socket Mode 3rd party implementation + "websockets>=8,<9", ], # pip install -e ".[adapter]" # NOTE: any of async ones requires pip install -e ".[async]" too @@ -67,6 +69,8 @@ # server "uvicorn<1", "gunicorn>=20,<21", + # Socket Mode 3rd party implementation + "websocket_client>=0.57,<1" ], # pip install -e ".[testing]" "testing": test_dependencies, diff --git a/tests/adapter_tests/socket_mode/test_interactions_web_client.py b/tests/adapter_tests/socket_mode/test_interactions_websocket_client.py similarity index 100% rename from tests/adapter_tests/socket_mode/test_interactions_web_client.py rename to tests/adapter_tests/socket_mode/test_interactions_websocket_client.py From 3e483fe2266cb5b544aaf9d93e58d2d6492eedf3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 24 Feb 2021 08:50:48 +0900 Subject: [PATCH 278/865] Set moto version to v1 --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e527d4604..5d2ad9121 100755 --- a/setup.py +++ b/setup.py @@ -51,7 +51,8 @@ "adapter": [ # used only under src/slack_bolt/adapter "boto3<=2", - "moto<=2", # For AWS tests + # TODO: Upgrade to v2 + "moto<2", # For AWS tests "bottle>=0.12,<1", "boddle>=0.2,<0.3", # For Bottle app tests "chalice>=1.22.1,<2", From 97a55d8dd68f27321a4a40ca997537c828184f08 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 24 Feb 2021 08:01:22 +0900 Subject: [PATCH 279/865] Pass the Bolt logger in default WebClient instantiation --- slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py | 2 +- slack_bolt/app/app.py | 6 +++++- slack_bolt/app/async_app.py | 7 +++++-- slack_bolt/oauth/async_oauth_flow.py | 2 +- slack_bolt/oauth/oauth_flow.py | 2 +- slack_bolt/util/async_utils.py | 6 +++++- slack_bolt/util/utils.py | 6 +++++- 7 files changed, 23 insertions(+), 8 deletions(-) diff --git a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py index f6c8ba4d7..14c3f41d3 100644 --- a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py +++ b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py @@ -72,7 +72,7 @@ def __init__( @property def client(self) -> WebClient: if self._client is None: - self._client = create_web_client() + self._client = create_web_client(logger=self.logger) return self._client @property diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 2d2c8a116..1941f129f 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -138,7 +138,11 @@ def __init__( warning_client_prioritized_and_token_skipped() ) else: - self._client = create_web_client(token) # NOTE: the token here can be None + self._client = create_web_client( + # NOTE: the token here can be None + token=token, + logger=self._framework_logger, + ) # -------------------------------------- # Authorize & OAuthFlow initialization diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 83242ca90..df4d909b4 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -145,8 +145,11 @@ def __init__( warning_client_prioritized_and_token_skipped() ) else: - # NOTE: the token here can be None - self._async_client = create_async_web_client(token) + self._async_client = create_async_web_client( + # NOTE: the token here can be None + token=token, + logger=self._framework_logger, + ) # -------------------------------------- # Authorize & OAuthFlow initialization diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 42a98c9e5..3808c1408 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -39,7 +39,7 @@ class AsyncOAuthFlow: @property def client(self) -> AsyncWebClient: if self._async_client is None: - self._async_client = create_async_web_client() + self._async_client = create_async_web_client(logger=self.logger) return self._async_client @property diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index c03f406d2..df03ef452 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -38,7 +38,7 @@ class OAuthFlow: @property def client(self) -> WebClient: if self._client is None: - self._client = create_web_client() + self._client = create_web_client(logger=self.logger) return self._client @property diff --git a/slack_bolt/util/async_utils.py b/slack_bolt/util/async_utils.py index 60bbe0903..de01d6835 100644 --- a/slack_bolt/util/async_utils.py +++ b/slack_bolt/util/async_utils.py @@ -1,3 +1,4 @@ +from logging import Logger from typing import Optional from slack_sdk.web.async_client import AsyncWebClient @@ -5,8 +6,11 @@ from slack_bolt.version import __version__ as bolt_version -def create_async_web_client(token: Optional[str] = None) -> AsyncWebClient: +def create_async_web_client( + token: Optional[str] = None, logger: Optional[Logger] = None +) -> AsyncWebClient: return AsyncWebClient( token=token, + logger=logger, user_agent_prefix=f"Bolt-Async/{bolt_version}", ) diff --git a/slack_bolt/util/utils.py b/slack_bolt/util/utils.py index 15352367e..e603110f8 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -1,5 +1,6 @@ import copy import sys +from logging import Logger from typing import Optional, Union, Dict, Any, Sequence, Callable from slack_sdk import WebClient @@ -9,9 +10,12 @@ from slack_bolt.version import __version__ as bolt_version -def create_web_client(token: Optional[str] = None) -> WebClient: +def create_web_client( + token: Optional[str] = None, logger: Optional[Logger] = None +) -> WebClient: return WebClient( token=token, + logger=logger, user_agent_prefix=f"Bolt/{bolt_version}", ) From 8a0a3de78c4f8bd9abb001607d55b494e4716c47 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 18 Feb 2021 17:52:59 +0900 Subject: [PATCH 280/865] Add Socket Mode in README --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index d046cb59d..dd710eb92 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,39 @@ python app.py ngrok http 3000 ``` +## Running a Socket Mode app + +If you use [Socket Mode](https://api.slack.com/socket-mode) for running your app, `SocketModeHandler` is available for it. + +```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"]) + +# Add functionality here + +if __name__ == "__main__": + # Create an app-level token with connections:write scope + handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + handler.start() +``` + +Run the app this way: + +```bash +export SLACK_APP_TOKEN=xapp-*** +export SLACK_BOT_TOKEN=xoxb-*** +python app.py + +# SLACK_SIGNING_SECRET is not required +# Running ngrok is not required +``` + ## Listening for events + Apps typically react to a collection of incoming events, which can correspond to [Events API events](https://api.slack.com/events-api), [actions](https://api.slack.com/interactivity/components), [shortcuts](https://api.slack.com/interactivity/shortcuts), [slash commands](https://api.slack.com/interactivity/slash-commands) or [options requests](https://api.slack.com/reference/block-kit/block-elements#external_select). For each type of request, there's a method to build a listener function. From 09c7f2c19fd177d9f32358598fd37e00d4df61a0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 25 Feb 2021 23:55:38 +0900 Subject: [PATCH 281/865] Remove unnecessary semi-colons from code snippets in docs --- docs/_basic/opening_modals.md | 2 +- docs/_basic/responding_actions.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/_basic/opening_modals.md b/docs/_basic/opening_modals.md index 9c71763e7..2276e0c79 100644 --- a/docs/_basic/opening_modals.md +++ b/docs/_basic/opening_modals.md @@ -20,7 +20,7 @@ Read more about modal composition in the
    @@ -37,7 +37,7 @@ Since `respond()` is a utility for calling the `response_url`, it behaves in the # Listens to actions triggered with action_id of “user_select” @app.action("user_select") def select_user(ack, action, respond): - ack(); + ack() respond(f"You selected <@{action['selected_user']}>") ``` From fa0fedc87c9115f7d806f8d93d446bd511a548dd Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 3 Mar 2021 11:24:04 +0900 Subject: [PATCH 282/865] version 1.4.2 --- setup.py | 2 +- slack_bolt/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 5d2ad9121..c25be3ea3 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.4,<4",], + install_requires=["slack_sdk>=3.4.1,<4",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index bf2561596..daa50c7cf 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.4.1" +__version__ = "1.4.2" From 197407b25ee21e864299663f3c4b5ce24795dc75 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 5 Mar 2021 15:42:57 +0900 Subject: [PATCH 283/865] Add Getting started guide in Japanese --- docs/_config.yml | 9 +- docs/_includes/header.html | 2 +- docs/_tutorials/ja_getting_started.md | 304 ++++++++++++++++++++++++++ docs/assets/style.css | 4 +- 4 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 docs/_tutorials/ja_getting_started.md diff --git a/docs/_config.yml b/docs/_config.yml index 716bea204..8e01ded40 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -36,12 +36,11 @@ t: start: Getting started contribute: Contributing ja-jp: - basic: 基本的な概念 - # TODO: translate this title - steps: Workflow steps - advanced: 応用コンセプト +# basic: 基本的な概念 +# steps: ワークフローステップ +# advanced: 応用コンセプト start: Bolt 入門ガイド - contribute: 貢献 +# contribute: 貢献 # Metadata repo_name: bolt-python diff --git a/docs/_includes/header.html b/docs/_includes/header.html index 55924b2d4..53482748e 100644 --- a/docs/_includes/header.html +++ b/docs/_includes/header.html @@ -6,7 +6,7 @@ {% if page.lang == "ja-jp" %} English {% else %} - + 日本語 (Japanese) {% endif %} diff --git a/docs/_tutorials/ja_getting_started.md b/docs/_tutorials/ja_getting_started.md new file mode 100644 index 000000000..80a315f66 --- /dev/null +++ b/docs/_tutorials/ja_getting_started.md @@ -0,0 +1,304 @@ +--- +title: Bolt 入門ガイド +order: 0 +slug: getting-started +lang: ja-jp +layout: tutorial +permalink: /ja-jp/tutorial/getting-started +redirect_from: + - /ja-jp/getting-started + - /getting-started/ja-jp +--- +# Bolt 入門ガイド + +
    +このガイドでは、Bolt for Python を使った Slack アプリの設定と起動する方法について説明します。ここで説明する手順は、新しい Slack アプリを作成し、ローカルの開発環境をセットアップし、Slack ワークスペースからのメッセージをリッスンして応答するアプリを開発するという流れになります。 +
    + +この手順を全て終わらせたら、あなたはきっと ⚡️[Slack アプリのはじめ方](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started)のサンプルアプリを動作させたり、それに変更を加えたり、自分のアプリを作ったりすることができるようになるでしょう。 + +--- + +### アプリを作成する +最初にやるべきこと : Bolt での開発を始める前に、[Slack アプリを作成](https://api.slack.com/apps/new)します。 + +> 💡 いつもの仕事のさまたげにならないように、別の開発用のワークスペースを使用することをおすすめします。[新しいワークスペースは無料で作成できます](https://slack.com/get-started#create)。 + +アプリ名を入力し(_後で変更可能_)、インストール先のワークスペースを選択したら、「`Create App`」ボタンをクリックすると、アプリの **Basic Information** ページが表示されます。 + +このページでは、アプリの概要を確認できます。また、「**App Credentials**」ヘッダーの下では「`Signing Secret`」などの重要な認証情報も確認できます。これらの認証情報は後で必要になります。 + +![Basic Information ページ](../../assets/basic-information-page.png "Basic Information ページ") + +ひと通り確認し、アプリのアイコンと説明を追加したら、アプリの構成 🔩 を始めましょう。 + +--- + +### トークンとアプリのインストール +Slack アプリでは、[Slack API へのアクセスの管理に OAuth を使用します](https://api.slack.com/docs/oauth)。アプリがインストールされると、トークンが発行されます。アプリはそのトークンを使って API メソッドを呼び出すことができます。 + +Slack アプリで使用できるトークンには、ユーザートークン(`xoxp`)とボットトークン(`xoxb`)の 2 種類があります。ユーザートークンを使用すると、ユーザーがアプリをインストールまたは認証した後、アプリがそのユーザーを代理して API メソッドを呼び出すことができます。1 つのワークスペースに複数のユーザートークンが存在する可能性があります。ボットトークンはボットユーザーに関連づけられ、1 つのワークスペースでは最初に誰かがそのアプリをインストールした際に一度だけ発行されます。どのユーザーがインストールを実行しても、アプリが使用するボットトークンは同じになります。_ほとんど_のアプリで使用されるのは、ボットトークンです。 + +説明を簡潔にするために、このガイドではボットトークンを使用します。 + +左サイドバーの「**OAuth & Permissions**」をクリックし、「**Bot Token Scopes**」セクションまで下にスクロールします。「**Add an OAuth Scope**」をクリックします。 + +ここでは「[`chat:write`](https://api.slack.com/scopes/chat:write)」というスコープのみを追加します。このスコープは、アプリが参加しているチャンネルにメッセージを投稿することを許可します。 + +OAuth & Permissions ページの一番上までスクロールし、「**Install App to Workspace**」をクリックします。Slack の OAuth 確認画面 が表示されます。この画面で開発用ワークスペースへのアプリのインストールを承認します。 + +インストールを承認すると **OAuth & Permissions** ページが表示され、**Bot User OAuth Access Token** を確認できるでしょう。 + +![OAuth トークン](../../assets/bot-token.png "ボット用 OAuth トークン") + +> 💡 トークンはパスワードと同様に取り扱い、[安全な方法で保管してください](https://api.slack.com/docs/oauth-safety)。アプリはこのトークンを使って Slack ワークスペースで投稿をしたり、情報の取得をしたりします。 + +--- + +### ローカルでプロジェクトをセットアップする +初期設定が終わったら、新しい Bolt プロジェクトのセットアップを行いましょう。このプロジェクトが、あなたのアプリのロジックを処理するコードを配置する場所となります。 + +プロジェクトをまだ作成していない場合は、新しく作成しましょう。空のディレクトリを作成します。 + +```shell +mkdir first-bolt-app +cd first-bolt-app +``` + +次に、プロジェクトの依存関係を管理する方法として、[Python 仮想環境](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment)を使ったおすすめの方法を紹介します。これはシステム Python に存在するパッケージとのコンフリクトを防ぐために推奨されている優れた方法です。[Python 3.6 以降](https://www.python.org/downloads/)の仮想環境を作成し、アクティブにしてみましょう。 + +```shell +python3 -m venv .venv +source .venv/bin/activate +``` + +`python3` へのパスがプロジェクトの中を指していることを確かめることで、仮想環境がアクティブになっていることを確認できます([Windows でもこれに似たコマンドが利用できます](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#activating-a-virtual-environment))。 + +```shell +which python3 +# 出力結果 : /path/to/first-bolt-app/.venv/bin/python3 +``` + +Bolt for Python のパッケージを新しいプロジェクトにインストールする前に、アプリの設定時に作成されたボットトークンと署名シークレットを保存しましょう。これらは環境変数に保存する必要があります。バージョンコントロールには*保存しない*ようにしてください。 + +1. **Basic Information ページの署名シークレット(Signing Secret)をコピー**して、新しい環境変数に保存します。以下のコマンド例は Linux と macOS で利用できます。[Windows でもこれに似たコマンドが利用できます](https://superuser.com/questions/212150/how-to-set-env-variable-in-windows-cmd-line/212153#212153)。 +```shell +export SLACK_SIGNING_SECRET=<署名シークレット> +``` + +2. **OAuth & Permissions ページのボットトークン(xoxb)をコピー**して、別の環境変数に保存します。 +```shell +export SLACK_BOT_TOKEN=xoxb-<ボットトークン> +``` + +完了したら、アプリを作ってみましょう。以下のコマンドを使って、仮想環境に Python の `slack_bolt` パッケージをインストールします。 + +```shell +pip install slack_bolt +``` + +このディレクトリに「`app.py`」という名前の新しいファイルを作成し、以下のコードを追加します。 + +```python +import os +from slack_bolt import App + +# ボットトークンと署名シークレットを使ってアプリを初期化します +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# アプリを起動します +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + +このようにトークンと署名シークレットだけあれば、最初の Bolt アプリが作成できます。「`app.py`」ファイルを保存して、コマンドラインで以下を実行します。 + +```script +python3 app.py +``` + +アプリが起動し、実行中であることが表示されます。 + +--- + +### イベントを設定する +アプリはワークスペース内の他のメンバーと同じように振る舞い、メッセージを投稿したり絵文字リアクションを追加したりできます。Slack ワークスペースで発生するイベント(メッセージが投稿されたときや、メッセージに対するリアクションがつけられたときなど)をリッスンするには、[Events API を使って特定の種類のイベントをサブスクライブします](https://api.slack.com/events-api)。 + +アプリをイベントに対応させるため、まずこのアプリの設定ページに戻って設定を行います。[アプリ管理ページ](https://api.slack.com/apps)でアプリをクリックします。次に、左サイドバーの「**Event Subscriptions**」をクリックします。「**Enable Events**」というラベルのスイッチをオンに切り替えます。 + +「**Request URL**」というラベルのテキスト入力フィールドが表示されます。Request URL は、指定したイベントに対応する Slack からの HTTP POST リクエストの送信先となるパブリック URL です。 + +> ⚙️ [API サイト](https://api.slack.com/docs/hosting)に、Slack の開発チームがアプリのホストによく使用する一般的なホスティングプロバイダをいくつか記載しておきました。 + +イベントが発生すると、そのイベントをトリガーしたユーザーやイベントが発生したチャンネルなど、イベントに関する情報が Slack からアプリに送信されます。アプリではこれらの情報を処理して、適切な応答を返します。 + +
    + +

    開発用にローカルの Request URL を使用する

    +
    アプリの開発を始めたばかりの場合、パブリックにアクセスできる URL をまだ持っていないかもしれませんね。最終的には適切なものをセットアップするものとして、ここでは [ngrok](https://ngrok.com/) のような開発用プロキシを利用することにします。開発用プロキシを利用すると、リクエストを開発環境にトンネルするパブリック URL を作成できます。[Slack のローカル開発で ngrok を使用する方法](https://api.slack.com/tutorials/tunneling-with-ngrok)については、別のチュートリアルを用意していますので参考にしてください。開発用プロキシをインストールして実行を開始すると、リクエストが特定のポートに転送されるようになります(この例ではポート 3000 を使用していますが、アプリを初期化する際に使用ポートをカスタマイズしている場合は、そのポートを使用してください)。```shell ngrok http 3000 ``` ![ngrok の実行](../../assets/ngrok.gif "ngrok の実行") 利用可能な URL が生成され、出力結果に表示されます(「`https://`」で始まる URL をおすすめします)。この URL がリクエスト URL のベースになります。この例では「`https://8e8ec2d7.ngrok.io`」です。--- +
    + +これで、ローカルマシンにトンネルしてアプリで利用できるパブリック URL が用意できました。アプリの設定で指定する Request URL は、パブリック URL に、アプリがリッスンするパスを組み合わせたものになります。デフォルトでは、Bolt アプリは「`/slack/events`」をリッスンします。この場合、Request URL 全体は「`https://8e8ec2d7.ngrok.io/slack/events`」となります。 + +> ⚙️ Bolt では、`/slack/events` というエンドポイントで、すべての受信リクエストをリッスンします。これらのリクエストにはショートカット、イベント、インタラクションペイロードが含まれます。アプリの設定でエンドポイントを指定するときは、すべての Request URL の末尾に「`/slack/events`」を追加してください。 + +「**Request URL**」ボックスの「**Enable Events**」スイッチの下に URL を貼りつけます。Bolt アプリが起動した状態のままなら、URL の検証が成功するはずです。 + +Request URL の検証が完了したら、「**Subscribe to Bot Events**」まで下にスクロールします。メッセージに関連するイベントには、次の 4 つがあります。 + +- `message.channels` アプリが参加しているパブリックチャンネルのメッセージをリッスン +- `message.groups` アプリが参加しているプライベートチャンネルのメッセージをリッスン +- `message.im` あなたのアプリとユーザーのダイレクトメッセージをリッスン +- `message.mpim` あなたのアプリが追加されているグループ DM をリッスン + +ボットが参加するすべての場所のメッセージをリッスンさせるには、これら 4 つのメッセージイベントをすべて選択します。ボットにリッスンさせるメッセージイベントの種類を選択したら、「**Save Changes**」ボタンをクリックします。 + +--- + +### メッセージをリッスンして応答する +アプリにロジックを組み込む準備が整いました。まずは `message()` メソッドを使用して、メッセージのリスナーをアタッチしましょう。 + +次の例では、アプリが参加するチャンネルとダイレクトメッセージに投稿されるすべてのメッセージをリッスンし、「hello」というメッセージに応答を返します。 + +```python +import os +from slack_bolt import App + +# ボットトークンと署名シークレットを使ってアプリを初期化します +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# 「hello」を含むメッセージをリッスンします +@app.message("hello") +def message_hello(message, say): + # イベントがトリガーされたチャンネルへ say() でメッセージを送信します + say(f"Hey there <@{message['user']}>!") + +# アプリを起動します +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + +アプリを再起動し、ボットユーザーが参加しているチャンネルまたはダイレクトメッセージに「hello」というメッセージを投稿すれば、アプリが応答するでしょう。 + +これはごく基本的なコード例ですが、最終的にやりたいことを実現するためにアプリをカスタマイズするための起点として利用できます。プレーンテキストを送信する代わりにボタンを表示するという、もう少しインタラクティブな動作を試してみましょう。 + +--- + +### アクションを送信して応答する + +インタラクティブ機能を有効にすると、ボタン、選択メニュー、日付ピッカー、モーダル、ショートカットなどの機能が利用できるようになります。イベントと同様に、Slack からのアクション(*ユーザーがボタンをクリックした*など)の送信先となる URL を設定する必要があります。 + +アプリ設定ページに戻り、左サイドメニューの「**Interactivity & Shortcuts**」をクリックします。別の **Request URL** ボックスを見つけます。 + +デフォルトでは、Bolt はイベントに使用しているのと同じエンドポイントをインタラクティブコンポーネントにも使用するように設定されているため、上記と同じリクエスト URL(この例では「`https://8e8ec2d7.ngrok.io/slack/events`」)を使用します。このままの状態で、右下隅にある「**Save Changes**」ボタンを押してください。これでインタラクティブ機能がアプリで利用できるようになりました。 + +![Request URL の設定](../../assets/request-url-config.png "Request URL の設定") + +それでは、アプリのコードに戻り、インタラクティブ機能を追加しましょう。インタラクティブ機能は 2 つのステップで構成されます。まず、ボタンを含んだメッセージをアプリから送信します。次に、ユーザーから返されるボタンクリックのアクションをリッスンし、それに応答します。 + +以下のコードの後の部分を編集し、文字列だけのメッセージの代わりに、ボタンを含んだメッセージを送信するようにしてみます。 + +```python +import os +from slack_bolt import App + +# ボットトークンと署名シークレットを使ってアプリを初期化します +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# 「hello」を含むメッセージをリッスンします +@app.message("hello") +def message_hello(message, say): + # イベントがトリガーされたチャンネルへ say() でメッセージを送信します + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text":"Click Me"}, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +# アプリを起動します +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + +`say()` の中の値を、「`blocks`」という配列のオブジェクトに変えました。ブロックは Slack メッセージを構成するコンポーネントであり、テキストや画像、日付ピッカーなど、さまざまなタイプのブロックがあります。この例では、「accessory」に「button」を持たせた「section」のブロックを、アプリからの応答に含めています。「`blocks`」を使用する場合、「`text`」は通知やアクセシビリティのためのフォールバックとなります。 + +ボタンを含む「`accessory`」オブジェクトでは、「`action_id`」を指定していることがわかります。これは、ボタンを一意に示す識別子として機能します。これを使って、アプリをどのアクションに応答させるかを指定できます。 + +> 💡 [Block Kit Builder](https://app.slack.com/block-kit-builder) を使用すると、インタラクティブなメッセージのプロトタイプを簡単に作成できます。自分自身やチームメンバーがメッセージのモックアップを作成し、生成される JSON をアプリに直接貼りつけることができます。 + +アプリを再起動し、アプリが参加しているチャンネルで「hello」と入力すると、ボタン付きのメッセージが表示されるようになりました。ただし、ボタンをクリックしても、*まだ*何も起こりません。 + +ハンドラーを追加して、ボタンがクリックされたときにフォローアップメッセージを送信するようにしてみましょう。 + +```python +import os +from slack_bolt import App + +# ボットトークンと署名シークレットを使ってアプリを初期化します +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# 「hello」を含むメッセージをリッスンします +@app.message("hello") +def message_hello(message, say): + # イベントがトリガーされたチャンネルへ say() でメッセージを送信します + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text":"Click Me"}, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +@app.action("button_click") +def action_button_click(body, ack, say): + # アクションを確認したことを即時で応答します + ack() + # チャンネルにメッセージを投稿します + say(f"<@{body['user']['id']}> clicked the button") + +# アプリを起動します +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + +`app.action()` を使って、先ほど命名した「`button_click`」という `action_id` をリッスンしています。アプリを再起動し、ボタンをクリックすると、アプリからの「clicked the button」というメッセージが新たに表示されるでしょう。 + +--- + +### 次のステップ +はじめての [Bolt for Python アプリ](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started)を構築することができました。🎉 + +ここまでで基本的なアプリをセットアップして実行することはできたので、次は自分だけの Bolt アプリを作る方法を調べてみましょう。参考になりそうな記事をいくつかご紹介します。 + +* [基本的な概念](/bolt-python/concepts#basic)について読む。Bolt アプリがアクセスできるさまざまメソッドや機能について知ることができます。 +* [`events()` メソッド](/bolt-python/concepts#event-listening)でボットがリッスンできるイベントをほかにも試してみる。すべてのイベントの一覧は [API サイト](https://api.slack.com/events)で確認できます。 +* Bolt では、アプリにアタッチされたクライアントから [Web API メソッドを呼び出す](/bolt-python/concepts#web-api)ことができます。API サイトに [220 以上のメソッド](https://api.slack.com/methods)を一覧しています。 +* [API サイト](https://api.slack.com/docs/token-types)でほかのタイプのトークンを確認する。アプリで実行したいアクションによって、異なるトークンが必要になる場合があります。 \ No newline at end of file diff --git a/docs/assets/style.css b/docs/assets/style.css index 3af53a8c6..2041edc07 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -133,7 +133,7 @@ span.beta { color: var(--grey); font-weight: 700; padding: 6px 14px 9px; - font-size 0.9em; + font-size: 0.9em; } .header a.language-switcher:hover { @@ -173,7 +173,7 @@ span.beta { border-image: linear-gradient( to bottom, #FFFFFF 0%, - #F2F2F2 6% 92%, + #F2F2F2 6%, #FFFFFF 100% ) 1 100%; list-style: none; From dd9dc762766b05c6ad2ed6c27aff30ec5d902311 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 6 Mar 2021 11:49:26 +0900 Subject: [PATCH 284/865] Fix an error in Django example --- examples/django/manage.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/django/manage.py b/examples/django/manage.py index 38488e78b..eb1a2c09c 100755 --- a/examples/django/manage.py +++ b/examples/django/manage.py @@ -1,6 +1,7 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" import os +import sys def main(): From 1efd5cd62a87d683d5df7fc0adb40efc798b5815 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 6 Mar 2021 12:07:57 +0900 Subject: [PATCH 285/865] Fix errors in AWS Lambda examples --- examples/aws_lambda/aws_lambda.py | 2 ++ examples/aws_lambda/aws_lambda_oauth.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/examples/aws_lambda/aws_lambda.py b/examples/aws_lambda/aws_lambda.py index 8ca79a82c..420ed380c 100644 --- a/examples/aws_lambda/aws_lambda.py +++ b/examples/aws_lambda/aws_lambda.py @@ -1,5 +1,7 @@ import logging +import sys +sys.path.insert(1, "vendor") from slack_bolt import App from slack_bolt.adapter.aws_lambda import SlackRequestHandler diff --git a/examples/aws_lambda/aws_lambda_oauth.py b/examples/aws_lambda/aws_lambda_oauth.py index f30609a1c..d13660c06 100644 --- a/examples/aws_lambda/aws_lambda_oauth.py +++ b/examples/aws_lambda/aws_lambda_oauth.py @@ -1,4 +1,7 @@ import logging +import sys + +sys.path.insert(1, "vendor") from slack_bolt import App from slack_bolt.adapter.aws_lambda import SlackRequestHandler From f0080b287837b32e89db93df07b9f5ecb1e96687 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 6 Mar 2021 13:14:45 +0900 Subject: [PATCH 286/865] Resolve #251 by correcting response headers --- slack_bolt/oauth/async_oauth_flow.py | 1 - slack_bolt/oauth/internals.py | 2 -- slack_bolt/oauth/oauth_flow.py | 1 - tests/adapter_tests/aws/test_aws_chalice.py | 1 - tests/adapter_tests/aws/test_aws_lambda.py | 2 -- tests/adapter_tests/django/test_django.py | 1 - tests/adapter_tests/starlette/test_fastapi.py | 1 - tests/adapter_tests/starlette/test_starlette.py | 1 - tests/slack_bolt/oauth/test_oauth_flow.py | 1 - tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py | 1 - tests/slack_bolt_async/oauth/test_async_oauth_flow.py | 1 - tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py | 1 - 12 files changed, 14 deletions(-) diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 3808c1408..ce26c3e03 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -172,7 +172,6 @@ async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse: body=html, headers={ "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(bytes(html, "utf-8")), "Set-Cookie": [set_cookie_value], }, ) diff --git a/slack_bolt/oauth/internals.py b/slack_bolt/oauth/internals.py index e551f682f..39fcd9fcf 100644 --- a/slack_bolt/oauth/internals.py +++ b/slack_bolt/oauth/internals.py @@ -42,7 +42,6 @@ def _build_callback_success_response( # type: ignore status=200, headers={ "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(bytes(html, "utf-8")), "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), }, body=html, @@ -66,7 +65,6 @@ def _build_callback_failure_response( # type: ignore status=status, headers={ "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(bytes(html, "utf-8")), "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), }, body=html, diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index df03ef452..ccb31c826 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -167,7 +167,6 @@ def handle_installation(self, request: BoltRequest) -> BoltResponse: body=html, headers={ "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(bytes(html, "utf-8")), "Set-Cookie": [set_cookie_value], }, ) diff --git a/tests/adapter_tests/aws/test_aws_chalice.py b/tests/adapter_tests/aws/test_aws_chalice.py index bf65601d0..ad9263550 100644 --- a/tests/adapter_tests/aws/test_aws_chalice.py +++ b/tests/adapter_tests/aws/test_aws_chalice.py @@ -288,5 +288,4 @@ def install() -> Response: ) assert response["statusCode"] == 200 assert response["headers"]["content-type"] == "text/html; charset=utf-8" - assert response["headers"]["content-length"] == "565" assert "https://slack.com/oauth/v2/authorize?state=" in response.get("body") diff --git a/tests/adapter_tests/aws/test_aws_lambda.py b/tests/adapter_tests/aws/test_aws_lambda.py index 4c6a1bb14..99000094d 100644 --- a/tests/adapter_tests/aws/test_aws_lambda.py +++ b/tests/adapter_tests/aws/test_aws_lambda.py @@ -305,7 +305,6 @@ def test_oauth(self): response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 assert response["headers"]["content-type"] == "text/html; charset=utf-8" - assert response["headers"]["content-length"] == "565" assert response.get("body") is not None event = { @@ -318,5 +317,4 @@ def test_oauth(self): response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 assert response["headers"]["content-type"] == "text/html; charset=utf-8" - assert response["headers"]["content-length"] == "565" assert "https://slack.com/oauth/v2/authorize?state=" in response.get("body") diff --git a/tests/adapter_tests/django/test_django.py b/tests/adapter_tests/django/test_django.py index c8254c52c..f387427a9 100644 --- a/tests/adapter_tests/django/test_django.py +++ b/tests/adapter_tests/django/test_django.py @@ -188,7 +188,6 @@ def test_oauth(self): response = SlackRequestHandler(app).handle(request) assert response.status_code == 200 assert response.get("content-type") == "text/html; charset=utf-8" - assert response.get("content-length") == "565" assert "https://slack.com/oauth/v2/authorize?state=" in response.content.decode( "utf-8" ) diff --git a/tests/adapter_tests/starlette/test_fastapi.py b/tests/adapter_tests/starlette/test_fastapi.py index 29282bc24..4b09c618a 100644 --- a/tests/adapter_tests/starlette/test_fastapi.py +++ b/tests/adapter_tests/starlette/test_fastapi.py @@ -213,5 +213,4 @@ async def endpoint(req: Request): response = client.get("/slack/install", allow_redirects=False) assert response.status_code == 200 assert response.headers.get("content-type") == "text/html; charset=utf-8" - assert response.headers.get("content-length") == "565" assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/adapter_tests/starlette/test_starlette.py b/tests/adapter_tests/starlette/test_starlette.py index 033d80f51..43b42cfbf 100644 --- a/tests/adapter_tests/starlette/test_starlette.py +++ b/tests/adapter_tests/starlette/test_starlette.py @@ -222,5 +222,4 @@ async def endpoint(req: Request): response = client.get("/slack/install", allow_redirects=False) assert response.status_code == 200 assert response.headers.get("content-type") == "text/html; charset=utf-8" - assert response.headers.get("content-length") == "565" assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/slack_bolt/oauth/test_oauth_flow.py b/tests/slack_bolt/oauth/test_oauth_flow.py index 9be907b66..a43db0b5f 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow.py +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -56,7 +56,6 @@ def test_handle_installation_default(self): resp = oauth_flow.handle_installation(req) assert resp.status == 200 assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] - assert resp.headers.get("content-length") == ["576"] assert "https://slack.com/oauth/v2/authorize?state=" in resp.body # https://github.com/slackapi/bolt-python/issues/183 diff --git a/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py index 00b3ad12b..26c54df15 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py +++ b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py @@ -41,7 +41,6 @@ def test_handle_installation(self): resp = oauth_flow.handle_installation(req) assert resp.status == 200 assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] - assert resp.headers.get("content-length") == ["565"] assert "https://slack.com/oauth/v2/authorize?state=" in resp.body def test_handle_callback(self): diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index 2aecaf424..eea474b87 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -107,7 +107,6 @@ async def test_handle_installation_default(self): resp = await oauth_flow.handle_installation(req) assert resp.status == 200 assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] - assert resp.headers.get("content-length") == ["565"] assert "https://slack.com/oauth/v2/authorize?state=" in resp.body @pytest.mark.asyncio diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py index afd3cbdac..c9190ff55 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py @@ -55,7 +55,6 @@ async def test_handle_installation(self): resp = await oauth_flow.handle_installation(req) assert resp.status == 200 assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] - assert resp.headers.get("content-length") == ["565"] assert "https://slack.com/oauth/v2/authorize?state=" in resp.body @pytest.mark.asyncio From e80a21cec28cd7dcbeb0dce35ec3e4fcdc0ad277 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 6 Mar 2021 14:33:41 +0900 Subject: [PATCH 287/865] version 1.4.3 --- setup.py | 2 +- slack_bolt/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index c25be3ea3..cdb5bcd01 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.4.1,<4",], + install_requires=["slack_sdk>=3.4.2,<4",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index daa50c7cf..aa56ed404 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.4.2" +__version__ = "1.4.3" From 4044d5ab37656e65f2f91526c0b7d250f1018566 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 10 Mar 2021 08:35:03 +0900 Subject: [PATCH 288/865] Improve the warning message in App/AsyncApp constructor #256 --- slack_bolt/app/app.py | 7 ++++++- slack_bolt/app/async_app.py | 5 +++++ slack_bolt/logger/messages.py | 13 ++++++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 1941f129f..175bc0e58 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -44,6 +44,7 @@ error_authorize_conflicts, warning_bot_only_conflicts, debug_return_listener_middleware_response, + info_default_oauth_settings_loaded, ) from slack_bolt.middleware import ( Middleware, @@ -174,7 +175,11 @@ def __init__( # initialize with the default settings oauth_settings = OAuthSettings() - if oauth_flow: + if oauth_flow is None and installation_store is None: + # show info-level log for avoiding confusions + self._framework_logger.info(info_default_oauth_settings_loaded()) + + if oauth_flow is not None: self._oauth_flow = oauth_flow installation_store = select_consistent_installation_store( client_id=self._oauth_flow.client_id, diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index df4d909b4..7d1180674 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -45,6 +45,7 @@ error_oauth_flow_invalid_type_async, warning_bot_only_conflicts, debug_return_listener_middleware_response, + info_default_oauth_settings_loaded, ) from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -184,6 +185,10 @@ def __init__( # initialize with the default settings oauth_settings = AsyncOAuthSettings() + if oauth_flow is None and installation_store is None: + # show info-level log for avoiding confusions + self._framework_logger.info(info_default_oauth_settings_loaded()) + if oauth_flow: if not isinstance(oauth_flow, AsyncOAuthFlow): raise BoltError(error_oauth_flow_invalid_type_async()) diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index f7bc8af7e..24d33b956 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -69,7 +69,8 @@ def warning_client_prioritized_and_token_skipped() -> str: def warning_token_skipped() -> str: return ( - "As you gave `installation_store`/`authorize` as well, `token` will be unused." + "As either `installation_store` or `authorize` is enabled, " + "`token` (or SLACK_BOT_TOKEN env variable) will be unused." ) @@ -104,6 +105,16 @@ def warning_skip_uncommon_arg_name(arg_name: str) -> str: # ------------------------------- +def info_default_oauth_settings_loaded() -> str: + return ( + "As you've set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET env variables, " + "Bolt enabled file-based InstallationStore/OAuthStateStore for you. " + "Note that the file based ones are for local development. " + "If you want to use a different datastore, set oauth_settings argument in App constructor. " + "Refer to https://slack.dev/bolt-python/concepts#authenticating-oauth for more details." + ) + + # ------------------------------- # Debug # ------------------------------- From dafc833e9cbc39edb87528e8e782422e87fefc5b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 10 Mar 2021 09:28:56 +0900 Subject: [PATCH 289/865] Apply suggestions from code review Co-authored-by: Alissa Renz --- slack_bolt/logger/messages.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 24d33b956..57d12dfab 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -69,8 +69,8 @@ def warning_client_prioritized_and_token_skipped() -> str: def warning_token_skipped() -> str: return ( - "As either `installation_store` or `authorize` is enabled, " - "`token` (or SLACK_BOT_TOKEN env variable) will be unused." + "As `installation_store` or `authorize` has been used, " + "`token` (or SLACK_BOT_TOKEN env variable) will be ignored." ) @@ -108,10 +108,10 @@ def warning_skip_uncommon_arg_name(arg_name: str) -> str: def info_default_oauth_settings_loaded() -> str: return ( "As you've set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET env variables, " - "Bolt enabled file-based InstallationStore/OAuthStateStore for you. " - "Note that the file based ones are for local development. " - "If you want to use a different datastore, set oauth_settings argument in App constructor. " - "Refer to https://slack.dev/bolt-python/concepts#authenticating-oauth for more details." + "Bolt has enabled the file-based InstallationStore/OAuthStateStore for you. " + "Note that these file-based stores are for local development. " + "If you'd like to use a different data store, set the oauth_settings argument in the App constructor. " + "Please refer to https://slack.dev/bolt-python/concepts#authenticating-oauth for more details." ) From de2ee3ea1305051795c3f2ebc72a0bba8471034d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 19 Mar 2021 21:44:03 +0900 Subject: [PATCH 290/865] Fix #261 SocketModeHandler#start() does not terminate on Windows --- slack_bolt/adapter/socket_mode/base_handler.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/slack_bolt/adapter/socket_mode/base_handler.py b/slack_bolt/adapter/socket_mode/base_handler.py index 99568754e..d64c20c69 100644 --- a/slack_bolt/adapter/socket_mode/base_handler.py +++ b/slack_bolt/adapter/socket_mode/base_handler.py @@ -1,4 +1,6 @@ import logging +import signal +import sys from threading import Event from slack_sdk.socket_mode.client import BaseSocketModeClient @@ -30,4 +32,10 @@ def start(self): print(get_boot_message()) else: self.app.logger.info(get_boot_message()) + + if sys.platform == "win32": + # Ctrl+C etc does not work on Windows OS + # see https://bugs.python.org/issue35935 for details + signal.signal(signal.SIGINT, signal.SIG_DFL) + Event().wait() From 1282934e96e41ef73da34ce0d6786314681282f5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 22 Mar 2021 15:23:46 +0900 Subject: [PATCH 291/865] version 1.4.4 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index aa56ed404..c0f285b05 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.4.3" +__version__ = "1.4.4" From 66aad210faeed75c04941dfb635a4556952a8868 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 24 Mar 2021 21:22:33 +0900 Subject: [PATCH 292/865] Update docstring and add the api-docs generator script --- .github/maintainers_guide.md | 6 + docs/_includes/sidebar.html | 3 + scripts/generate_api_docs.sh | 10 + slack_bolt/__init__.py | 7 + slack_bolt/adapter/__init__.py | 2 + slack_bolt/app/__init__.py | 7 + slack_bolt/app/app.py | 406 +++++++++++++---- slack_bolt/app/async_app.py | 408 ++++++++++++++---- slack_bolt/app/async_server.py | 14 +- slack_bolt/async_app.py | 46 ++ slack_bolt/authorization/__init__.py | 5 + .../authorization/async_authorize_args.py | 11 +- slack_bolt/authorization/authorize_args.py | 11 +- slack_bolt/authorization/authorize_result.py | 20 +- slack_bolt/context/__init__.py | 7 + slack_bolt/context/async_context.py | 68 +++ slack_bolt/context/base_context.py | 15 + slack_bolt/context/context.py | 68 +++ slack_bolt/error/__init__.py | 1 + slack_bolt/kwargs_injection/__init__.py | 6 + slack_bolt/kwargs_injection/args.py | 36 ++ slack_bolt/kwargs_injection/async_args.py | 36 ++ slack_bolt/lazy_listener/__init__.py | 23 + slack_bolt/lazy_listener/async_runner.py | 12 +- slack_bolt/lazy_listener/runner.py | 14 +- slack_bolt/listener/__init__.py | 5 + slack_bolt/listener/async_listener.py | 18 +- .../listener/async_listener_error_handler.py | 8 +- slack_bolt/listener/listener.py | 20 +- slack_bolt/listener/listener_error_handler.py | 8 +- slack_bolt/listener_matcher/__init__.py | 4 + .../async_listener_matcher.py | 9 +- .../listener_matcher/listener_matcher.py | 9 +- slack_bolt/logger/__init__.py | 2 + slack_bolt/middleware/__init__.py | 7 + slack_bolt/middleware/async_middleware.py | 19 + .../async_multi_teams_authorization.py | 3 +- .../multi_teams_authorization.py | 3 +- .../single_team_authorization.py | 3 +- slack_bolt/middleware/middleware.py | 19 + .../async_request_verification.py | 6 + .../request_verification.py | 7 +- slack_bolt/middleware/ssl_check/ssl_check.py | 9 +- slack_bolt/oauth/__init__.py | 5 + slack_bolt/oauth/async_callback_options.py | 22 +- slack_bolt/oauth/async_oauth_flow.py | 7 +- slack_bolt/oauth/async_oauth_settings.py | 37 +- slack_bolt/oauth/callback_options.py | 27 +- slack_bolt/oauth/oauth_flow.py | 7 +- slack_bolt/oauth/oauth_settings.py | 37 +- slack_bolt/request/__init__.py | 5 + slack_bolt/request/async_request.py | 11 +- slack_bolt/request/request.py | 11 +- slack_bolt/response/__init__.py | 8 + slack_bolt/response/response.py | 7 +- slack_bolt/util/__init__.py | 1 + slack_bolt/util/utils.py | 7 +- slack_bolt/version.py | 1 + slack_bolt/workflows/__init__.py | 10 + slack_bolt/workflows/step/async_step.py | 127 ++++-- .../workflows/step/async_step_middleware.py | 1 + slack_bolt/workflows/step/internals.py | 8 +- slack_bolt/workflows/step/step.py | 127 ++++-- slack_bolt/workflows/step/step_middleware.py | 1 + .../workflows/step/utilities/__init__.py | 19 + .../step/utilities/async_complete.py | 22 + .../step/utilities/async_configure.py | 29 ++ .../workflows/step/utilities/async_fail.py | 19 + .../workflows/step/utilities/async_update.py | 38 ++ .../workflows/step/utilities/complete.py | 22 + .../workflows/step/utilities/configure.py | 29 ++ slack_bolt/workflows/step/utilities/fail.py | 19 + slack_bolt/workflows/step/utilities/update.py | 38 ++ 73 files changed, 1719 insertions(+), 384 deletions(-) create mode 100755 scripts/generate_api_docs.sh diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index 6839007d8..550480f74 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -109,6 +109,12 @@ $ ngrok http 3000 --subdomain {your-domain} ### Releasing +#### Generate API documents + +```bash +./scripts/generate_api_docs.sh +``` + #### test.pypi.org deployment ##### $HOME/.pypirc diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html index ecadae7b0..4343ff5c3 100644 --- a/docs/_includes/sidebar.html +++ b/docs/_includes/sidebar.html @@ -23,6 +23,9 @@
  • {{ site.t[page.lang].start }}
  • + +
  • API Documents
  • +

    kuHci1T>%df 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ÁL-1d-(Q|-QVD5f?wX!S;g7ykz;DfF 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�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 literal 0 HcmV?d00001 diff --git a/docs/assets/style.css b/docs/assets/style.css new file mode 100644 index 000000000..07390ce24 --- /dev/null +++ b/docs/assets/style.css @@ -0,0 +1,426 @@ +/* Color variables */ +:root { + --light-grey: #F8F8F8; + --grey: #868686; + --dark-grey: #616061; + --soft-grey: #DDDDDD; + --blue: #1264A3; + --green: #00B073; + --light-blue: #B8D1E3; + --white: #FFFFFF; + --black: #1D1C1D; +} + +body { + background-color: var(--white); + font-family: 'Noto Sans JP', 'Slack-Lato', sans-serif; +} + +.content { + grid-area: content; +} + +span.beta { + background-color: #E8F5FA; + color: #1264A3; + padding: 4px 9px; + margin-right: 2px; + border-radius: 16px; + border: 1px solid #D4ECF6; + text-transform: uppercase; + font-weight: 600; + font-size: 0.7em; +} + +/* Sidebar */ +.panel { + position: fixed; + width: 20%; + height: 100%; + overflow: auto; + top: 0; + left: 0; + background-color: var(--light-grey); +} + +.panel .sidebar-content { + width: 75%; + margin: 30px auto 20px auto; +} + +.panel .sidebar-content .logo { + padding-top: 1em; + position: relative; +} + +.panel .sidebar-content .logo .icon img { + width: 30px; + margin-right: 6px; +} + +.panel .sidebar-content .logo .name { + font-weight: 800; + font-size: 1.7em; + vertical-align: bottom; +} + +.panel .sidebar-content .logo .version { + line-height: 1em; + vertical-align: bottom; +} + +.panel .sidebar-content .logo .version a { + color: var(--dark-grey); + background-color: var(--soft-grey); + font-size: 0.5em; + font-weight: 800; + padding: 4px 10px; + border-radius: 12px; + margin-left: 10px; + +} + +.panel .sidebar-content ul.sidebar-section { + list-style: none; + list-style-position: inside; + padding-top: 0.9em; + margin: 0 0 0 -8px; + font-size: 0.80em; +} + +.panel .sidebar-content ul.sidebar-section li { + border-radius: 8px; + padding: 2px 0 2px 8px; + margin: 3px 0; + color: var(--black); +} + +.panel .sidebar-content ul.sidebar-section li:hover { + background-color: #D7D7D7; +} + +.panel .sidebar-content ul.sidebar-section li.madeby:hover { + background-color: transparent; +} + +.panel .sidebar-content a:hover { + text-decoration: none; +} + +.panel .sidebar-content ul.sidebar-section li.active { + background-color: var(--blue); + color: var(--white); +} + +.panel .sidebar-content ul.sidebar-section li.title { + font-weight: 600; +} + +/* Main page */ +.header { + width: 95%; + margin: 0 auto 1em auto; + height: 5rem; + padding-top: 1.5em; +} + +.header a:hover { + text-decoration: none; +} + +.header a.language-switcher { + color: var(--grey); + font-weight: 700; + padding: 6px 14px 9px; + font-size: 15px; +} + +.header a.language-switcher:hover { + color: var(--black); +} + +.wrapper { + width: 100%; + margin: 0 auto; +} + +/* Main page content */ +.section-wrapper { + width: 90%; + margin: 0 auto 30px auto; + display: grid; + grid-gap: 25px; + grid-template-areas: + "head" + "body" + "code" + "secondary" + "divider" +} + +.tutorial-nav { + width: 20%; + position: fixed; +} + +.tutorial-nav ul { + margin-left: 3em; + padding-left: 1em; + border-width: 4px; + border-left-style: solid; + border-color: #F2F2F2; + border-image: linear-gradient( + to bottom, + #FFFFFF 0%, + #F2F2F2 6% 92%, + #FFFFFF 100% + ) 1 100%; + list-style: none; + padding-top: 1.5em; +} + +.circle { + background: #ddd; + border-radius: 50%; + height: 1em; + width: 1em; + float: left; + margin: 5px 0 0 -1.6em; +} + +.completed { + background: #58AF7F; +} + +.tutorial-nav ul li { + padding-bottom: 2.5em; +} + +.tutorial-nav a { + font-weight: 700; + font-size: 0.8em; + color: #757575; +} + +.tutorial-nav a:hover { + color: #000; + text-decoration: none; +} + +.tutorial { + width: 55%; + margin: 1em 0 0 33%; + padding-bottom: 2em; + font-size: 1em; + line-height: 1.75em; +} + +.tutorial img { + width: 85%; + margin: 0.2em auto; + display: block; + box-shadow: 0 0 15px #DDDDDD; +} + +.tutorial blockquote { + margin: 0 0 0 1em; + padding: 0 6em 0 1.5em; + border-radius: 6px; + border-left: 6px solid #DDD; + font-size: 0.95em; +} + +.tutorial h3 { + padding-bottom: 1em; +} + +.content .section-wrapper .language-javascript { + grid-area: code; +} + +pre { + background-color: var(--light-grey) !important; + background-image: none; + padding: 2em; + border: 1px solid #DDDDDD; +} + +pre code { + font-size: 0.85em !important; +} + +.content .section-wrapper .section-content { + grid-area: body; + font-size: 1em; + line-height: 2em; +} + +.content .section-wrapper h3 { + grid-area: head; + font-size: 1.4em; + font-weight: 600; +} + +.content .section-wrapper hr { + grid-area: divider; + height: 1px; + border-top: 1px solid #DDD; + width: 100%; +} + +a:hover { + text-decoration: underline; +} + +/* Secondary content */ +.secondary-wrapper { + width: 100%; + grid-area: secondary; + margin: 1em auto 0 auto; + font-size: 1em; + line-height: 1.75em; +} + +.secondary-wrapper .language-javascript { + width: 50%; + float: left; + margin-top: 1em; +} + +.content .section-wrapper .secondary-content { + width: 45%; + float: left; + margin-right: 5%; + margin-top: 1em; +} + +summary h4 { + display: inline; +} + +/* Responsive */ +@media (min-width: 1024px) { + .tutorial-nav ul { + margin-left: 5em; + } +} + +@media (min-width: 768px) { + .wrapper { + display: grid; + grid-template-columns: 20% 75%; + grid-template-areas: + "sidebar content" + } + + .section-wrapper { + grid-template-columns: 50% 50%; + grid-template-areas: + "head head" + "body code" + "secondary secondary" + "divider divider" + } +} + +@media (max-width: 768px) { + .panel { + display: none; + } + + .language-switcher { + display: none; + } + + .tutorial-nav { + display: none; + } + + .tutorial { + width: 85%; + margin: 1em auto; + } + + .wrapper { + display: grid; + grid-template-columns: 100%; + grid-template-areas: + "content" + } + + .section-wrapper { + grid-template-columns: 100%; + grid-template-areas: + "head" + "body" + "code" + "secondary" + "divider" + } +} + + +/* + * Github theme stylesheet from: http://jwarby.github.io/jekyll-pygments-themes/languages/javascript.html + */ + .highlight .hll { background-color: #ffffcc } + .highlight .c { color: #999988; } /* Comment */ + .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ + .highlight .k { color: #000000; font-weight: bold } /* Keyword */ + .highlight .o { color: #000000; font-weight: bold } /* Operator */ + .highlight .cm { color: #999988; } /* Comment.Multiline */ + .highlight .cp { color: #999999; font-weight: bold; } /* Comment.Preproc */ + .highlight .c1 { color: #999988; } /* Comment.Single */ + .highlight .cs { color: #999999; font-weight: bold; } /* Comment.Special */ + .highlight .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ + .highlight .ge { color: #000000; font-style: italic } /* Generic.Emph */ + .highlight .gr { color: #aa0000 } /* Generic.Error */ + .highlight .gh { color: #999999 } /* Generic.Heading */ + .highlight .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ + .highlight .go { color: #888888 } /* Generic.Output */ + .highlight .gp { color: #555555 } /* Generic.Prompt */ + .highlight .gs { font-weight: bold } /* Generic.Strong */ + .highlight .gu { color: #aaaaaa } /* Generic.Subheading */ + .highlight .gt { color: #aa0000 } /* Generic.Traceback */ + .highlight .kc { color: #000000; font-weight: bold } /* Keyword.Constant */ + .highlight .kd { color: #000000; font-weight: bold } /* Keyword.Declaration */ + .highlight .kn { color: #000000; font-weight: bold } /* Keyword.Namespace */ + .highlight .kp { color: #000000; font-weight: bold } /* Keyword.Pseudo */ + .highlight .kr { color: #000000; font-weight: bold } /* Keyword.Reserved */ + .highlight .kt { color: #445588; font-weight: bold } /* Keyword.Type */ + .highlight .m { color: #009999 } /* Literal.Number */ + .highlight .s { color: #d01040 } /* Literal.String */ + .highlight .na { color: #008080 } /* Name.Attribute */ + .highlight .nb { color: #0086B3 } /* Name.Builtin */ + .highlight .nc { color: #445588; font-weight: bold } /* Name.Class */ + .highlight .no { color: #008080 } /* Name.Constant */ + .highlight .nd { color: #3c5d5d; font-weight: bold } /* Name.Decorator */ + .highlight .ni { color: #800080 } /* Name.Entity */ + .highlight .ne { color: #990000; font-weight: bold } /* Name.Exception */ + .highlight .nf { color: #990000; font-weight: bold } /* Name.Function */ + .highlight .nl { color: #990000; font-weight: bold } /* Name.Label */ + .highlight .nn { color: #555555 } /* Name.Namespace */ + .highlight .nt { color: #000080 } /* Name.Tag */ + .highlight .nv { color: #008080 } /* Name.Variable */ + .highlight .ow { color: #000000; font-weight: bold } /* Operator.Word */ + .highlight .w { color: #bbbbbb } /* Text.Whitespace */ + .highlight .mf { color: #009999 } /* Literal.Number.Float */ + .highlight .mh { color: #009999 } /* Literal.Number.Hex */ + .highlight .mi { color: #009999 } /* Literal.Number.Integer */ + .highlight .mo { color: #009999 } /* Literal.Number.Oct */ + .highlight .sb { color: #d01040 } /* Literal.String.Backtick */ + .highlight .sc { color: #d01040 } /* Literal.String.Char */ + .highlight .sd { color: #d01040 } /* Literal.String.Doc */ + .highlight .s2 { color: #d01040 } /* Literal.String.Double */ + .highlight .se { color: #d01040 } /* Literal.String.Escape */ + .highlight .sh { color: #d01040 } /* Literal.String.Heredoc */ + .highlight .si { color: #d01040 } /* Literal.String.Interpol */ + .highlight .sx { color: #d01040 } /* Literal.String.Other */ + .highlight .sr { color: #009926 } /* Literal.String.Regex */ + .highlight .s1 { color: #d01040 } /* Literal.String.Single */ + .highlight .ss { color: #990073 } /* Literal.String.Symbol */ + .highlight .bp { color: #999999 } /* Name.Builtin.Pseudo */ + .highlight .vc { color: #008080 } /* Name.Variable.Class */ + .highlight .vg { color: #008080 } /* Name.Variable.Global */ + .highlight .vi { color: #008080 } /* Name.Variable.Instance */ + .highlight .il { color: #009999 } /* Literal.Number.Integer.Long */ \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..7694fecc9 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,7 @@ +--- +permalink: /concepts +redirect_from: + - / +layout: default +lang: en +--- diff --git a/docs/jp.md b/docs/jp.md new file mode 100644 index 000000000..387668f76 --- /dev/null +++ b/docs/jp.md @@ -0,0 +1,8 @@ +--- +permalink: ja-jp/concepts +redirect_from: + - /jp + - /ja-jp +layout: default +lang: ja-jp +--- diff --git a/docs/scripts/tutorial_nav.js b/docs/scripts/tutorial_nav.js new file mode 100644 index 000000000..8ac9d67ae --- /dev/null +++ b/docs/scripts/tutorial_nav.js @@ -0,0 +1,41 @@ +var navTag = 'h3'; + +window.addEventListener('DOMContentLoaded', (event) => { + var sections = document.querySelectorAll(navTag); + var navParent = document.querySelector('.tutorial-nav-list'); + + function createNavElement(title, href) { + var navElement = document.createElement('li'); + + var navCircle = document.createElement('div'); + navCircle.setAttribute('class', 'circle ' + href); + + var navAnchor = document.createElement('a'); + navAnchor.setAttribute('href', '#' + href); + navAnchor.innerText = title; + + navElement.appendChild(navCircle); + navElement.appendChild(navAnchor); + + return navElement; + } + + sections.forEach(function(navHeader) { + var newElement = createNavElement(navHeader.innerText, navHeader.id); + navParent.appendChild(newElement); + }) +}); + +window.addEventListener('scroll', (event) => { + var sections = document.querySelectorAll(navTag); + + sections.forEach(function(navHeader) { + var navElement = document.querySelector('.' + navHeader.id); + + if (window.scrollY >= (navHeader.getBoundingClientRect().top + window.pageYOffset - 5)) { + navElement.setAttribute('class', 'circle completed ' + navHeader.id); + } else { + navElement.setAttribute('class', 'circle ' + navHeader.id); + } + }) +}); From 3eff6a1a0580d3ab833ea82591d7e571c5cdd328 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Wed, 16 Sep 2020 16:33:28 -0700 Subject: [PATCH 080/865] [#70 #71] Add Getting started guide and sample app * [#70] Add 'Create an app' section * [#70] Add 'Tokens and installing apps' section * [#70] Add 'Setting up your local project' section * [#70] Add 'Setting up events' section * [#70] Add 'Listening and responding to a message' section * [#70] Add 'Sending and responding to actions' section * [#70] Add 'Next steps' section * [#70] Stub a details section for setting up a Python virtual environment * [#70] Update MacOS to macOS to reflect the modern naming convention. - Based on feedback from @seratch - See https://github.com/slackapi/bolt-python/pull/71/files#r485996903 * [#70] Update Python code samples to use 4 spaces. - Thanks @seratch https://github.com/slackapi/bolt-python/pull/71#discussion_r485995994 * [#70] Update wording of a Python package - Thanks @seratch https://github.com/slackapi/bolt-python/pull/71#discussion_r485997353 * [#70] Update 'python' command usage to 'python3' to help all audiences - Thanks @seratch https://github.com/slackapi/bolt-python/pull/71#discussion_r485998864 * [#70] Update responds with "Hey there @user!" for consistency. - Thanks @seratch https://github.com/slackapi/bolt-python/pull/71#discussion_r485999403 * Update 130 to 220 Web API methods - Thanks @seratch https://github.com/slackapi/bolt-python/pull/71#discussion_r486000352 Co-authored-by: Kazuhiro Sera * [#70] Remove completed TODO comments. * [#70] Fix number list to display correctly. * [#70] Adjust "Listening and Responding to a Message" section for clarity - Bring in changes from https://github.com/slackapi/bolt-js/pull/600/files * [#70] Refactor 'reply_to_hello' to 'message_hello' for consistency * [#70] Remove details section for setting up a Python virtual environment * [#70] Update the virtual environment directory name. The official Python 3 tutorial recommends using .venv as the directory name of a virtual environment because it's hidden by many systems/editors and doesn't conflict with some system conventions of .env https://docs.python.org/3/tutorial/venv.html#creating-virtual-environments * [#70] Emphasize that python3 will be inside your virtual environment project * [#70] Add getting started app to samples * [#70] Remove completed TODO comments. * [#70] Update event scopes to be a list * [#70] Remove completed TODOs * [#70] Mention that Python 3.6 or later is required for 'slack_bolt' * [#70] Remove TODO for argument order best practice. - We decided to match the bolt-js docs as a first step - See https://github.com/slackapi/bolt-python/pull/71#discussion_r488212003 - Thanks, @misscoded! * [#70] .gitignore env*/ in the getting_started sample app See https://github.com/slackapi/bolt-python/pull/71#discussion_r488262529 * [#70] Update message.im scope description - See https://github.com/slackapi/bolt-python/pull/71#discussion_r488225981 * [#70] Update wording of 'dialogs' to 'modals' - See https://github.com/slackapi/bolt-python/pull/71#discussion_r488226546 Co-authored-by: Shay DeWael * [#70] Update wording of interactivity setup - See https://github.com/slackapi/bolt-python/pull/71#discussion_r488227157 Co-authored-by: Shay DeWael * [#70] Update wording for modifying the code base - See https://github.com/slackapi/bolt-python/pull/71#discussion_r488228132 Co-authored-by: Shay DeWael * [#70] Update explaination of say() to align with bolt-js and clarify text role - See https://github.com/slackapi/bolt-python/pull/71#discussion_r488229309 - See https://github.com/slackapi/bolt-python/pull/71#discussion_r488248912 * [#70] Link to Getting Started app at beginning and end of guide. - See https://github.com/slackapi/bolt-python/pull/71#discussion_r488236265 * [#70] Update link to Block Kit Builder - See https://github.com/slackapi/bolt-python/pull/71#discussion_r489240762 * [#70] Update action_id explaination to be clearer. - See https://github.com/slackapi/bolt-python/pull/71#discussion_r488230539 Co-authored-by: Kazuhiro Sera Co-authored-by: Shay DeWael --- docs/_tutorials/getting_started.md | 319 ++++++++++++++++++++++- samples/getting_started/.gitignore | 3 + samples/getting_started/README.md | 47 ++++ samples/getting_started/app.py | 43 +++ samples/getting_started/requirements.txt | 1 + 5 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 samples/getting_started/.gitignore create mode 100644 samples/getting_started/README.md create mode 100644 samples/getting_started/app.py create mode 100644 samples/getting_started/requirements.txt diff --git a/docs/_tutorials/getting_started.md b/docs/_tutorials/getting_started.md index d3ff59bc1..c64ace770 100644 --- a/docs/_tutorials/getting_started.md +++ b/docs/_tutorials/getting_started.md @@ -12,4 +12,321 @@ redirect_from:

    -

    Methods

    -
    -
    -async def handle(self, client: slack_sdk.socket_mode.aiohttp.SocketModeClient, req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> NoneType -
    -
    -
    -
    - -Expand source code - -
    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)
    -
    -
    -
    +

    Inherited members

    +
    class SocketModeHandler (app: App, app_token: Optional[str] = None, logger: Optional[logging.Logger] = None, web_client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, proxy: Optional[str] = None, ping_interval: float = 10)
    -
    +

    Socket Mode adapter for Bolt apps

    +

    Args

    +
    +
    app
    +
    The Bolt app
    +
    app_token
    +
    App-level token starting with xapp-
    +
    logger
    +
    Custom logger
    +
    web_client
    +
    custom slack_sdk.web.WebClient instance
    +
    proxy
    +
    HTTP proxy URL
    +
    ping_interval
    +
    The ping-pong internal (seconds)
    +
    Expand source code @@ -219,6 +240,16 @@

    Methods

    proxy: Optional[str] = None, ping_interval: float = 10, ): + """Socket Mode adapter for Bolt apps + + Args: + app: The Bolt app + app_token: App-level token starting with `xapp-` + logger: Custom logger + web_client: custom `slack_sdk.web.WebClient` instance + proxy: HTTP proxy URL + ping_interval: The ping-pong internal (seconds) + """ self.app = app self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] self.client = SocketModeClient( @@ -254,24 +285,18 @@

    Class variables

    -

    Methods

    -
    -
    -async def handle(self, client: slack_sdk.socket_mode.aiohttp.SocketModeClient, req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> NoneType -
    -
    -
    -
    - -Expand source code - -
    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)
    -
    -
    -
    +

    Inherited members

    + @@ -295,7 +320,6 @@

    app
  • app_token
  • client
  • -
  • handle
  • @@ -304,7 +328,6 @@

    app

  • app_token
  • client
  • -
  • handle
  • diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/async_base_handler.html b/docs/api-docs/slack_bolt/adapter/socket_mode/async_base_handler.html index 532ac480a..c9f7c4b53 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/async_base_handler.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/async_base_handler.html @@ -5,7 +5,7 @@ slack_bolt.adapter.socket_mode.async_base_handler API documentation - + @@ -22,11 +22,13 @@

    Module slack_bolt.adapter.socket_mode.async_base_handler

    +

    The base class of asyncio-based Socket Mode client implementation

    Expand source code -
    import asyncio
    +
    """The base class of asyncio-based Socket Mode client implementation"""
    +import asyncio
     import logging
     from typing import Union
     
    @@ -45,18 +47,31 @@ 

    Module slack_bolt.adapter.socket_mode.async_base_handler async def handle( self, client: AsyncBaseSocketModeClient, req: SocketModeRequest ) -> None: + """Handles Socket Mode envelope requests through a WebSocket connection. + + Args: + client: this Socket Mode client instance + req: the request data + """ raise NotImplementedError() async def connect_async(self): + """Establishes a new connection with the Socket Mode server""" await self.client.connect() async def disconnect_async(self): + """Disconnects the current WebSocket connection with the Socket Mode server""" await self.client.disconnect() async def close_async(self): + """Disconnects from the Socket Mode server and cleans the resources this instance holds up""" await self.client.close() async def start_async(self): + """Establishes a new connection and then starts infinite sleep + to prevent the termination of this process. + If you don't want to have the sleep, use `#connect()` method instead. + """ await self.connect_async() if self.app.logger.level > logging.INFO: print(get_boot_message()) @@ -90,18 +105,31 @@

    Classes

    async def handle( self, client: AsyncBaseSocketModeClient, req: SocketModeRequest ) -> None: + """Handles Socket Mode envelope requests through a WebSocket connection. + + Args: + client: this Socket Mode client instance + req: the request data + """ raise NotImplementedError() async def connect_async(self): + """Establishes a new connection with the Socket Mode server""" await self.client.connect() async def disconnect_async(self): + """Disconnects the current WebSocket connection with the Socket Mode server""" await self.client.disconnect() async def close_async(self): + """Disconnects from the Socket Mode server and cleans the resources this instance holds up""" await self.client.close() async def start_async(self): + """Establishes a new connection and then starts infinite sleep + to prevent the termination of this process. + If you don't want to have the sleep, use `#connect()` method instead. + """ await self.connect_async() if self.app.logger.level > logging.INFO: print(get_boot_message()) @@ -133,12 +161,13 @@

    Methods

    async def close_async(self)
    -
    +

    Disconnects from the Socket Mode server and cleans the resources this instance holds up

    Expand source code
    async def close_async(self):
    +    """Disconnects from the Socket Mode server and cleans the resources this instance holds up"""
         await self.client.close()
    @@ -146,12 +175,13 @@

    Methods

    async def connect_async(self)
    -
    +

    Establishes a new connection with the Socket Mode server

    Expand source code
    async def connect_async(self):
    +    """Establishes a new connection with the Socket Mode server"""
         await self.client.connect()
    @@ -159,12 +189,13 @@

    Methods

    async def disconnect_async(self)
    -
    +

    Disconnects the current WebSocket connection with the Socket Mode server

    Expand source code
    async def disconnect_async(self):
    +    """Disconnects the current WebSocket connection with the Socket Mode server"""
         await self.client.disconnect()
    @@ -172,7 +203,14 @@

    Methods

    async def handle(self, client: slack_sdk.socket_mode.async_client.AsyncBaseSocketModeClient, req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> NoneType
    -
    +

    Handles Socket Mode envelope requests through a WebSocket connection.

    +

    Args

    +
    +
    client
    +
    this Socket Mode client instance
    +
    req
    +
    the request data
    +
    Expand source code @@ -180,6 +218,12 @@

    Methods

    async def handle(
         self, client: AsyncBaseSocketModeClient, req: SocketModeRequest
     ) -> None:
    +    """Handles Socket Mode envelope requests through a WebSocket connection.
    +
    +    Args:
    +        client: this Socket Mode client instance
    +        req: the request data
    +    """
         raise NotImplementedError()
    @@ -187,12 +231,18 @@

    Methods

    async def start_async(self)
    -
    +

    Establishes a new connection and then starts infinite sleep +to prevent the termination of this process. +If you don't want to have the sleep, use #connect() method instead.

    Expand source code
    async def start_async(self):
    +    """Establishes a new connection and then starts infinite sleep
    +    to prevent the termination of this process.
    +    If you don't want to have the sleep, use `#connect()` method instead.
    +    """
         await self.connect_async()
         if self.app.logger.level > logging.INFO:
             print(get_boot_message())
    diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/async_handler.html b/docs/api-docs/slack_bolt/adapter/socket_mode/async_handler.html
    index f6be34ed9..1e1ca2ee4 100644
    --- a/docs/api-docs/slack_bolt/adapter/socket_mode/async_handler.html
    +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/async_handler.html
    @@ -5,7 +5,7 @@
     
     
     slack_bolt.adapter.socket_mode.async_handler API documentation
    -
    +
     
     
     
    @@ -22,11 +22,13 @@
     

    Module slack_bolt.adapter.socket_mode.async_handler

    +

    Default implementation is the aiohttp-based one.

    Expand source code -
    from .aiohttp import AsyncSocketModeHandler  # noqa
    +
    """Default implementation is the aiohttp-based one."""
    +from .aiohttp import AsyncSocketModeHandler  # noqa
    diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/async_internals.html b/docs/api-docs/slack_bolt/adapter/socket_mode/async_internals.html index 5d8c62d9b..824288e56 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/async_internals.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/async_internals.html @@ -5,7 +5,7 @@ slack_bolt.adapter.socket_mode.async_internals API documentation - + @@ -22,11 +22,13 @@

    Module slack_bolt.adapter.socket_mode.async_internals

    +

    Internal functions

    Expand source code -
    import json
    +
    """Internal functions"""
    +import json
     import logging
     from time import time
     
    diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/base_handler.html b/docs/api-docs/slack_bolt/adapter/socket_mode/base_handler.html
    index 5d3d5ffe3..92518d180 100644
    --- a/docs/api-docs/slack_bolt/adapter/socket_mode/base_handler.html
    +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/base_handler.html
    @@ -5,7 +5,8 @@
     
     
     slack_bolt.adapter.socket_mode.base_handler API documentation
    -
    +
     
     
     
    @@ -22,11 +23,16 @@
     

    Module slack_bolt.adapter.socket_mode.base_handler

    +

    The base class of Socket Mode client implementation. +If you want to build asyncio-based ones, use AsyncBaseSocketModeHandler instead.

    Expand source code -
    import logging
    +
    """The base class of Socket Mode client implementation.
    +If you want to build asyncio-based ones, use `AsyncBaseSocketModeHandler` instead.
    +"""
    +import logging
     import signal
     import sys
     from threading import Event
    @@ -43,18 +49,31 @@ 

    Module slack_bolt.adapter.socket_mode.base_handlerClasses

    client: BaseSocketModeClient def handle(self, client: BaseSocketModeClient, req: SocketModeRequest) -> None: + """Handles Socket Mode envelope requests through a WebSocket connection. + + Args: + client: this Socket Mode client instance + req: the request data + """ raise NotImplementedError() def connect(self): + """Establishes a new connection with the Socket Mode server""" self.client.connect() def disconnect(self): + """Disconnects the current WebSocket connection with the Socket Mode server""" self.client.disconnect() def close(self): + """Disconnects from the Socket Mode server and cleans the resources this instance holds up""" self.client.close() def start(self): + """Establishes a new connection and then blocks the current thread + to prevent the termination of this process. + If you don't want to block the current thread, use `#connect()` method instead. + """ self.connect() if self.app.logger.level > logging.INFO: print(get_boot_message()) @@ -139,12 +171,13 @@

    Methods

    def close(self)
    -
    +

    Disconnects from the Socket Mode server and cleans the resources this instance holds up

    Expand source code
    def close(self):
    +    """Disconnects from the Socket Mode server and cleans the resources this instance holds up"""
         self.client.close()
    @@ -152,12 +185,13 @@

    Methods

    def connect(self)
    -
    +

    Establishes a new connection with the Socket Mode server

    Expand source code
    def connect(self):
    +    """Establishes a new connection with the Socket Mode server"""
         self.client.connect()
    @@ -165,12 +199,13 @@

    Methods

    def disconnect(self)
    -
    +

    Disconnects the current WebSocket connection with the Socket Mode server

    Expand source code
    def disconnect(self):
    +    """Disconnects the current WebSocket connection with the Socket Mode server"""
         self.client.disconnect()
    @@ -178,12 +213,25 @@

    Methods

    def handle(self, client: slack_sdk.socket_mode.client.BaseSocketModeClient, req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> NoneType
    -
    +

    Handles Socket Mode envelope requests through a WebSocket connection.

    +

    Args

    +
    +
    client
    +
    this Socket Mode client instance
    +
    req
    +
    the request data
    +
    Expand source code
    def handle(self, client: BaseSocketModeClient, req: SocketModeRequest) -> None:
    +    """Handles Socket Mode envelope requests through a WebSocket connection.
    +
    +    Args:
    +        client: this Socket Mode client instance
    +        req: the request data
    +    """
         raise NotImplementedError()
    @@ -191,12 +239,18 @@

    Methods

    def start(self)
    -
    +

    Establishes a new connection and then blocks the current thread +to prevent the termination of this process. +If you don't want to block the current thread, use #connect() method instead.

    Expand source code
    def start(self):
    +    """Establishes a new connection and then blocks the current thread
    +    to prevent the termination of this process.
    +    If you don't want to block the current thread, use `#connect()` method instead.
    +    """
         self.connect()
         if self.app.logger.level > logging.INFO:
             print(get_boot_message())
    diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/builtin/index.html b/docs/api-docs/slack_bolt/adapter/socket_mode/builtin/index.html
    index ef6a4102c..3e050b73d 100644
    --- a/docs/api-docs/slack_bolt/adapter/socket_mode/builtin/index.html
    +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/builtin/index.html
    @@ -5,7 +5,7 @@
     
     
     slack_bolt.adapter.socket_mode.builtin API documentation
    -
    +
     
     
     
    @@ -22,11 +22,13 @@
     

    Module slack_bolt.adapter.socket_mode.builtin

    +

    The built-in implementation, which does not have any external dependencies

    Expand source code -
    import os
    +
    """The built-in implementation, which does not have any external dependencies"""
    +import os
     from logging import Logger
     from time import time
     from typing import Optional, Dict
    @@ -62,6 +64,23 @@ 

    Module slack_bolt.adapter.socket_mode.builtin

    Classes

    (app: App, app_token: Optional[str] = None, logger: Optional[logging.Logger] = None, web_client: Optional[slack_sdk.web.client.WebClient] = None, proxy: Optional[str] = None, proxy_headers: Optional[Dict[str, 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)
    -
    +

    Socket Mode adapter for Bolt apps

    +

    Args

    +
    +
    app
    +
    The Bolt app
    +
    app_token
    +
    App-level token starting with xapp-
    +
    logger
    +
    Custom logger
    +
    web_client
    +
    custom slack_sdk.web.WebClient instance
    +
    proxy
    +
    HTTP proxy URL
    +
    proxy_headers
    +
    Additional request header for proxy connections
    +
    auto_reconnect_enabled
    +
    True if the auto-reconnect logic works
    +
    trace_enabled
    +
    True if trace-level logging is enabled
    +
    all_message_trace_enabled
    +
    True if trace-logging for all received WebSocket messages is enabled
    +
    ping_pong_trace_enabled
    +
    True if trace-logging for all ping-pong communications
    +
    ping_interval
    +
    The ping-pong internal (seconds)
    +
    receive_buffer_size
    +
    The data length for a single socket recv operation
    +
    concurrency
    +
    The size of the underlying thread pool
    +
    Expand source code @@ -126,6 +174,23 @@

    Classes

    receive_buffer_size: int = 1024, concurrency: int = 10, ): + """Socket Mode adapter for Bolt apps + + Args: + app: The Bolt app + app_token: App-level token starting with `xapp-` + logger: Custom logger + web_client: custom `slack_sdk.web.WebClient` instance + proxy: HTTP proxy URL + proxy_headers: Additional request header for proxy connections + auto_reconnect_enabled: True if the auto-reconnect logic works + trace_enabled: True if trace-level logging is enabled + all_message_trace_enabled: True if trace-logging for all received WebSocket messages is enabled + ping_pong_trace_enabled: True if trace-logging for all ping-pong communications + ping_interval: The ping-pong internal (seconds) + receive_buffer_size: The data length for a single socket recv operation + concurrency: The size of the underlying thread pool + """ self.app = app self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] self.client = SocketModeClient( @@ -168,24 +233,18 @@

    Class variables

    -

    Methods

    -
    -
    -def handle(self, client: slack_sdk.socket_mode.builtin.client.SocketModeClient, req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> NoneType -
    -
    -
    -
    - -Expand source code - -
    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)
    -
    -
    -
    +

    Inherited members

    + @@ -209,7 +268,6 @@

    app
  • app_token
  • client
  • -
  • handle
  • diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/index.html b/docs/api-docs/slack_bolt/adapter/socket_mode/index.html index 26b00daff..8ce6feae4 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/index.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/index.html @@ -5,7 +5,7 @@ slack_bolt.adapter.socket_mode API documentation - + @@ -22,11 +22,26 @@

    Module slack_bolt.adapter.socket_mode

    +

    Socket Mode adapter package provides the following implementations. If you don't have strong reasons to use 3rd party library based adapters, we recommend using the built-in client based one.

    +
    Expand source code -
    # Don't add async module imports here
    +
    """Socket Mode adapter package provides the following implementations. If you don't have strong reasons to use 3rd party library based adapters, we recommend using the built-in client based one.
    +
    +* `slack_bolt.adapter.socket_mode.builtin`
    +* `slack_bolt.adapter.socket_mode.websocket_client`
    +* `slack_bolt.adapter.socket_mode.aiohttp`
    +* `slack_bolt.adapter.socket_mode.websockets`
    +"""
    +
    +# Don't add async module imports here
     from .builtin import SocketModeHandler  # noqa
    @@ -35,39 +50,41 @@

    Sub-modules

    slack_bolt.adapter.socket_mode.aiohttp
    -
    +

    aiohttp based implementation / asyncio compatible

    slack_bolt.adapter.socket_mode.async_base_handler
    -
    +

    The base class of asyncio-based Socket Mode client implementation

    slack_bolt.adapter.socket_mode.async_handler
    -
    +

    Default implementation is the aiohttp-based one.

    slack_bolt.adapter.socket_mode.async_internals
    -
    +

    Internal functions

    slack_bolt.adapter.socket_mode.base_handler
    -
    +

    The base class of Socket Mode client implementation. +If you want to build asyncio-based ones, use AsyncBaseSocketModeHandler instead.

    slack_bolt.adapter.socket_mode.builtin
    -
    +

    The built-in implementation, which does not have any external dependencies

    slack_bolt.adapter.socket_mode.internals
    -
    +

    Internal functions

    slack_bolt.adapter.socket_mode.websocket_client
    -
    +

    websocket-client based implementation

    slack_bolt.adapter.socket_mode.websockets
    -
    +

    websockets based implementation +/ asyncio compatible

    diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/internals.html b/docs/api-docs/slack_bolt/adapter/socket_mode/internals.html index 2d14923a7..bf7e9141b 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/internals.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/internals.html @@ -5,7 +5,7 @@ slack_bolt.adapter.socket_mode.internals API documentation - + @@ -22,11 +22,13 @@

    Module slack_bolt.adapter.socket_mode.internals

    +

    Internal functions

    Expand source code -
    import json
    +
    """Internal functions"""
    +import json
     import logging
     from time import time
     
    diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/websocket_client/index.html b/docs/api-docs/slack_bolt/adapter/socket_mode/websocket_client/index.html
    index 12ecdf250..ad409bdef 100644
    --- a/docs/api-docs/slack_bolt/adapter/socket_mode/websocket_client/index.html
    +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/websocket_client/index.html
    @@ -5,7 +5,7 @@
     
     
     slack_bolt.adapter.socket_mode.websocket_client API documentation
    -
    +
     
     
     
    @@ -22,11 +22,13 @@
     

    Module slack_bolt.adapter.socket_mode.websocket_client

    +

    websocket-client based implementation

    Expand source code -
    import os
    +
    """[`websocket-client`](https://pypi.org/project/websocket-client/) based implementation"""
    +import os
     from logging import Logger
     from time import time
     from typing import Optional, Tuple
    @@ -60,6 +62,21 @@ 

    Module slack_bolt.adapter.socket_mode.websocket_clientClasses

    (app: App, app_token: Optional[str] = None, logger: Optional[logging.Logger] = None, web_client: Optional[slack_sdk.web.client.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)
    -
    +

    Socket Mode adapter for Bolt apps

    +

    Args

    +
    +
    app
    +
    The Bolt app
    +
    app_token
    +
    App-level token starting with xapp-
    +
    logger
    +
    Custom logger
    +
    web_client
    +
    custom slack_sdk.web.WebClient instance
    +
    ping_interval
    +
    The ping-pong internal (seconds)
    +
    concurrency
    +
    The size of the underlying thread pool
    +
    http_proxy_host
    +
    HTTP proxy host
    +
    http_proxy_port
    +
    HTTP proxy port
    +
    http_proxy_auth
    +
    HTTP proxy authentication (username, password)
    +
    proxy_type
    +
    Proxy type
    +
    trace_enabled
    +
    True if trace-level logging is enabled
    +
    Expand source code @@ -120,6 +162,21 @@

    Classes

    proxy_type: Optional[str] = None, trace_enabled: bool = False, ): + """Socket Mode adapter for Bolt apps + + Args: + app: The Bolt app + app_token: App-level token starting with `xapp-` + logger: Custom logger + web_client: custom `slack_sdk.web.WebClient` instance + ping_interval: The ping-pong internal (seconds) + concurrency: The size of the underlying thread pool + http_proxy_host: HTTP proxy host + http_proxy_port: HTTP proxy port + http_proxy_auth: HTTP proxy authentication (username, password) + proxy_type: Proxy type + trace_enabled: True if trace-level logging is enabled + """ self.app = app self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] self.client = SocketModeClient( @@ -160,24 +217,18 @@

    Class variables

    -

    Methods

    -
    -
    -def handle(self, client: slack_sdk.socket_mode.websocket_client.SocketModeClient, req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> NoneType -
    -
    -
    -
    - -Expand source code - -
    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)
    -
    -
    -
    +

    Inherited members

    +
    @@ -201,7 +252,6 @@

    app
  • app_token
  • client
  • -
  • handle
  • diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/websockets/index.html b/docs/api-docs/slack_bolt/adapter/socket_mode/websockets/index.html index 96239259c..af0cc2c40 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/websockets/index.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/websockets/index.html @@ -5,7 +5,8 @@ slack_bolt.adapter.socket_mode.websockets API documentation - + @@ -22,11 +23,14 @@

    Module slack_bolt.adapter.socket_mode.websockets

    +

    websockets based implementation +/ asyncio compatible

    Expand source code -
    import os
    +
    """[`websockets`](https://pypi.org/project/websockets/) based implementation  / asyncio compatible"""
    +import os
     from logging import Logger
     from time import time
     from typing import Optional
    @@ -59,6 +63,19 @@ 

    Module slack_bolt.adapter.socket_mode.websockets< web_client: Optional[AsyncWebClient] = None, ping_interval: float = 10, ): + """Socket Mode adapter for Bolt apps. + + Please note that this adapter does not support proxy configuration + as the underlying websockets module does not support proxy-wired connections. + If you use proxy, consider using one of the other Socket Mode adapters. + + Args: + app: The Bolt app + app_token: App-level token starting with `xapp-` + logger: Custom logger + web_client: custom `slack_sdk.web.WebClient` instance + ping_interval: The ping-pong internal (seconds) + """ self.app = app self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] self.client = SocketModeClient( @@ -170,31 +187,41 @@

    Class variables

    -

    Methods

    -
    -
    -async def handle(self, client: slack_sdk.socket_mode.websockets.SocketModeClient, req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> NoneType -
    -
    -
    -
    - -Expand source code - -
    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)
    -
    -
    -
    +

    Inherited members

    +
    class SocketModeHandler (app: App, app_token: Optional[str] = None, logger: Optional[logging.Logger] = None, web_client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, ping_interval: float = 10)
    -
    +

    Socket Mode adapter for Bolt apps.

    +

    Please note that this adapter does not support proxy configuration +as the underlying websockets module does not support proxy-wired connections. +If you use proxy, consider using one of the other Socket Mode adapters.

    +

    Args

    +
    +
    app
    +
    The Bolt app
    +
    app_token
    +
    App-level token starting with xapp-
    +
    logger
    +
    Custom logger
    +
    web_client
    +
    custom slack_sdk.web.WebClient instance
    +
    ping_interval
    +
    The ping-pong internal (seconds)
    +
    Expand source code @@ -212,6 +239,19 @@

    Methods

    web_client: Optional[AsyncWebClient] = None, ping_interval: float = 10, ): + """Socket Mode adapter for Bolt apps. + + Please note that this adapter does not support proxy configuration + as the underlying websockets module does not support proxy-wired connections. + If you use proxy, consider using one of the other Socket Mode adapters. + + Args: + app: The Bolt app + app_token: App-level token starting with `xapp-` + logger: Custom logger + web_client: custom `slack_sdk.web.WebClient` instance + ping_interval: The ping-pong internal (seconds) + """ self.app = app self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] self.client = SocketModeClient( @@ -246,24 +286,18 @@

    Class variables

    -

    Methods

    -
    -
    -async def handle(self, client: slack_sdk.socket_mode.websockets.SocketModeClient, req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> NoneType -
    -
    -
    -
    - -Expand source code - -
    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)
    -
    -
    -
    +

    Inherited members

    +
    @@ -287,7 +321,6 @@

    app
  • app_token
  • client
  • -
  • handle
  • @@ -296,7 +329,6 @@

    app

  • app_token
  • client
  • -
  • handle
  • diff --git a/slack_bolt/adapter/socket_mode/__init__.py b/slack_bolt/adapter/socket_mode/__init__.py index 94223be4d..fbb17b0f7 100644 --- a/slack_bolt/adapter/socket_mode/__init__.py +++ b/slack_bolt/adapter/socket_mode/__init__.py @@ -1,2 +1,10 @@ +"""Socket Mode adapter package provides the following implementations. If you don't have strong reasons to use 3rd party library based adapters, we recommend using the built-in client based one. + +* `slack_bolt.adapter.socket_mode.builtin` +* `slack_bolt.adapter.socket_mode.websocket_client` +* `slack_bolt.adapter.socket_mode.aiohttp` +* `slack_bolt.adapter.socket_mode.websockets` +""" + # 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 index 0e3038c75..6e47fb2ef 100644 --- a/slack_bolt/adapter/socket_mode/aiohttp/__init__.py +++ b/slack_bolt/adapter/socket_mode/aiohttp/__init__.py @@ -1,3 +1,4 @@ +"""[`aiohttp`](https://pypi.org/project/aiohttp/) based implementation / asyncio compatible""" import os from logging import Logger from time import time @@ -32,6 +33,16 @@ def __init__( # type: ignore proxy: Optional[str] = None, ping_interval: float = 10, ): + """Socket Mode adapter for Bolt apps + + Args: + app: The Bolt app + app_token: App-level token starting with `xapp-` + logger: Custom logger + web_client: custom `slack_sdk.web.WebClient` instance + proxy: HTTP proxy URL + ping_interval: The ping-pong internal (seconds) + """ self.app = app self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] self.client = SocketModeClient( diff --git a/slack_bolt/adapter/socket_mode/async_base_handler.py b/slack_bolt/adapter/socket_mode/async_base_handler.py index 836074fad..b4e21b7c5 100644 --- a/slack_bolt/adapter/socket_mode/async_base_handler.py +++ b/slack_bolt/adapter/socket_mode/async_base_handler.py @@ -1,3 +1,4 @@ +"""The base class of asyncio-based Socket Mode client implementation""" import asyncio import logging from typing import Union @@ -17,18 +18,31 @@ class AsyncBaseSocketModeHandler: async def handle( self, client: AsyncBaseSocketModeClient, req: SocketModeRequest ) -> None: + """Handles Socket Mode envelope requests through a WebSocket connection. + + Args: + client: this Socket Mode client instance + req: the request data + """ raise NotImplementedError() async def connect_async(self): + """Establishes a new connection with the Socket Mode server""" await self.client.connect() async def disconnect_async(self): + """Disconnects the current WebSocket connection with the Socket Mode server""" await self.client.disconnect() async def close_async(self): + """Disconnects from the Socket Mode server and cleans the resources this instance holds up""" await self.client.close() async def start_async(self): + """Establishes a new connection and then starts infinite sleep + to prevent the termination of this process. + If you don't want to have the sleep, use `#connect()` method instead. + """ await self.connect_async() if self.app.logger.level > logging.INFO: print(get_boot_message()) diff --git a/slack_bolt/adapter/socket_mode/async_handler.py b/slack_bolt/adapter/socket_mode/async_handler.py index 08edd8004..9c6e21ff0 100644 --- a/slack_bolt/adapter/socket_mode/async_handler.py +++ b/slack_bolt/adapter/socket_mode/async_handler.py @@ -1 +1,2 @@ +"""Default implementation is the aiohttp-based one.""" 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 index 5b5750bd1..58a53902d 100644 --- a/slack_bolt/adapter/socket_mode/async_internals.py +++ b/slack_bolt/adapter/socket_mode/async_internals.py @@ -1,3 +1,4 @@ +"""Internal functions""" import json import logging from time import time diff --git a/slack_bolt/adapter/socket_mode/base_handler.py b/slack_bolt/adapter/socket_mode/base_handler.py index d64c20c69..dfa1fd750 100644 --- a/slack_bolt/adapter/socket_mode/base_handler.py +++ b/slack_bolt/adapter/socket_mode/base_handler.py @@ -1,3 +1,6 @@ +"""The base class of Socket Mode client implementation. +If you want to build asyncio-based ones, use `AsyncBaseSocketModeHandler` instead. +""" import logging import signal import sys @@ -15,18 +18,31 @@ class BaseSocketModeHandler: client: BaseSocketModeClient def handle(self, client: BaseSocketModeClient, req: SocketModeRequest) -> None: + """Handles Socket Mode envelope requests through a WebSocket connection. + + Args: + client: this Socket Mode client instance + req: the request data + """ raise NotImplementedError() def connect(self): + """Establishes a new connection with the Socket Mode server""" self.client.connect() def disconnect(self): + """Disconnects the current WebSocket connection with the Socket Mode server""" self.client.disconnect() def close(self): + """Disconnects from the Socket Mode server and cleans the resources this instance holds up""" self.client.close() def start(self): + """Establishes a new connection and then blocks the current thread + to prevent the termination of this process. + If you don't want to block the current thread, use `#connect()` method instead. + """ self.connect() if self.app.logger.level > logging.INFO: print(get_boot_message()) diff --git a/slack_bolt/adapter/socket_mode/builtin/__init__.py b/slack_bolt/adapter/socket_mode/builtin/__init__.py index f857098ba..664b68ef9 100644 --- a/slack_bolt/adapter/socket_mode/builtin/__init__.py +++ b/slack_bolt/adapter/socket_mode/builtin/__init__.py @@ -1,3 +1,4 @@ +"""The built-in implementation, which does not have any external dependencies""" import os from logging import Logger from time import time @@ -34,6 +35,23 @@ def __init__( # type: ignore receive_buffer_size: int = 1024, concurrency: int = 10, ): + """Socket Mode adapter for Bolt apps + + Args: + app: The Bolt app + app_token: App-level token starting with `xapp-` + logger: Custom logger + web_client: custom `slack_sdk.web.WebClient` instance + proxy: HTTP proxy URL + proxy_headers: Additional request header for proxy connections + auto_reconnect_enabled: True if the auto-reconnect logic works + trace_enabled: True if trace-level logging is enabled + all_message_trace_enabled: True if trace-logging for all received WebSocket messages is enabled + ping_pong_trace_enabled: True if trace-logging for all ping-pong communications + ping_interval: The ping-pong internal (seconds) + receive_buffer_size: The data length for a single socket recv operation + concurrency: The size of the underlying thread pool + """ self.app = app self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] self.client = SocketModeClient( diff --git a/slack_bolt/adapter/socket_mode/internals.py b/slack_bolt/adapter/socket_mode/internals.py index 4f7f4d68c..2274c449c 100644 --- a/slack_bolt/adapter/socket_mode/internals.py +++ b/slack_bolt/adapter/socket_mode/internals.py @@ -1,3 +1,4 @@ +"""Internal functions""" import json import logging from time import time diff --git a/slack_bolt/adapter/socket_mode/websocket_client/__init__.py b/slack_bolt/adapter/socket_mode/websocket_client/__init__.py index 589edb80d..216b6d207 100644 --- a/slack_bolt/adapter/socket_mode/websocket_client/__init__.py +++ b/slack_bolt/adapter/socket_mode/websocket_client/__init__.py @@ -1,3 +1,4 @@ +"""[`websocket-client`](https://pypi.org/project/websocket-client/) based implementation""" import os from logging import Logger from time import time @@ -32,6 +33,21 @@ def __init__( # type: ignore proxy_type: Optional[str] = None, trace_enabled: bool = False, ): + """Socket Mode adapter for Bolt apps + + Args: + app: The Bolt app + app_token: App-level token starting with `xapp-` + logger: Custom logger + web_client: custom `slack_sdk.web.WebClient` instance + ping_interval: The ping-pong internal (seconds) + concurrency: The size of the underlying thread pool + http_proxy_host: HTTP proxy host + http_proxy_port: HTTP proxy port + http_proxy_auth: HTTP proxy authentication (username, password) + proxy_type: Proxy type + trace_enabled: True if trace-level logging is enabled + """ self.app = app self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] self.client = SocketModeClient( diff --git a/slack_bolt/adapter/socket_mode/websockets/__init__.py b/slack_bolt/adapter/socket_mode/websockets/__init__.py index 878eaf5e6..424f97f3d 100644 --- a/slack_bolt/adapter/socket_mode/websockets/__init__.py +++ b/slack_bolt/adapter/socket_mode/websockets/__init__.py @@ -1,3 +1,4 @@ +"""[`websockets`](https://pypi.org/project/websockets/) based implementation / asyncio compatible""" import os from logging import Logger from time import time @@ -31,6 +32,19 @@ def __init__( # type: ignore web_client: Optional[AsyncWebClient] = None, ping_interval: float = 10, ): + """Socket Mode adapter for Bolt apps. + + Please note that this adapter does not support proxy configuration + as the underlying websockets module does not support proxy-wired connections. + If you use proxy, consider using one of the other Socket Mode adapters. + + Args: + app: The Bolt app + app_token: App-level token starting with `xapp-` + logger: Custom logger + web_client: custom `slack_sdk.web.WebClient` instance + ping_interval: The ping-pong internal (seconds) + """ self.app = app self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] self.client = SocketModeClient( From cddcc9542f3c9d173725b6cdad50351dec4c3ab1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 29 Mar 2021 19:57:55 +0900 Subject: [PATCH 295/865] Fix errors in documents --- docs/_basic/listening_actions.md | 2 +- docs/_basic/listening_events.md | 4 ++-- docs/_basic/listening_responding_shortcuts.md | 6 ++---- docs/_basic/responding_actions.md | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/_basic/listening_actions.md b/docs/_basic/listening_actions.md index 131b48701..3de71d003 100644 --- a/docs/_basic/listening_actions.md +++ b/docs/_basic/listening_actions.md @@ -15,7 +15,7 @@ You’ll notice in all `action()` examples, `ack()` is used. It is required to c ```python -# Your middleware will be called every time an interactive component with the action_id "approve_button" is triggered +# Your listener will be called every time a block element with the action_id "approve_button" is triggered @app.action("approve_button") def update_message(ack): ack() diff --git a/docs/_basic/listening_events.md b/docs/_basic/listening_events.md index a8779d826..f3136b18e 100644 --- a/docs/_basic/listening_events.md +++ b/docs/_basic/listening_events.md @@ -14,7 +14,7 @@ The `event()` method requires an `eventType` of type `str`. ```python -# When a user joins the team, send a message in a predefined channel asking them to introduce themselves +# When a user joins the workspace, send a message in a predefined channel asking them to introduce themselves @app.event("team_join") def ask_for_introduction(event, say): welcome_channel_id = "C12345" @@ -37,7 +37,7 @@ You can filter on subtypes of events by passing in the additional key `subtype`. ```python -# Matches all messages from bot users +# Matches all modified messages @app.event({ "type": "message", "subtype": "message_changed" diff --git a/docs/_basic/listening_responding_shortcuts.md b/docs/_basic/listening_responding_shortcuts.md index 0a73d6de7..1e23ddb69 100644 --- a/docs/_basic/listening_responding_shortcuts.md +++ b/docs/_basic/listening_responding_shortcuts.md @@ -22,13 +22,12 @@ When setting up shortcuts within your app configuration, as with other URLs, you ```python - # The open_modal shortcut listens to a shortcut with the callback_id "open_modal" @app.shortcut("open_modal") def open_modal(ack, shortcut, client): # Acknowledge the shortcut request ack() - # Call the views_open method using one of the built-in WebClients + # Call the views_open method using the built-in WebClient client.views_open( trigger_id=shortcut["trigger_id"], # A simple view payload for a modal @@ -68,8 +67,7 @@ def open_modal(ack, shortcut, client): ```python - -# Your middleware will only be called when the callback_id matches 'open_modal' AND the type matches 'message_action' +# Your listener will only be called when the callback_id matches 'open_modal' AND the type matches 'message_action' @app.shortcut({"callback_id": "open_modal", "type": "message_action"}) def open_modal(ack, shortcut, client): # Acknowledge the shortcut request diff --git a/docs/_basic/responding_actions.md b/docs/_basic/responding_actions.md index e8dbbc301..8f432e19b 100644 --- a/docs/_basic/responding_actions.md +++ b/docs/_basic/responding_actions.md @@ -14,7 +14,7 @@ The second way to respond to actions is using `respond()`, which is a utility to ```python -# Your middleware will be called every time an interactive component with the action_id “approve_button” is triggered +# Your listener will be called every time an interactive component with the action_id “approve_button” is triggered @app.action("approve_button") def approve_request(ack, say): # Acknowledge action request From 57fdb45100b7608af389fe947504983b78119eee Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 30 Mar 2021 11:02:54 +0900 Subject: [PATCH 296/865] Add Japanese version of Basic Concepts documents --- docs/_advanced/async.md | 2 +- docs/_basic/authenticating_oauth.md | 4 +- docs/_basic/ja_acknowledging_events.md | 33 ++++++ docs/_basic/ja_authenticating_oauth.md | 103 +++++++++++++++++ docs/_basic/ja_listening_actions.md | 54 +++++++++ docs/_basic/ja_listening_events.md | 49 ++++++++ docs/_basic/ja_listening_messages.md | 45 ++++++++ docs/_basic/ja_listening_modals.md | 48 ++++++++ .../ja_listening_responding_commands.md | 27 +++++ .../_basic/ja_listening_responding_options.md | 34 ++++++ .../ja_listening_responding_shortcuts.md | 107 ++++++++++++++++++ docs/_basic/ja_opening_modals.md | 58 ++++++++++ docs/_basic/ja_publishing_views.md | 46 ++++++++ docs/_basic/ja_responding_actions.md | 44 +++++++ docs/_basic/ja_sending_messages.md | 57 ++++++++++ docs/_basic/ja_socket_mode.md | 72 ++++++++++++ docs/_basic/ja_updating_pushing_modals.md | 53 +++++++++ docs/_basic/ja_web_api.md | 26 +++++ docs/_basic/listening_actions.md | 2 +- docs/_basic/listening_messages.md | 2 +- docs/_basic/listening_responding_shortcuts.md | 2 +- docs/_basic/sending_messages.md | 2 +- docs/_basic/socket_mode.md | 4 +- docs/_basic/web_api.md | 2 +- docs/_config.yml | 2 +- docs/_includes/sidebar.html | 2 +- docs/_tutorials/ja_getting_started.md | 14 +-- 27 files changed, 875 insertions(+), 19 deletions(-) create mode 100644 docs/_basic/ja_acknowledging_events.md create mode 100644 docs/_basic/ja_authenticating_oauth.md create mode 100644 docs/_basic/ja_listening_actions.md create mode 100644 docs/_basic/ja_listening_events.md create mode 100644 docs/_basic/ja_listening_messages.md create mode 100644 docs/_basic/ja_listening_modals.md create mode 100644 docs/_basic/ja_listening_responding_commands.md create mode 100644 docs/_basic/ja_listening_responding_options.md create mode 100644 docs/_basic/ja_listening_responding_shortcuts.md create mode 100644 docs/_basic/ja_opening_modals.md create mode 100644 docs/_basic/ja_publishing_views.md create mode 100644 docs/_basic/ja_responding_actions.md create mode 100644 docs/_basic/ja_sending_messages.md create mode 100644 docs/_basic/ja_socket_mode.md create mode 100644 docs/_basic/ja_updating_pushing_modals.md create mode 100644 docs/_basic/ja_web_api.md diff --git a/docs/_advanced/async.md b/docs/_advanced/async.md index 02b60cc4e..fddf82f99 100644 --- a/docs/_advanced/async.md +++ b/docs/_advanced/async.md @@ -1,5 +1,5 @@ --- -title: Using async +title: Using async (asyncio) lang: en slug: async order: 2 diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md index 48b903c14..fa1db0873 100644 --- a/docs/_basic/authenticating_oauth.md +++ b/docs/_basic/authenticating_oauth.md @@ -7,9 +7,9 @@ order: 15
    -Slack apps installed on multiple workspaces will need to implement OAuth, then store installation information (like access tokens) securely. By providing `client_id`, `client_secret`, `scopes`, `installation_store`, and `state_store` when initializing App, Bolt for Python will handle the work of setting up OAuth routes and verifying state. If you’re implementing a custom adapter, you can make use of our [OAuth library](https://slack.dev/python-slack-sdk/oauth/), which is what Bolt for Python uses under the hood. +Slack apps installed on multiple workspaces will need to implement OAuth, then store installation information (like access tokens) securely. By providing `client_id`, `client_secret`, `scopes`, `installation_store`, and `state_store` when initializing App, Bolt for Python will handle the work of setting up OAuth routes and verifying state. If you're implementing a custom adapter, you can make use of our [OAuth library](https://slack.dev/python-slack-sdk/oauth/), which is what Bolt for Python uses under the hood. -Bolt for Python will create a **Redirect URL** `slack/oauth_redirect`, which Slack uses to redirect users after they complete your app’s installation flow. You will need to add this **Redirect URL** in your app configuration settings under **OAuth and Permissions**. This path can be configured in the `OAuthSettings` argument described below. +Bolt for Python will create a **Redirect URL** `slack/oauth_redirect`, which Slack uses to redirect users after they complete your app's installation flow. You will need to add this **Redirect URL** in your app configuration settings under **OAuth and Permissions**. This path can be configured in the `OAuthSettings` argument described below. Bolt for Python will also create a `slack/install` route, where you can find an **Add to Slack** button for your app to perform direct installs of your app. If you need any additional authorizations (user tokens) from users inside a team when your app is already installed or a reason to dynamically generate an install URL, you can pass your own custom URL generator to `oauth_settings` as `authorize_url_generator`. diff --git a/docs/_basic/ja_acknowledging_events.md b/docs/_basic/ja_acknowledging_events.md new file mode 100644 index 000000000..996500347 --- /dev/null +++ b/docs/_basic/ja_acknowledging_events.md @@ -0,0 +1,33 @@ +--- +title: イベントの確認 +lang: ja-jp +slug: acknowledge +order: 7 +--- + +
    + +アクション(action)、コマンド(command)、およびオプション(options)の各イベントは、**必ず** `ack()` 関数を使って確認を行う必要があります。これによってイベントが受信されたことが Slack に認識され、Slack のユーザーインターフェイスが適切に更新されます。 + +イベントの種類によっては、確認で通知方法が異なる場合があります。例えば、外部データソースを使用する選択メニューのオプションのリクエストに対する確認では、適切な[オプション](https://api.slack.com/reference/block-kit/composition-objects#option)のリストとともに `ack()` を呼び出します。 + +確認までの猶予は 3 秒しかないため、新しいメッセージの送信や、データベースからの情報の取得は、`ack()` を呼び出した後で行うことをおすすめします。 + +
    + +```python +# 外部データを使用する選択メニューオプションに応答するサンプル +@app.options("menu_selection") +def show_menu_options(ack): + options = [ + { + "text": {"type": "plain_text", "text":"Option 1"}, + "value":"1-1", + }, + { + "text": {"type": "plain_text", "text":"Option 2"}, + "value":"1-2", + }, + ] + ack(options=options) +``` diff --git a/docs/_basic/ja_authenticating_oauth.md b/docs/_basic/ja_authenticating_oauth.md new file mode 100644 index 000000000..a10c87399 --- /dev/null +++ b/docs/_basic/ja_authenticating_oauth.md @@ -0,0 +1,103 @@ +--- +title: OAuth を使った認証 +lang: ja-jp +slug: authenticating-oauth +order: 15 +--- + +
    + +Slack アプリを複数のワークスペースにインストールできるようにするためには、OAuth フローを実装した上で、アクセストークンなどのインストールに関する情報をセキュアな方法で保存する必要があります。アプリを初期化する際に `client_id`、`client_secret`、`scopes`、`installation_store`、`state_store` を指定することで、OAuth のエンドポイントのルート情報や stateパラメーターの検証をBolt for Python にハンドリングさせることができます。カスタムのアダプターを実装する場合は、SDK が提供する組み込みの[OAuth ライブラリ](https://slack.dev/python-slack-sdk/oauth/)を利用するのが便利です。これは Slack が開発したモジュールで、Bolt for Python 内部でも利用しています。 + +Bolt for Python によって `slack/oauth_redirect` という**リダイレクト URL** が生成されます。Slack はアプリのインストールフローを完了させたユーザーをこの URL にリダイレクトします。この**リダイレクト URL** は、アプリの設定の「**OAuth and Permissions**」であらかじめ追加しておく必要があります。この URL は、後ほど説明するように `OAuthSettings` というコンストラクタの引数で指定することもできます。 + +Bolt for Python は `slack/install` というルートも生成します。これはアプリを直接インストールするための「**Add to Slack**」ボタンを表示するために使われます。すでにワークスペースへのアプリのインストールが済んでいる場合に追加で各ユーザーのユーザートークンなどの情報を取得する場合や、カスタムのインストール用の URL を動的に生成したい場合などは、`oauth_settings` の `authorize_url_generator` でカスタムの URL ジェネレーターを指定することができます。 + +バージョン 1.1.0 以降の Bolt for Python では、[OrG 全体へのインストール](https://api.slack.com/enterprise/apps)がデフォルトでサポートされています。OrG 全体へのインストールは、アプリの設定の「**Org Level Apps**」で有効化できます。 + +Slack での OAuth を使ったインストールフローについて詳しくは、[API ドキュメントを参照してください](https://api.slack.com/authentication/oauth-v2)。 + +
    + +```python +import os +from slack_bolt import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +oauth_settings = OAuthSettings( + client_id=os.environ["SLACK_CLIENT_ID"], + client_secret=os.environ["SLACK_CLIENT_SECRET"], + scopes=["channels:read", "groups:read", "chat:write"], + installation_store=FileInstallationStore(base_dir="./data"), + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data") +) + +app = App( + signing_secret=os.environ["SIGNING_SECRET"], + oauth_settings=oauth_settings +) +``` + +
    + +

    OAuth デフォルト設定をカスタマイズ

    +
    + +
    +`oauth_settings` を使って OAuth モジュールのデフォルト設定を上書きすることができます。このカスタマイズされた設定は App の初期化時に渡します。以下の情報を変更可能です: + +- `install_path` : 「Add to Slack」ボタンのデフォルトのパスを上書きするために使用 +- `redirect_uri` : リダイレクト URL のデフォルトのパスを上書きするために使用 +- `callback_options` : OAuth フローの最後に表示するカスタムの成功ページと失敗ページの表示処理を提供するために使用 +- `state_store` : 組み込みの `FileOAuthStateStore` に代わる、カスタムの stateに関するデータストアを指定するために使用 +- `installation_store` : 組み込みの `FileInstallationStore` に代わる、カスタムのデータストアを指定するために使用 + +
    + +```python +from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs +from slack_bolt.response import BoltResponse + +def success(args:SuccessArgs) -> BoltResponse: + assert args.request is not None + return BoltResponse( + status=200, # ユーザーをリダイレクトすることも可能 + body="Your own response to end-users here" + ) + +def failure(args:FailureArgs) -> BoltResponse: + assert args.request is not None + assert args.reason is not None + return BoltResponse( + status=args.suggested_status_code, + body="Your own response to end-users here" + ) + +callback_options = CallbackOptions(success=success, failure=failure) + +import os +from slack_bolt import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +app = App( + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), + installation_store=FileInstallationStore(base_dir="./data"), + oauth_settings=OAuthSettings( + client_id=os.environ.get("SLACK_CLIENT_ID"), + client_secret=os.environ.get("SLACK_CLIENT_SECRET"), + scopes=["app_mentions:read", "channels:history", "im:history", "chat:write"], + user_scopes=[], + redirect_uri=None, + install_path="/slack/install", + redirect_uri_path="/slack/oauth_redirect", + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data"), + callback_options=callback_options, + ), +) +``` + +
    diff --git a/docs/_basic/ja_listening_actions.md b/docs/_basic/ja_listening_actions.md new file mode 100644 index 000000000..fe4407809 --- /dev/null +++ b/docs/_basic/ja_listening_actions.md @@ -0,0 +1,54 @@ +--- +title: アクションのリスニング +lang: ja-jp +slug: action-listening +order: 5 +--- + +
    +Bolt アプリは `action` メソッドを用いて、ボタンのクリック、メニューの選択、メッセージショートカットなどのユーザーのアクションをリッスンすることができます。 + +アクションは `str` 型または `re.Pattern` 型の `action_id` でフィルタリングできます。`action_id` は、Slack プラットフォーム上のインタラクティブコンポーネントを区別する一意の識別子として機能します。 + +`action()` を使ったすべての例で `ack()` が使用されていることに注目してください。アクションのリスナー内では、Slack からのイベントを受信したことを確認するために、`ack()` 関数を呼び出す必要があります。これについては、[イベントの確認](#acknowledge)セクションで説明しています。 + +
    + +```python +# 'approve_button' という action_id のブロックエレメントがトリガーされるたびに、このリスナーが呼び出させれる +@app.action("approve_button") +def update_message(ack): + ack() + # アクションへの反応としてメッセージを更新 +``` + +
    + +

    制約付きオブジェクトを使用したアクションのリスニング

    +
    + +
    + +制約付きのオブジェクトを使用すると、`callback_id`、`block_id`、および `action_id` をそれぞれ、または任意に組み合わせてリッスンできます。オブジェクト内の制約は、`str` 型または `re.Pattern` 型で指定できます。 + +
    + +```python +# この関数は、block_id が 'assign_ticket' に一致し +# かつ action_id が 'select_user' に一致する場合にのみ呼び出される +@app.action({ + "block_id": "assign_ticket", + "action_id": "select_user" +}) +def update_message(ack, body, client): + ack() + + if "container" in body and "message_ts" in body["container"]: + client.reactions_add( + name="white_check_mark", + channel=body["channel"]["id"], + timestamp=body["container"]["message_ts"], + ) +``` + +
    diff --git a/docs/_basic/ja_listening_events.md b/docs/_basic/ja_listening_events.md new file mode 100644 index 000000000..b3ac00773 --- /dev/null +++ b/docs/_basic/ja_listening_events.md @@ -0,0 +1,49 @@ +--- +title: イベントのリスニング +lang: ja-jp +slug: event-listening +order: 3 +--- + +
    + +`event()` メソッドを使うと、[Events API](https://api.slack.com/events) の任意のイベントをリッスンできます。リッスンするイベントは、アプリの設定であらかじめサブスクライブしておく必要があります。これを利用することで、アプリがインストールされたワークスペースで何らかのイベント(例:ユーザーがメッセージにリアクションをつけた、ユーザーがチャンネルに参加した)が発生したときに、アプリに何らかのアクションを実行させることができます。 + +`event()` メソッドには `str` 型の `eventType` を指定する必要があります。 + +
    + +```python +# ユーザーがワークスペースに参加した際に、自己紹介を促すメッセージを指定のチャンネルに送信 +@app.event("team_join") +def ask_for_introduction(event, say): + welcome_channel_id = "C12345" + user_id = event["user"] + text = f"Welcome to the team, <@{user_id}>! 🎉 You can introduce yourself in this channel." + say(text=text, channel=welcome_channel_id) +``` + +
    + + +

    メッセージのサブタイプのフィルタリング

    +
    + +
    +`message()` リスナーは `event("message")` と等価の機能を提供します。 + +`subtype` という追加のキーを指定して、イベントのサブタイプでフィルタリングすることもできます。よく使われるサブタイプには、`bot_message` や `message_replied` があります。詳しくは[メッセージイベントページ](https://api.slack.com/events/message#message_subtypes)を参照してください。 + +
    + +```python +# 変更されたすべてのメッセージに一致 +@app.event({ + "type": "message", + "subtype": "message_changed" +}) +def log_message_change(logger, event): + user, text = event["user"], event["text"] + logger.info(f"The user {user} changed the message to {text}") +``` +
    diff --git a/docs/_basic/ja_listening_messages.md b/docs/_basic/ja_listening_messages.md new file mode 100644 index 000000000..d0ca9eea8 --- /dev/null +++ b/docs/_basic/ja_listening_messages.md @@ -0,0 +1,45 @@ +--- +title: メッセージのリスニング +lang: ja-jp +slug: message-listening +order: 1 +--- + +
    + +[あなたのアプリがアクセス権限を持つ](https://api.slack.com/messaging/retrieving#permissions)メッセージの投稿イベントをリッスンするには `message()` メソッドを利用します。このメソッドは `type` が `message` ではないイベントを処理対象から除外します。 + +`message()` の引数には `str` 型または `re.Pattern` オブジェクトを指定できます。この条件のパターンに一致しないメッセージは除外されます。 + +
    + +```python +# '👋' が含まれるすべてのメッセージに一致 +@app.message(":wave:") +def say_hello(message, say): + user = message['user'] + say(f"Hi there, <@{user}>!") +``` + +
    + +

    正規表現パターンの使用

    +
    + +
    + +文字列の代わりに `re.compile()` メソッドを使用すれば、より細やかな条件指定ができます。 + +
    + +```python +import re + +@app.message(re.compile("(hi|hello|hey)")) +def say_hello_regex(say, context): + # 正規表現のマッチ結果は context.matches に設定される + greeting = context['matches'][0] + say(f"{greeting}, how are you?") +``` + +
    diff --git a/docs/_basic/ja_listening_modals.md b/docs/_basic/ja_listening_modals.md new file mode 100644 index 000000000..c5986d395 --- /dev/null +++ b/docs/_basic/ja_listening_modals.md @@ -0,0 +1,48 @@ +--- +title: モーダルの送信のリスニング +lang: ja-jp +slug: view_submissions +order: 12 +--- + +
    + +モーダルのペイロードに input ブロックを含める場合、その入力値を受け取るために`view_submission` イベントをリッスンする必要があります。`view_submission` イベントのリッスンには、組み込みの`view()` メソッドを利用することができます。`view()` の引数には、`str` 型または `re.Pattern` 型の `callback_id` を指定します。 + +`input` ブロックの値にアクセスするには `state` オブジェクトを参照します。`state` 内には `values` というオブジェクトがあり、`block_id` と一意の `action_id` に紐づける形で入力値を保持しています。 + +モーダルの送信について詳しくは、API ドキュメントを参照してください。 + +
    + +```python +# view_submission イベントを処理 +@app.view("view_1") +def handle_submission(ack, body, client, view): + # `block_c`という block_id に `dreamy_input` を持つ input ブロックがある場合 + hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"] + user = body["user"]["id"] + # 入力値を検証 + errors = {} + if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5: + errors["block_c"] = "The value must be longer than 5 characters" + if len(errors) > 0: + ack(response_action="errors", errors=errors) + return + # view_submission イベントの確認を行い、モーダルを閉じる + ack() + # 入力されたデータを使った処理を実行。このサンプルでは DB に保存する処理を行う + # そして入力値の検証結果をユーザーに送信 + + # ユーザーに送信するメッセージ + msg = "" + try: + # DB に保存 + msg = f"Your submission of {hopes_and_dreams} was successful" + except Exception as e: + # エラーをハンドリング + msg = "There was an error with your submission" + finally: + # ユーザーにメッセージを送信 + client.chat_postMessage(channel=user, text=msg) +``` diff --git a/docs/_basic/ja_listening_responding_commands.md b/docs/_basic/ja_listening_responding_commands.md new file mode 100644 index 000000000..da782ce6e --- /dev/null +++ b/docs/_basic/ja_listening_responding_commands.md @@ -0,0 +1,27 @@ +--- +title: コマンドのリスニングと応答 +lang: ja-jp +slug: commands +order: 9 +--- + +
    + +スラッシュコマンドが実行されたイベントをリッスンするには、`command()` メソッドを使用します。このメソッドでは `str` 型の `command_name` の指定が必要です。 + +コマンドイベントをアプリが受信し確認したことを Slack に通知するため、`ack()` を呼び出す必要があります。 + +スラッシュコマンドに応答する方法は 2 つあります。1 つ目は `say()` を使う方法で、文字列または JSON のペイロードを渡すことができます。2 つ目は `respond()` を使う方法です。これは `response_url` がある場合に活躍します。これらの方法は[アクションへの応答](#action-respond)セクションで詳しく説明しています。 + +アプリの設定でコマンドを登録するときは、リクエスト URL の末尾に `/slack/events` をつけます。 + +
    + +```python +# echoコマンドは受け取ったコマンドをそのまま返す +@app.command("/echo") +def repeat_text(ack, say, command): + # command リクエストを確認 + ack() + say(f"{command['text']}") +``` diff --git a/docs/_basic/ja_listening_responding_options.md b/docs/_basic/ja_listening_responding_options.md new file mode 100644 index 000000000..8bd7eb705 --- /dev/null +++ b/docs/_basic/ja_listening_responding_options.md @@ -0,0 +1,34 @@ +--- +title: オプションのリスニングと応答 +lang: ja-jp +slug: options +order: 14 +--- + +
    +`options()` メソッドは、Slack からのオプション(セレクトメニュー内の動的な選択肢)をリクエストするペイロードをリッスンします。 [`action()` と同様に](#action-listening)、文字列型の `action_id` または制約付きオブジェクトが必要です。 + +外部データソースを使って選択メニューをロードするためには、末部に `/slack/events` が付加された URL を Options Load URL として予め設定しておく必要があります。 + +`external_select` メニューでは `action_id` を指定することをおすすめしています。ただし、ダイアログを利用している場合、ダイアログが Block Kit に対応していないため、`callback_id` をフィルタリングするための制約オブジェクトを使用する必要があります。 + +オプションのリクエストに応答するときは、有効なオプションを含む `options` または `option_groups` のリストとともに `ack()` を呼び出す必要があります。API サイトにある[外部データを使用する選択メニューに応答するサンプル例](https://api.slack.com/reference/messaging/block-elements#external-select)と、[ダイアログでの応答例](https://api.slack.com/dialogs#dynamic_select_elements_external)を参考にしてください。 + +
    + +```python +# 外部データを使用する選択メニューオプションに応答するサンプル例 +@app.options("external_action") +def show_options(ack): + options = [ + { + "text": {"type": "plain_text", "text":"Option 1"}, + "value":"1-1", + }, + { + "text": {"type": "plain_text", "text":"Option 2"}, + "value":"1-2", + }, + ] + ack(options=options) +``` diff --git a/docs/_basic/ja_listening_responding_shortcuts.md b/docs/_basic/ja_listening_responding_shortcuts.md new file mode 100644 index 000000000..30af2a1aa --- /dev/null +++ b/docs/_basic/ja_listening_responding_shortcuts.md @@ -0,0 +1,107 @@ +--- +title: ショートカットのリスニングと応答 +lang: ja-jp +slug: shortcuts +order: 8 +--- + +
    + +`shortcut()` メソッドは、[グローバルショートカット](https://api.slack.com/interactivity/shortcuts/using#global_shortcuts)と[メッセージショートカット](https://api.slack.com/interactivity/shortcuts/using#message_shortcuts)の 2 つをサポートしています。 + +ショートカットは、いつでも呼び出せるアプリのエントリーポイントを提供するものです。グローバルショートカットは Slack のテキスト入力エリアや検索ウィンドウからアクセスできます。メッセージショートカットはメッセージのコンテキストメニューからアクセスできます。アプリは、ショートカットイベントをリッスンするために `shortcut()` メソッドを使用します。このメソッドには `str` 型または `re.Pattern` 型の `callback_id` パラメーターを指定します。 + +ショートカットイベントがアプリによって確認されたことを Slack に伝えるため、`ack()` を呼び出す必要があります。 + +ショートカットのペイロードには `trigger_id` が含まれます。アプリはこれを使って、ユーザーにやろうとしていることを確認するための[モーダルを開く](#creating-modals)ことができます。 + +アプリの設定でショートカットを登録する際は、他の URL と同じように、リクエスト URL の末尾に `/slack/events` をつけます。 + +⚠️ グローバルショートカットのペイロードにはチャンネル ID が **含まれません**。アプリでチャンネル ID を取得する必要がある場合は、モーダル内に [`conversations_select`](https://api.slack.com/reference/block-kit/block-elements#conversation_select) エレメントを配置します。メッセージショートカットにはチャンネル ID が含まれます。 + +
    + +```python + +# 'open_modal' という callback_id のショートカットをリッスン +@app.shortcut("open_modal") +def open_modal(ack, shortcut, client): + # ショートカットのリクエストを確認 + ack() + # 組み込みのクライアントを使って views_open メソッドを呼び出す + client.views_open( + trigger_id=shortcut["trigger_id"], + # モーダルで表示するシンプルなビューのペイロード + view={ + "type": "modal", + "title": {"type": "plain_text", "text":"My App"}, + "close": {"type": "plain_text", "text":"Close"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text":"About the simplest modal you could conceive of :smile:\n\nMaybe or ." + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text":"Psssst this modal was designed using " + } + ] + } + ] + } + ) +``` + +
    + +

    制約付きオブジェクトを使用したショートカットのリスニング

    +
    + +
    +制約付きオブジェクトを使って `callback_id` や `type` によるリッスンできます。オブジェクト内の制約は `str` 型または `re.Pattern` オブジェクトを使用できます。 +
    + +```python + +# このリスナーが呼び出されるのは、callback_id が 'open_modal' と一致し +# かつ type が 'message_action' と一致するときのみ +@app.shortcut({"callback_id": "open_modal", "type": "message_action"}) +def open_modal(ack, shortcut, client): + # ショートカットのリクエストを確認 + ack() + # 組み込みのクライアントを使って views_open メソッドを呼び出す + client.views_open( + trigger_id=shortcut["trigger_id"], + view={ + "type": "modal", + "title": {"type": "plain_text", "text":"My App"}, + "close": {"type": "plain_text", "text":"Close"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text":"About the simplest modal you could conceive of :smile:\n\nMaybe or ." + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text":"Psssst this modal was designed using " + } + ] + } + ] + } + ) +``` + +
    diff --git a/docs/_basic/ja_opening_modals.md b/docs/_basic/ja_opening_modals.md new file mode 100644 index 000000000..65aa100ce --- /dev/null +++ b/docs/_basic/ja_opening_modals.md @@ -0,0 +1,58 @@ +--- +title: モーダルの開始 +lang: ja-jp +slug: opening-modals +order: 10 +--- + +
    + +モーダルは、ユーザーからのデータの入力を受け付けたり、動的な情報を表示したりするためのインターフェイスです。組み込みの APIクライアントの `views.open` メソッドに、有効な `trigger_id` とビューのペイロードを指定してモーダルを開始します。 + +ショートカットの実行、ボタンを押下、選択メニューの操作などの操作の場合、Request URL に送信されるペイロードには `trigger_id` が含まれます。 + +モーダルの生成方法についての詳細は、API ドキュメントを参照してください。 + +
    + +```python +# ショートカットの呼び出しをリッスン +@app.shortcut("open_modal") +def open_modal(ack, body, client): + # コマンドのリクエストを確認 + ack() + # 組み込みのクライアントで views_open を呼び出し + client.views_open( + # 受け取りから 3 秒以内に有効な trigger_id を渡す + trigger_id=body["trigger_id"], + # ビューのペイロード + view={ + "type": "modal", + # ビューの識別子 + "callback_id": "view_1", + "title": {"type": "plain_text", "text":"My App"}, + "submit": {"type": "plain_text", "text":"Submit"}, + "blocks": [ + { + "type": "section", + "text": {"type": "mrkdwn", "text":"Welcome to a modal with _blocks_"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text":"Click me!"}, + "action_id": "button_abc" + } + }, + { + "type": "input", + "block_id": "input_c", + "label": {"type": "plain_text", "text":"What are your hopes and dreams?"}, + "element": { + "type": "plain_text_input", + "action_id": "dreamy_input", + "multiline":True + } + } + ] + } + ) +``` diff --git a/docs/_basic/ja_publishing_views.md b/docs/_basic/ja_publishing_views.md new file mode 100644 index 000000000..62278161b --- /dev/null +++ b/docs/_basic/ja_publishing_views.md @@ -0,0 +1,46 @@ +--- +title: ホームタブの更新 +lang: ja-jp +slug: app-home +order: 13 +--- + +
    +ホームタブは、サイドバーや検索画面からアクセス可能なサーフェスエリアです。アプリはこのエリアを使ってユーザーごとのビューを表示することができます。アプリ設定ページで App Home の機能を有効にすると、`views.publish` API メソッドの呼び出しで `user_id` と[ビューのペイロード](https://api.slack.com/reference/block-kit/views)を指定して、ホームタブを公開・更新することができるようになります。 + +`app_home_opened` イベントをサブスクライブすると、ユーザーが App Home を開く操作をリッスンできます。 +
    + +```python +@app.event("app_home_opened") +def update_home_tab(client, event, logger): + try: + # 組み込みのクライアントを使って views.publish を呼び出す + client.views_publish( + # イベントに関連づけられたユーザー ID を使用 + user_id=event["user"], + # アプリの設定で予めホームタブが有効になっている必要がある + view={ + "type": "home", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Welcome home, <@" + event["user"] + "> :house:*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text":"Learn how home tabs can be more useful and interactive ." + } + } + ] + } + ) + + except Exception as e: + logger.error(f"Error publishing home tab: {e}") +``` diff --git a/docs/_basic/ja_responding_actions.md b/docs/_basic/ja_responding_actions.md new file mode 100644 index 000000000..e8ddd9b30 --- /dev/null +++ b/docs/_basic/ja_responding_actions.md @@ -0,0 +1,44 @@ +--- +title: アクションへの応答 +lang: ja-jp +slug: action-respond +order: 6 +--- + +
    + +アクションへの応答には、主に 2 つの方法があります。1 つ目の最も一般的なやり方は `say()` を使用する方法です。そのイベントが発生した会話(チャンネルや DM)にメッセージを返します。 + +2 つ目は、`respond()` を使用する方法です。これは、アクションに関連づけられた `response_url` を使ったメッセージ送信を行うためのユーティリティです。 + +
    + +```python +# 'approve_button' という action_id のインタラクティブコンポーネントがトリガーされると、このリスナーが呼ばれる +@app.action("approve_button") +def approve_request(ack, say): + # アクションのリクエストを確認 + ack() + say("Request approved 👍") +``` + +
    + +

    respond() の使用

    +
    + +
    + +`respond()` は `response_url` を使って送信するときに便利なメソッドで、これらと同じような動作をします。投稿するメッセージのペイロードには JSON オブジェクトを渡すことができ、メッセージはやり取りの発生元に反映されます。オプションのプロパティとして `response_type`(値は `in_channel` または `ephemeral`)、`replace_original`、`delete_original` などを指定できます。 + +
    + +```python +# 'user_select' という action_id を持つアクションのトリガーをリッスン +@app.action("user_select") +def select_user(ack, action, respond): + ack() + respond(f"You selected <@{action['selected_user']}>") +``` + +
    diff --git a/docs/_basic/ja_sending_messages.md b/docs/_basic/ja_sending_messages.md new file mode 100644 index 000000000..40499555e --- /dev/null +++ b/docs/_basic/ja_sending_messages.md @@ -0,0 +1,57 @@ +--- +title: メッセージの送信 +lang: ja-jp +slug: message-sending +order: 2 +--- + +
    + +リスナー関数内では、関連づけられた会話(例:リスナー実行のトリガーとなったイベントまたはアクションの発生元の会話)がある場合はいつでも `say()` を使用できます。`say()` には文字列または JSON ペイロードを指定できます。文字列の場合、送信できるのはテキストベースの単純なメッセージです。より複雑なメッセージを送信するには JSON ペイロードを指定します。指定したメッセージのペイロードは、関連づけられた会話内のメッセージとして送信されます。 + +リスナー関数の外でメッセージを送信したい場合や、より高度な処理(特定のエラーの処理など)を実行したい場合は、[Bolt インスタンスにアタッチされたクライアント](#web-api)の `client.chat_postMessage` を呼び出します。 + +
    + +```python +# 'knock knock' が含まれるメッセージをリッスンし、イタリック体で 'Who's there?' と返信 +@app.message("knock knock") +def ask_who(message, say): + say("_Who's there?_") +``` + +
    + +

    ブロックを用いたメッセージの送信

    +
    + +
    +`say()` は、より複雑なメッセージペイロードを受け付けるので、メッセージに機能やリッチな構造を与えることが容易です。 + +リッチなメッセージレイアウトをアプリに追加する方法については、[API サイトのガイド](https://api.slack.com/messaging/composing/layouts)を参照してください。また、[Block Kit ビルダー](https://api.slack.com/tools/block-kit-builder?template=1)の一般的なアプリフローのテンプレートも見てみてください。 + +
    + +```python +# ユーザーが 📅 のリアクションをつけたら、日付ピッカーのついた section ブロックを送信 +@app.event("reaction_added") +def show_datepicker(event, say): + reaction = event["reaction"] + if reaction == "calendar": + blocks = [{ + "type": "section", + "text": {"type": "mrkdwn", "text":"Pick a date for me to remind you"}, + "accessory": { + "type": "datepicker", + "action_id": "datepicker_remind", + "initial_date":"2020-05-04", + "placeholder": {"type": "plain_text", "text":"Select a date"} + } + }] + say( + blocks=blocks, + text="Pick a date for me to remind you" + ) +``` + +
    diff --git a/docs/_basic/ja_socket_mode.md b/docs/_basic/ja_socket_mode.md new file mode 100644 index 000000000..7e202add0 --- /dev/null +++ b/docs/_basic/ja_socket_mode.md @@ -0,0 +1,72 @@ +--- +title: ソケットモードの使用 +lang: ja-jp +slug: socket-mode +order: 16 +--- + +
    +[ソケットモード](https://api.slack.com/apis/connections/socket)は、アプリに WebSocket での接続と、そのコネクション経由でのデータ受信を可能とします。Bolt for Python は、バージョン 1.2.0 からこれに対応しています。 + +ソケットモードでは、Slack からのペイロード送信を受け付けるエンドポイントをホストする HTTP サーバーを起動する代わりに WebSocket で Slack に接続し、そのコネクション経由でデータを受信します。ソケットモードを使う前に、アプリの管理画面でソケットモードの機能が有効になっているコオを確認しておいてください。 + +ソケットモードを使用するには、環境変数に `SLACK_APP_TOKEN` を追加します。アプリのトークン(App-Level Token)は、アプリの設定の「**Basic Information**」セクションで確認できます。 + +[組み込みのソケットモードアダプター](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/builtin)を使用するのがおすすめですが、サードパーティ製ライブラリを使ったアダプターの実装もいくつか存在しています。利用可能なアダプターの一覧です。 + +|PyPI プロジェクト|Bolt アダプター| +|-|-| +|[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)| + +
    + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# 事前に Slack アプリをインストールし 'xoxb-' で始まるトークンを入手 +app = App(token=os.environ["SLACK_BOT_TOKEN"]) + +# ここでミドルウェアとリスナーの追加を行います + +if __name__ == "__main__": + # export SLACK_APP_TOKEN=xapp-*** + # export SLACK_BOT_TOKEN=xoxb-*** + handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + handler.start() +``` + +
    + +

    Async (asyncio) の利用

    +
    + +
    +aiohttp のような asyncio をベースとしたアダプターを使う場合、アプリケーション全体が asyncio の async/await プログラミングモデルで実装されている必要があります。`AsyncApp` を動作させるためには `AsyncSocketModeHandler` とその async なミドルウェアやリスナーを利用します。 + +`AsyncApp` の使い方についての詳細は、[Usin async (asyncio)](https://slack.dev/bolt-python/concepts#async)トや、関連する[サンプルコード例](https://github.com/slackapi/bolt-python/tree/main/examples)を参考にしてください。 +
    + +```python +from slack_bolt.app.async_app import AsyncApp +# デフォルトは aiohttp を使った実装 +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()) +``` + +
    diff --git a/docs/_basic/ja_updating_pushing_modals.md b/docs/_basic/ja_updating_pushing_modals.md new file mode 100644 index 000000000..26fdcdc94 --- /dev/null +++ b/docs/_basic/ja_updating_pushing_modals.md @@ -0,0 +1,53 @@ +--- +title: モーダルの更新と多重表示 +lang: ja-jp +slug: updating-pushing-views +order: 11 +--- + +
    + +モーダル内では、複数のモーダルをスタックのように重ねることができます。`views_open` という APIを呼び出すと、親となるとなるモーダルビューが追加されます。この最初の呼び出しの後、`views_update` を呼び出すことでそのビューを更新することができます。また、`views_push` を呼び出すと、親のモーダルの上にさらに新しいモーダルビューを重ねることもできます。 + +**`views_update`**
    +モーダルの更新は、組み込みのクライアントで `views_update` API を呼び出します。この API呼び出しでは、ビューを開いた時に生成された `view_id` と、更新後の `blocks` のリストを含む新しい `view` を指定します。既存のモーダルに含まれるエレメントをユーザーが操作した時にビューを更新する場合は、リクエストの `body` に含まれる `view_id` が利用できます。 + +**`views_push`**
    +既存のモーダルの上に新しいモーダルをスタックのように追加する場合は、組み込みのクライアントで `views_push` API を呼び出します。この API 呼び出しでは、有効な `trigger_id` と新しいビューのペイロードを指定します。`views_push` の引数は モーダルの開始 と同じです。モーダルを開いた後、このモーダルのスタックに追加できるモーダルビューは 2 つまでです。 + +モーダルの更新と多重表示に関する詳細は、API ドキュメントを参照してください。 + +
    + +```python +# モーダルに含まれる、`button_abc` という action_id のボタンの呼び出しをリッスン +@app.action("button_abc") +def update_modal(ack, body, client): + # ボタンのリクエストを確認 + ack() + # 組み込みのクライアントで views_update を呼び出し + client.views_update( + # view_id を渡すこと + view_id=body["view"]["id"], + # 競合状態を防ぐためのビューの状態を示す文字列 + hash=body["view"]["hash"], + # 更新後の blocks を含むビューのペイロード + view={ + "type": "modal", + # ビューの識別子 + "callback_id": "view_1", + "title": {"type": "plain_text", "text":"Updated modal"}, + "blocks": [ + { + "type": "section", + "text": {"type": "plain_text", "text":"You updated the modal!"} + }, + { + "type": "image", + "image_url": "https://media.giphy.com/media/SVZGEcYt7brkFUyU90/giphy.gif", + "alt_text":"Yay!The modal was updated" + } + ] + } + ) +``` diff --git a/docs/_basic/ja_web_api.md b/docs/_basic/ja_web_api.md new file mode 100644 index 000000000..c44c8da22 --- /dev/null +++ b/docs/_basic/ja_web_api.md @@ -0,0 +1,26 @@ +--- +title: Web API の使い方 +lang: ja-jp +slug: web-api +order: 4 +--- + +
    +`app.client`、またはミドルウェア・リスナーの引数 `client` として Bolt アプリに提供されている [`WebClient`](https://slack.dev/python-slack-sdk/basic_usage.html) は必要な権限を付与されており、これを利用することで[あらゆる Web API メソッド](https://api.slack.com/methods)を呼び出すことができます。このクライアントのメソッドを呼び出すと `SlackResponse` という Slack からの応答情報を含むオブジェクトが返されます。 + +Bolt の初期化に使用するトークンは `context` オブジェクトに設定されます。このトークンは、多くの Web API メソッドを呼び出す際に必要となります。 + +
    + +```python +@app.message("wake me up") +def say_hello(client, message): + # 2020 年 9 月 30 日午後 11:59:59 を示す Unix エポック秒 + when_september_ends = 1601510399 + channel_id = message["channel"] + client.chat_scheduleMessage( + channel=channel_id, + post_at=when_september_ends, + text="Summer has come and passed" + ) +``` diff --git a/docs/_basic/listening_actions.md b/docs/_basic/listening_actions.md index 3de71d003..af85c4c4c 100644 --- a/docs/_basic/listening_actions.md +++ b/docs/_basic/listening_actions.md @@ -10,7 +10,7 @@ Your app can listen to user actions, like button clicks, and menu selects, using Actions can be filtered on an `action_id` of type `str` or `re.Pattern`. `action_id`s act as unique identifiers for interactive components on the Slack platform. -You’ll notice in all `action()` examples, `ack()` is used. It is required to call the `ack()` function within an action listener to acknowledge that the event was received from Slack. This is discussed in the [acknowledging events section](#acknowledge). +You'll notice in all `action()` examples, `ack()` is used. It is required to call the `ack()` function within an action listener to acknowledge that the event was received from Slack. This is discussed in the [acknowledging events section](#acknowledge).
    diff --git a/docs/_basic/listening_messages.md b/docs/_basic/listening_messages.md index 34c30e180..d193da141 100644 --- a/docs/_basic/listening_messages.md +++ b/docs/_basic/listening_messages.md @@ -9,7 +9,7 @@ order: 1 To listen to messages that [your app has access to receive](https://api.slack.com/messaging/retrieving#permissions), you can use the `message()` method which filters out events that aren't of type `message`. -`message()` accepts an argument of type `str` or `re.Pattern` object that filters out any messages that don’t match the pattern. +`message()` accepts an argument of type `str` or `re.Pattern` object that filters out any messages that don't match the pattern. diff --git a/docs/_basic/listening_responding_shortcuts.md b/docs/_basic/listening_responding_shortcuts.md index 1e23ddb69..a7f414501 100644 --- a/docs/_basic/listening_responding_shortcuts.md +++ b/docs/_basic/listening_responding_shortcuts.md @@ -9,7 +9,7 @@ order: 8 The `shortcut()` method supports both [global shortcuts](https://api.slack.com/interactivity/shortcuts/using#global_shortcuts) and [message shortcuts](https://api.slack.com/interactivity/shortcuts/using#message_shortcuts). -Shortcuts are invokable entry points to apps. Global shortcuts are available from within search in Slack. Message shortcuts are available in the context menus of messages. Your app can use the `shortcut()` method to listen to incoming shortcut events. The method requires a `callback_id` parameter of type `str` or `re.Pattern`. +Shortcuts are invokable entry points to apps. Global shortcuts are available from within search and text composer area in Slack. Message shortcuts are available in the context menus of messages. Your app can use the `shortcut()` method to listen to incoming shortcut events. The method requires a `callback_id` parameter of type `str` or `re.Pattern`. Shortcuts must be acknowledged with `ack()` to inform Slack that your app has received the event. diff --git a/docs/_basic/sending_messages.md b/docs/_basic/sending_messages.md index cb6170f6a..bdf9174de 100644 --- a/docs/_basic/sending_messages.md +++ b/docs/_basic/sending_messages.md @@ -9,7 +9,7 @@ order: 2 Within your listener function, `say()` is available whenever there is an associated conversation (for example, a conversation where the event or action which triggered the listener occurred). `say()` accepts a string to post simple messages and JSON payloads to send more complex messages. The message payload you pass in will be sent to the associated conversation. -In the case that you’d like to send a message outside of a listener or you want to do something more advanced (like handle specific errors), you can call `client.chat_postMessage` [using the client attached to your Bolt instance](#web-api). +In the case that you'd like to send a message outside of a listener or you want to do something more advanced (like handle specific errors), you can call `client.chat_postMessage` [using the client attached to your Bolt instance](#web-api). diff --git a/docs/_basic/socket_mode.md b/docs/_basic/socket_mode.md index da7f1d024..2e23266a9 100644 --- a/docs/_basic/socket_mode.md +++ b/docs/_basic/socket_mode.md @@ -40,11 +40,11 @@ if __name__ == "__main__":
    -

    Using Async

    +

    Using Async (asyncio)

    -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. +To use the asyncio-based adapters such as aiohttp, your whole app needs to be compatible with asyncio's async/await programming model. `AsyncSocketModeHandler` is available for running `AsyncApp` and its async middleware and listeners. 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/docs/_basic/web_api.md b/docs/_basic/web_api.md index e29812106..d226b6e15 100644 --- a/docs/_basic/web_api.md +++ b/docs/_basic/web_api.md @@ -6,7 +6,7 @@ order: 4 ---
    -You can call [any Web API method](https://api.slack.com/methods) using the [`WebClient`](https://slack.dev/python-slack-sdk/basic_usage.html) provided to your Bolt app as `app.client` (given that your app has the appropriate scopes). When you call one the client’s methods, it returns a `SlackResponse` which contains the response from Slack. +You can call [any Web API method](https://api.slack.com/methods) using the [`WebClient`](https://slack.dev/python-slack-sdk/basic_usage.html) provided to your Bolt app as either `app.client` or `client` in middleware/listener arguments (given that your app has the appropriate scopes). When you call one the client's methods, it returns a `SlackResponse` which contains the response from Slack. The token used to initialize Bolt can be found in the `context` object, which is required to call most Web API methods. diff --git a/docs/_config.yml b/docs/_config.yml index 8e01ded40..c6437f1fa 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -36,7 +36,7 @@ t: start: Getting started contribute: Contributing ja-jp: -# basic: 基本的な概念 + basic: 基本的な概念 # steps: ワークフローステップ # advanced: 応用コンセプト start: Bolt 入門ガイド diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html index 4343ff5c3..25b31dea5 100644 --- a/docs/_includes/sidebar.html +++ b/docs/_includes/sidebar.html @@ -23,7 +23,7 @@
  • {{ site.t[page.lang].start }}
  • - +
  • API Documents
  • diff --git a/docs/_tutorials/ja_getting_started.md b/docs/_tutorials/ja_getting_started.md index 80a315f66..9feb4375f 100644 --- a/docs/_tutorials/ja_getting_started.md +++ b/docs/_tutorials/ja_getting_started.md @@ -43,7 +43,7 @@ Slack アプリで使用できるトークンには、ユーザートークン 左サイドバーの「**OAuth & Permissions**」をクリックし、「**Bot Token Scopes**」セクションまで下にスクロールします。「**Add an OAuth Scope**」をクリックします。 -ここでは「[`chat:write`](https://api.slack.com/scopes/chat:write)」というスコープのみを追加します。このスコープは、アプリが参加しているチャンネルにメッセージを投稿することを許可します。 +ここでは [`chat:write`](https://api.slack.com/scopes/chat:write) というスコープのみを追加します。このスコープは、アプリが参加しているチャンネルにメッセージを投稿することを許可します。 OAuth & Permissions ページの一番上までスクロールし、「**Install App to Workspace**」をクリックします。Slack の OAuth 確認画面 が表示されます。この画面で開発用ワークスペースへのアプリのインストールを承認します。 @@ -173,7 +173,7 @@ app = App( signing_secret=os.environ.get("SLACK_SIGNING_SECRET") ) -# 「hello」を含むメッセージをリッスンします +# 'hello' を含むメッセージをリッスンします @app.message("hello") def message_hello(message, say): # イベントがトリガーされたチャンネルへ say() でメッセージを送信します @@ -214,7 +214,7 @@ app = App( signing_secret=os.environ.get("SLACK_SIGNING_SECRET") ) -# 「hello」を含むメッセージをリッスンします +# 'hello' を含むメッセージをリッスンします @app.message("hello") def message_hello(message, say): # イベントがトリガーされたチャンネルへ say() でメッセージを送信します @@ -238,9 +238,9 @@ if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) ``` -`say()` の中の値を、「`blocks`」という配列のオブジェクトに変えました。ブロックは Slack メッセージを構成するコンポーネントであり、テキストや画像、日付ピッカーなど、さまざまなタイプのブロックがあります。この例では、「accessory」に「button」を持たせた「section」のブロックを、アプリからの応答に含めています。「`blocks`」を使用する場合、「`text`」は通知やアクセシビリティのためのフォールバックとなります。 +`say()` の中の値を `blocks` という配列のオブジェクトに変えました。ブロックは Slack メッセージを構成するコンポーネントであり、テキストや画像、日付ピッカーなど、さまざまなタイプのブロックがあります。この例では `accessory` に `button` を持たせた「section」のブロックを、アプリからの応答に含めています。`blocks` を使用する場合、`text` は通知やアクセシビリティのためのフォールバックとなります。 -ボタンを含む「`accessory`」オブジェクトでは、「`action_id`」を指定していることがわかります。これは、ボタンを一意に示す識別子として機能します。これを使って、アプリをどのアクションに応答させるかを指定できます。 +ボタンを含む `accessory` オブジェクトでは、`action_id` を指定していることがわかります。これは、ボタンを一意に示す識別子として機能します。これを使って、アプリをどのアクションに応答させるかを指定できます。 > 💡 [Block Kit Builder](https://app.slack.com/block-kit-builder) を使用すると、インタラクティブなメッセージのプロトタイプを簡単に作成できます。自分自身やチームメンバーがメッセージのモックアップを作成し、生成される JSON をアプリに直接貼りつけることができます。 @@ -258,7 +258,7 @@ app = App( signing_secret=os.environ.get("SLACK_SIGNING_SECRET") ) -# 「hello」を含むメッセージをリッスンします +# 'hello' を含むメッセージをリッスンします @app.message("hello") def message_hello(message, say): # イベントがトリガーされたチャンネルへ say() でメッセージを送信します @@ -289,7 +289,7 @@ if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) ``` -`app.action()` を使って、先ほど命名した「`button_click`」という `action_id` をリッスンしています。アプリを再起動し、ボタンをクリックすると、アプリからの「clicked the button」というメッセージが新たに表示されるでしょう。 +`app.action()` を使って、先ほど命名した `button_click` という `action_id` をリッスンしています。アプリを再起動し、ボタンをクリックすると、アプリからの「clicked the button」というメッセージが新たに表示されるでしょう。 --- From 689423957172cd37a604c76ef71553f3812069aa Mon Sep 17 00:00:00 2001 From: Jeremy Lujan Date: Thu, 25 Mar 2021 09:51:43 -0500 Subject: [PATCH 297/865] Add support for lazy listeners when running with chalice local --- .../adapter/aws_lambda/chalice_handler.py | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/slack_bolt/adapter/aws_lambda/chalice_handler.py b/slack_bolt/adapter/aws_lambda/chalice_handler.py index 8d5fc9b21..a67abb117 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_handler.py +++ b/slack_bolt/adapter/aws_lambda/chalice_handler.py @@ -1,6 +1,12 @@ import logging +import json +from os import getenv from chalice.app import Request, Response, Chalice +from chalice.config import Config +from chalice.test import ( + BaseClient, LambdaContext, InvokeResponse +) from slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner import ( ChaliceLazyListenerRunner, @@ -13,14 +19,41 @@ from slack_bolt.response import BoltResponse +class LocalLambdaClient(BaseClient): + """Lambda client implementing `invoke` for use when running with Chalice CLI""" + def __init__(self, app, config): + # type: (Chalice, Config) -> None + self._app = app + self._config = config + + def invoke(self, FunctionName: str = None, InvocationType: str = "Event", Payload: str = None): + # type: (str, Any) -> InvokeResponse + if Payload is None: + Payload = '{}' + scoped = self._config.scope(self._config.chalice_stage, FunctionName) + lambda_context = LambdaContext( + FunctionName, memory_size=scoped.lambda_memory_size) + + with self._patched_env_vars(scoped.environment_variables): + response = self._app(json.loads(Payload), lambda_context) + return InvokeResponse(payload=response) + + class ChaliceSlackRequestHandler: def __init__(self, app: App, chalice: Chalice): # type: ignore self.app = app self.chalice = chalice self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler) + + lambda_client = None + if getenv('AWS_CHALICE_CLI_MODE') == 'true': + lambda_client = LocalLambdaClient(self.chalice, Config()) + self.app.listener_runner.lazy_listener_runner = ChaliceLazyListenerRunner( - logger=self.logger + logger=self.logger, + lambda_client=lambda_client ) + if self.app.oauth_flow is not None: self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?" From 86522d0bb02afa6532b0d758fec7cdf484c6daff Mon Sep 17 00:00:00 2001 From: Jeremy Lujan Date: Thu, 25 Mar 2021 14:44:37 -0500 Subject: [PATCH 298/865] Fix types in LocalLambdaClient --- .../adapter/aws_lambda/chalice_handler.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/slack_bolt/adapter/aws_lambda/chalice_handler.py b/slack_bolt/adapter/aws_lambda/chalice_handler.py index a67abb117..12a4f3ed4 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_handler.py +++ b/slack_bolt/adapter/aws_lambda/chalice_handler.py @@ -4,9 +4,7 @@ from chalice.app import Request, Response, Chalice from chalice.config import Config -from chalice.test import ( - BaseClient, LambdaContext, InvokeResponse -) +from chalice.test import BaseClient, LambdaContext, InvokeResponse from slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner import ( ChaliceLazyListenerRunner, @@ -21,18 +19,23 @@ class LocalLambdaClient(BaseClient): """Lambda client implementing `invoke` for use when running with Chalice CLI""" - def __init__(self, app, config): - # type: (Chalice, Config) -> None + + def __init__(self, app: Chalice, config: Config) -> None: self._app = app self._config = config - def invoke(self, FunctionName: str = None, InvocationType: str = "Event", Payload: str = None): - # type: (str, Any) -> InvokeResponse + def invoke( + self, + FunctionName: str = None, + InvocationType: str = "Event", + Payload: str = None, + ) -> InvokeResponse: if Payload is None: - Payload = '{}' + Payload = "{}" scoped = self._config.scope(self._config.chalice_stage, FunctionName) lambda_context = LambdaContext( - FunctionName, memory_size=scoped.lambda_memory_size) + FunctionName, memory_size=scoped.lambda_memory_size + ) with self._patched_env_vars(scoped.environment_variables): response = self._app(json.loads(Payload), lambda_context) @@ -46,12 +49,11 @@ def __init__(self, app: App, chalice: Chalice): # type: ignore self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler) lambda_client = None - if getenv('AWS_CHALICE_CLI_MODE') == 'true': + if getenv("AWS_CHALICE_CLI_MODE") == "true": lambda_client = LocalLambdaClient(self.chalice, Config()) self.app.listener_runner.lazy_listener_runner = ChaliceLazyListenerRunner( - logger=self.logger, - lambda_client=lambda_client + logger=self.logger, lambda_client=lambda_client ) if self.app.oauth_flow is not None: From 30950633472213664673f9f446692f6e003538e6 Mon Sep 17 00:00:00 2001 From: Jeremy Lujan Date: Tue, 30 Mar 2021 14:45:58 -0500 Subject: [PATCH 299/865] Add tests for chalice local lazy through LocalLambdaClient --- .../adapter/aws_lambda/chalice_handler.py | 4 +- tests/adapter_tests/aws/test_aws_chalice.py | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/slack_bolt/adapter/aws_lambda/chalice_handler.py b/slack_bolt/adapter/aws_lambda/chalice_handler.py index 12a4f3ed4..f4c7220ba 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_handler.py +++ b/slack_bolt/adapter/aws_lambda/chalice_handler.py @@ -28,10 +28,8 @@ def invoke( self, FunctionName: str = None, InvocationType: str = "Event", - Payload: str = None, + Payload: str = "{}", ) -> InvokeResponse: - if Payload is None: - Payload = "{}" scoped = self._config.scope(self._config.chalice_stage, FunctionName) lambda_context = LambdaContext( FunctionName, memory_size=scoped.lambda_memory_size diff --git a/tests/adapter_tests/aws/test_aws_chalice.py b/tests/adapter_tests/aws/test_aws_chalice.py index ad9263550..83ec0a3fe 100644 --- a/tests/adapter_tests/aws/test_aws_chalice.py +++ b/tests/adapter_tests/aws/test_aws_chalice.py @@ -1,12 +1,16 @@ import json +import os from time import time from typing import Dict, Any from urllib.parse import quote +from unittest import mock +import logging from chalice import Chalice, Response from chalice.app import Request from chalice.config import Config from chalice.local import LocalGateway +from chalice.test import Client from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient @@ -265,6 +269,67 @@ def say_it(say): assert_auth_test_count(self, 1) assert self.mock_received_requests["/chat.postMessage"] == 1 + def test_lazy_listeners_cli(self): + with mock.patch.dict(os.environ, {"AWS_CHALICE_CLI_MODE": "true"}): + assert os.environ.get("AWS_CHALICE_CLI_MODE") == "true" + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True + ) + + def command_handler(ack): + ack() + + def say_it(say): + say("Done!") + + app.command("/hello-world")(ack=command_handler, lazy=[say_it]) + + 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 + + chalice_app = Chalice(app_name="bolt-python-chalice") + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + @chalice_app.route( + "/slack/events", + methods=["POST"], + content_types=["application/x-www-form-urlencoded", "application/json"], + ) + def events() -> Response: + return slack_handler.handle(chalice_app.current_request) + + headers = self.build_headers(timestamp, body) + client = Client(chalice_app, Config()) + response = client.http.post("/slack/events", headers=headers, body=body) + # + # response: Dict[str, Any] = LocalGateway(chalice_app, Config()).handle_request( + # method="POST", + # path="/slack/events", + # body=body, + # headers=self.build_headers(timestamp, body), + # ) + + # assert response["statusCode"] == 200, f"error: {response['body']}" + assert response.status_code == 200, f"Failed request: {response.body}" + assert_auth_test_count(self, 1) + assert self.mock_received_requests["/chat.postMessage"] == 1 + def test_oauth(self): app = App( client=self.web_client, From 2e32d1bf1b4b3703af2479ef0f93eb43de881e4d Mon Sep 17 00:00:00 2001 From: Jeremy Lujan Date: Tue, 30 Mar 2021 15:41:21 -0500 Subject: [PATCH 300/865] Remove commented code in test_aws_chalice --- tests/adapter_tests/aws/test_aws_chalice.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/adapter_tests/aws/test_aws_chalice.py b/tests/adapter_tests/aws/test_aws_chalice.py index 83ec0a3fe..7b9daad7b 100644 --- a/tests/adapter_tests/aws/test_aws_chalice.py +++ b/tests/adapter_tests/aws/test_aws_chalice.py @@ -317,15 +317,7 @@ def events() -> Response: headers = self.build_headers(timestamp, body) client = Client(chalice_app, Config()) response = client.http.post("/slack/events", headers=headers, body=body) - # - # response: Dict[str, Any] = LocalGateway(chalice_app, Config()).handle_request( - # method="POST", - # path="/slack/events", - # body=body, - # headers=self.build_headers(timestamp, body), - # ) - - # assert response["statusCode"] == 200, f"error: {response['body']}" + assert response.status_code == 200, f"Failed request: {response.body}" assert_auth_test_count(self, 1) assert self.mock_received_requests["/chat.postMessage"] == 1 From efab0105fd3ff9359a2f669eda46bbed2f6c3bec Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 2 Apr 2021 11:44:13 +0900 Subject: [PATCH 301/865] Apply black slack_bolt/ tests/ --- slack_bolt/adapter/__init__.py | 2 +- slack_bolt/error/__init__.py | 2 ++ slack_bolt/util/__init__.py | 2 +- slack_bolt/workflows/__init__.py | 2 +- slack_bolt/workflows/step/async_step.py | 1 + slack_bolt/workflows/step/async_step_middleware.py | 1 + slack_bolt/workflows/step/step.py | 1 + slack_bolt/workflows/step/step_middleware.py | 1 + slack_bolt/workflows/step/utilities/__init__.py | 2 +- slack_bolt/workflows/step/utilities/async_complete.py | 1 + slack_bolt/workflows/step/utilities/async_configure.py | 1 + slack_bolt/workflows/step/utilities/async_fail.py | 1 + slack_bolt/workflows/step/utilities/async_update.py | 1 + slack_bolt/workflows/step/utilities/complete.py | 1 + slack_bolt/workflows/step/utilities/configure.py | 1 + slack_bolt/workflows/step/utilities/fail.py | 1 + slack_bolt/workflows/step/utilities/update.py | 1 + tests/adapter_tests/aws/test_aws_chalice.py | 2 +- 18 files changed, 19 insertions(+), 5 deletions(-) diff --git a/slack_bolt/adapter/__init__.py b/slack_bolt/adapter/__init__.py index 8c6c51914..f339226bc 100644 --- a/slack_bolt/adapter/__init__.py +++ b/slack_bolt/adapter/__init__.py @@ -1,2 +1,2 @@ """Adapter modules for running Bolt apps along with Web frameworks or Socket Mode. -""" \ No newline at end of file +""" diff --git a/slack_bolt/error/__init__.py b/slack_bolt/error/__init__.py index d6f72b2f5..4201356dc 100644 --- a/slack_bolt/error/__init__.py +++ b/slack_bolt/error/__init__.py @@ -1,3 +1,5 @@ """Bolt specific error types.""" + + class BoltError(Exception): """General class in a Bolt app""" diff --git a/slack_bolt/util/__init__.py b/slack_bolt/util/__init__.py index f3c170424..2f85bff8d 100644 --- a/slack_bolt/util/__init__.py +++ b/slack_bolt/util/__init__.py @@ -1 +1 @@ -"""Internal utilities for the Bolt framework.""" \ No newline at end of file +"""Internal utilities for the Bolt framework.""" diff --git a/slack_bolt/workflows/__init__.py b/slack_bolt/workflows/__init__.py index e170de2b0..8c4b1fd03 100644 --- a/slack_bolt/workflows/__init__.py +++ b/slack_bolt/workflows/__init__.py @@ -7,4 +7,4 @@ * `slack_bolt.workflows.step.async_step` (if you use asyncio-based `AsyncApp`) Refer to https://api.slack.com/workflows/steps for details. -""" \ No newline at end of file +""" diff --git a/slack_bolt/workflows/step/async_step.py b/slack_bolt/workflows/step/async_step.py index 1a4e49517..b445962d0 100644 --- a/slack_bolt/workflows/step/async_step.py +++ b/slack_bolt/workflows/step/async_step.py @@ -29,6 +29,7 @@ class AsyncWorkflowStepBuilder: """Steps from Apps Refer to https://api.slack.com/workflows/steps for details. """ + callback_id: Union[str, Pattern] _edit: Optional[AsyncListener] _save: Optional[AsyncListener] diff --git a/slack_bolt/workflows/step/async_step_middleware.py b/slack_bolt/workflows/step/async_step_middleware.py index d9527d3cc..150729764 100644 --- a/slack_bolt/workflows/step/async_step_middleware.py +++ b/slack_bolt/workflows/step/async_step_middleware.py @@ -11,6 +11,7 @@ class AsyncWorkflowStepMiddleware(AsyncMiddleware): # type:ignore """Base middleware for workflow step specific ones""" + def __init__(self, step: AsyncWorkflowStep, listener_runner: AsyncioListenerRunner): self.step = step self.listener_runner = listener_runner diff --git a/slack_bolt/workflows/step/step.py b/slack_bolt/workflows/step/step.py index df0815e01..692ee5d1e 100644 --- a/slack_bolt/workflows/step/step.py +++ b/slack_bolt/workflows/step/step.py @@ -24,6 +24,7 @@ class WorkflowStepBuilder: """Steps from Apps Refer to https://api.slack.com/workflows/steps for details. """ + callback_id: Union[str, Pattern] _edit: Optional[Listener] _save: Optional[Listener] diff --git a/slack_bolt/workflows/step/step_middleware.py b/slack_bolt/workflows/step/step_middleware.py index 3aa32be62..f43fcbe7b 100644 --- a/slack_bolt/workflows/step/step_middleware.py +++ b/slack_bolt/workflows/step/step_middleware.py @@ -11,6 +11,7 @@ class WorkflowStepMiddleware(Middleware): # type:ignore """Base middleware for workflow step specific ones""" + def __init__(self, step: WorkflowStep, listener_runner: ThreadListenerRunner): self.step = step self.listener_runner = listener_runner diff --git a/slack_bolt/workflows/step/utilities/__init__.py b/slack_bolt/workflows/step/utilities/__init__.py index eedc9c85e..3495416ed 100644 --- a/slack_bolt/workflows/step/utilities/__init__.py +++ b/slack_bolt/workflows/step/utilities/__init__.py @@ -16,4 +16,4 @@ * `slack_bolt.workflows.step.utilities.complete` for notifying the execution completion to Slack For asyncio-based apps, refer to the corresponding `async` prefixed ones. -""" \ No newline at end of file +""" diff --git a/slack_bolt/workflows/step/utilities/async_complete.py b/slack_bolt/workflows/step/utilities/async_complete.py index 463e30ca4..0fb0b054c 100644 --- a/slack_bolt/workflows/step/utilities/async_complete.py +++ b/slack_bolt/workflows/step/utilities/async_complete.py @@ -24,6 +24,7 @@ async def execute(step, complete, fail): This utility is a thin wrapper of workflows.stepCompleted API method. Refer to https://api.slack.com/methods/workflows.stepCompleted for details. """ + def __init__(self, *, client: AsyncWebClient, body: dict): self.client = client self.body = body diff --git a/slack_bolt/workflows/step/utilities/async_configure.py b/slack_bolt/workflows/step/utilities/async_configure.py index 445f55635..721d5049c 100644 --- a/slack_bolt/workflows/step/utilities/async_configure.py +++ b/slack_bolt/workflows/step/utilities/async_configure.py @@ -34,6 +34,7 @@ async def edit(ack, step, configure): Refer to https://api.slack.com/workflows/steps for details. """ + def __init__(self, *, callback_id: str, client: AsyncWebClient, body: dict): self.callback_id = callback_id self.client = client diff --git a/slack_bolt/workflows/step/utilities/async_fail.py b/slack_bolt/workflows/step/utilities/async_fail.py index f2e272db0..751aeb4c3 100644 --- a/slack_bolt/workflows/step/utilities/async_fail.py +++ b/slack_bolt/workflows/step/utilities/async_fail.py @@ -21,6 +21,7 @@ async def execute(step, complete, fail): This utility is a thin wrapper of workflows.stepFailed API method. Refer to https://api.slack.com/methods/workflows.stepFailed for details. """ + def __init__(self, *, client: AsyncWebClient, body: dict): self.client = client self.body = body diff --git a/slack_bolt/workflows/step/utilities/async_update.py b/slack_bolt/workflows/step/utilities/async_update.py index 0528473ca..d3409bca3 100644 --- a/slack_bolt/workflows/step/utilities/async_update.py +++ b/slack_bolt/workflows/step/utilities/async_update.py @@ -40,6 +40,7 @@ async def save(ack, view, update): This utility is a thin wrapper of workflows.stepFailed API method. Refer to https://api.slack.com/methods/workflows.updateStep for details. """ + def __init__(self, *, client: AsyncWebClient, body: dict): self.client = client self.body = body diff --git a/slack_bolt/workflows/step/utilities/complete.py b/slack_bolt/workflows/step/utilities/complete.py index 7517e2677..687debf58 100644 --- a/slack_bolt/workflows/step/utilities/complete.py +++ b/slack_bolt/workflows/step/utilities/complete.py @@ -24,6 +24,7 @@ def execute(step, complete, fail): This utility is a thin wrapper of workflows.stepCompleted API method. Refer to https://api.slack.com/methods/workflows.stepCompleted for details. """ + def __init__(self, *, client: WebClient, body: dict): self.client = client self.body = body diff --git a/slack_bolt/workflows/step/utilities/configure.py b/slack_bolt/workflows/step/utilities/configure.py index 2cb82f597..92f9f0a1b 100644 --- a/slack_bolt/workflows/step/utilities/configure.py +++ b/slack_bolt/workflows/step/utilities/configure.py @@ -34,6 +34,7 @@ def edit(ack, step, configure): Refer to https://api.slack.com/workflows/steps for details. """ + def __init__(self, *, callback_id: str, client: WebClient, body: dict): self.callback_id = callback_id self.client = client diff --git a/slack_bolt/workflows/step/utilities/fail.py b/slack_bolt/workflows/step/utilities/fail.py index 7a1d9d9fa..eedbbc57e 100644 --- a/slack_bolt/workflows/step/utilities/fail.py +++ b/slack_bolt/workflows/step/utilities/fail.py @@ -21,6 +21,7 @@ def execute(step, complete, fail): This utility is a thin wrapper of workflows.stepFailed API method. Refer to https://api.slack.com/methods/workflows.stepFailed for details. """ + def __init__(self, *, client: WebClient, body: dict): self.client = client self.body = body diff --git a/slack_bolt/workflows/step/utilities/update.py b/slack_bolt/workflows/step/utilities/update.py index e15eddb12..bfc81d9d3 100644 --- a/slack_bolt/workflows/step/utilities/update.py +++ b/slack_bolt/workflows/step/utilities/update.py @@ -40,6 +40,7 @@ def save(ack, view, update): This utility is a thin wrapper of workflows.stepFailed API method. Refer to https://api.slack.com/methods/workflows.updateStep for details. """ + def __init__(self, *, client: WebClient, body: dict): self.client = client self.body = body diff --git a/tests/adapter_tests/aws/test_aws_chalice.py b/tests/adapter_tests/aws/test_aws_chalice.py index 7b9daad7b..223f961ee 100644 --- a/tests/adapter_tests/aws/test_aws_chalice.py +++ b/tests/adapter_tests/aws/test_aws_chalice.py @@ -275,7 +275,7 @@ def test_lazy_listeners_cli(self): app = App( client=self.web_client, signing_secret=self.signing_secret, - process_before_response=True + process_before_response=True, ) def command_handler(ack): From 12ce26aae6a55e20461b4dbc580d28d04dc57a87 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 9 Apr 2021 13:30:14 +0900 Subject: [PATCH 302/865] Fix #267 Mention the necessity of lambda:InvokeFunction permission in lazy listener document --- docs/_advanced/lazy_listener.md | 22 ++++++++++++++++++- .../aws_lambda/lazy_aws_lambda_config.yaml | 3 ++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/_advanced/lazy_listener.md b/docs/_advanced/lazy_listener.md index 84aa25952..9ae5f9f81 100644 --- a/docs/_advanced/lazy_listener.md +++ b/docs/_advanced/lazy_listener.md @@ -54,7 +54,8 @@ pip install slack_bolt # https://pypi.org/project/python-lambda/ pip install python-lambda -# Configure config.yml properly (AWSLambdaFullAccess required) +# Configure config.yml properly +# lambda:InvokeFunction & lambda:GetFunction are required for running lazy listeners export SLACK_SIGNING_SECRET=*** export SLACK_BOT_TOKEN=xoxb-*** echo 'slack_bolt' > requirements.txt @@ -90,4 +91,23 @@ def handler(event, context): slack_handler = SlackRequestHandler(app=app) return slack_handler.handle(event, context) ``` + +Please note that the followig IAM permissions would be required for running this example app. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction", + "lambda:GetFunction" + ], + "Resource": "*" + } + ] +} +```
    diff --git a/examples/aws_lambda/lazy_aws_lambda_config.yaml b/examples/aws_lambda/lazy_aws_lambda_config.yaml index abc1654da..a0e0c9abd 100644 --- a/examples/aws_lambda/lazy_aws_lambda_config.yaml +++ b/examples/aws_lambda/lazy_aws_lambda_config.yaml @@ -5,7 +5,8 @@ handler: lazy_aws_lambda.handler description: My first lambda function runtime: python3.8 # role: lambda_basic_execution -role: bolt_python_lambda_invocation # AWSLambdaFullAccess +# Have lambda:InvokeFunction & lambda:GetFunction in the allowed actions +role: bolt_python_lambda_invocation # S3 upload requires appropriate role with s3:PutObject permission # (ex. basic_s3_upload), a destination bucket, and the key prefix From ea73760e6dfa7b980e49f0584c1c46a333c9c41f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 2 Apr 2021 17:35:57 +0900 Subject: [PATCH 303/865] Fix #280 Django thread-local connection cleanup in multi threads --- examples/django/.gitignore | 3 +- examples/django/mysql-docker-compose.yml | 16 ++++ .../slackapp/migrations/0001_initial.py | 34 +++---- examples/django/slackapp/models.py | 67 +++++++++----- examples/django/slackapp/settings.py | 25 ++++-- slack_bolt/adapter/django/handler.py | 90 ++++++++++++++++++- slack_bolt/app/app.py | 11 +++ slack_bolt/app/async_app.py | 11 +++ slack_bolt/listener/async_internals.py | 59 ++++++++++++ .../async_listener_completion_handler.py | 67 ++++++++++++++ .../listener/async_listener_error_handler.py | 72 ++++----------- slack_bolt/listener/asyncio_runner.py | 14 +++ slack_bolt/listener/internals.py | 76 ++++++++++++++++ .../listener/listener_completion_handler.py | 63 +++++++++++++ slack_bolt/listener/listener_error_handler.py | 70 ++++----------- slack_bolt/listener/thread_runner.py | 14 +++ tests/adapter_tests/django/test_django.py | 82 +++++++++++++++++ 17 files changed, 620 insertions(+), 154 deletions(-) create mode 100644 examples/django/mysql-docker-compose.yml create mode 100644 slack_bolt/listener/async_internals.py create mode 100644 slack_bolt/listener/async_listener_completion_handler.py create mode 100644 slack_bolt/listener/internals.py create mode 100644 slack_bolt/listener/listener_completion_handler.py diff --git a/examples/django/.gitignore b/examples/django/.gitignore index ba520ccd8..68712b32b 100644 --- a/examples/django/.gitignore +++ b/examples/django/.gitignore @@ -1 +1,2 @@ -db.sqlite3 \ No newline at end of file +db.sqlite3 +db/ diff --git a/examples/django/mysql-docker-compose.yml b/examples/django/mysql-docker-compose.yml new file mode 100644 index 000000000..e1f543f56 --- /dev/null +++ b/examples/django/mysql-docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.9' +services: + db: + image: mysql:8 + environment: + MYSQL_DATABASE: slackapp + MYSQL_USER: app + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: password + #command: + # - '--wait_timeout=3' + volumes: + - './db:/var/lib/mysql' + ports: + - 33306:3306 + diff --git a/examples/django/slackapp/migrations/0001_initial.py b/examples/django/slackapp/migrations/0001_initial.py index a2bcb49ef..ebc013537 100644 --- a/examples/django/slackapp/migrations/0001_initial.py +++ b/examples/django/slackapp/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1.4 on 2020-12-04 13:07 +# Generated by Django 3.1.7 on 2021-04-02 05:53 from django.db import migrations, models @@ -22,15 +22,15 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("client_id", models.TextField()), - ("app_id", models.TextField()), - ("enterprise_id", models.TextField(null=True)), + ("client_id", models.CharField(max_length=32)), + ("app_id", models.CharField(max_length=32)), + ("enterprise_id", models.CharField(max_length=32, null=True)), ("enterprise_name", models.TextField(null=True)), - ("team_id", models.TextField(null=True)), + ("team_id", models.CharField(max_length=32, null=True)), ("team_name", models.TextField(null=True)), ("bot_token", models.TextField(null=True)), - ("bot_id", models.TextField(null=True)), - ("bot_user_id", models.TextField(null=True)), + ("bot_id", models.CharField(max_length=32, null=True)), + ("bot_user_id", models.CharField(max_length=32, null=True)), ("bot_scopes", models.TextField(null=True)), ("is_enterprise_install", models.BooleanField(null=True)), ("installed_at", models.DateTimeField()), @@ -48,18 +48,18 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("client_id", models.TextField()), - ("app_id", models.TextField()), - ("enterprise_id", models.TextField(null=True)), + ("client_id", models.CharField(max_length=32)), + ("app_id", models.CharField(max_length=32)), + ("enterprise_id", models.CharField(max_length=32, null=True)), ("enterprise_name", models.TextField(null=True)), ("enterprise_url", models.TextField(null=True)), - ("team_id", models.TextField(null=True)), + ("team_id", models.CharField(max_length=32, null=True)), ("team_name", models.TextField(null=True)), ("bot_token", models.TextField(null=True)), - ("bot_id", models.TextField(null=True)), + ("bot_id", models.CharField(max_length=32, null=True)), ("bot_user_id", models.TextField(null=True)), ("bot_scopes", models.TextField(null=True)), - ("user_id", models.TextField()), + ("user_id", models.CharField(max_length=32)), ("user_token", models.TextField(null=True)), ("user_scopes", models.TextField(null=True)), ("incoming_webhook_url", models.TextField(null=True)), @@ -67,7 +67,7 @@ class Migration(migrations.Migration): ("incoming_webhook_channel_id", models.TextField(null=True)), ("incoming_webhook_configuration_url", models.TextField(null=True)), ("is_enterprise_install", models.BooleanField(null=True)), - ("token_type", models.TextField(null=True)), + ("token_type", models.CharField(max_length=32, null=True)), ("installed_at", models.DateTimeField()), ], ), @@ -83,7 +83,7 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("state", models.TextField()), + ("state", models.CharField(max_length=64)), ("expire_at", models.DateTimeField()), ], ), @@ -97,14 +97,14 @@ class Migration(migrations.Migration): "user_id", "installed_at", ], - name="bolt_slacki_client__62c411_idx", + name="slackapp_sl_client__9b0d3f_idx", ), ), migrations.AddIndex( model_name="slackbot", index=models.Index( fields=["client_id", "enterprise_id", "team_id", "installed_at"], - name="bolt_slackb_client__be066b_idx", + name="slackapp_sl_client__d220d6_idx", ), ), ] diff --git a/examples/django/slackapp/models.py b/examples/django/slackapp/models.py index 737c4bd90..cfbbd8ade 100644 --- a/examples/django/slackapp/models.py +++ b/examples/django/slackapp/models.py @@ -6,15 +6,15 @@ class SlackBot(models.Model): - client_id = models.TextField(null=False) - app_id = models.TextField(null=False) - enterprise_id = models.TextField(null=True) + client_id = models.CharField(null=False, max_length=32) + app_id = models.CharField(null=False, max_length=32) + enterprise_id = models.CharField(null=True, max_length=32) enterprise_name = models.TextField(null=True) - team_id = models.TextField(null=True) + team_id = models.CharField(null=True, max_length=32) team_name = models.TextField(null=True) bot_token = models.TextField(null=True) - bot_id = models.TextField(null=True) - bot_user_id = models.TextField(null=True) + bot_id = models.CharField(null=True, max_length=32) + bot_user_id = models.CharField(null=True, max_length=32) bot_scopes = models.TextField(null=True) is_enterprise_install = models.BooleanField(null=True) installed_at = models.DateTimeField(null=False) @@ -28,18 +28,18 @@ class Meta: class SlackInstallation(models.Model): - client_id = models.TextField(null=False) - app_id = models.TextField(null=False) - enterprise_id = models.TextField(null=True) + client_id = models.CharField(null=False, max_length=32) + app_id = models.CharField(null=False, max_length=32) + enterprise_id = models.CharField(null=True, max_length=32) enterprise_name = models.TextField(null=True) enterprise_url = models.TextField(null=True) - team_id = models.TextField(null=True) + team_id = models.CharField(null=True, max_length=32) team_name = models.TextField(null=True) bot_token = models.TextField(null=True) - bot_id = models.TextField(null=True) + bot_id = models.CharField(null=True, max_length=32) bot_user_id = models.TextField(null=True) bot_scopes = models.TextField(null=True) - user_id = models.TextField(null=False) + user_id = models.CharField(null=False, max_length=32) user_token = models.TextField(null=True) user_scopes = models.TextField(null=True) incoming_webhook_url = models.TextField(null=True) @@ -47,7 +47,7 @@ class SlackInstallation(models.Model): incoming_webhook_channel_id = models.TextField(null=True) incoming_webhook_configuration_url = models.TextField(null=True) is_enterprise_install = models.BooleanField(null=True) - token_type = models.TextField(null=True) + token_type = models.CharField(null=True, max_length=32) installed_at = models.DateTimeField(null=False) class Meta: @@ -65,7 +65,7 @@ class Meta: class SlackOAuthState(models.Model): - state = models.TextField(null=False) + state = models.CharField(null=False, max_length=64) expire_at = models.DateTimeField(null=False) @@ -81,6 +81,7 @@ class SlackOAuthState(models.Model): from django.utils import timezone from slack_sdk.oauth import InstallationStore, OAuthStateStore from slack_sdk.oauth.installation_store import Bot, Installation +from slack_sdk.webhook import WebhookClient class DjangoInstallationStore(InstallationStore): @@ -100,9 +101,13 @@ def logger(self) -> Logger: def save(self, installation: Installation): i = installation.to_dict() + if is_naive(i["installed_at"]): + i["installed_at"] = make_aware(i["installed_at"]) i["client_id"] = self.client_id SlackInstallation(**i).save() b = installation.to_bot().to_dict() + if is_naive(b["installed_at"]): + b["installed_at"] = make_aware(b["installed_at"]) b["client_id"] = self.client_id SlackBot(**b).save() @@ -222,7 +227,7 @@ def consume(self, state: str) -> bool: import logging import os -from slack_bolt import App +from slack_bolt import App, BoltContext from slack_bolt.oauth.oauth_settings import OAuthSettings logger = logging.getLogger(__name__) @@ -249,12 +254,34 @@ def consume(self, state: str) -> bool: ) -@app.event("app_mention") -def event_test(body, say, logger): +def event_test(body, say, context: BoltContext, logger): logger.info(body) - say("What's up?") + say(":wave: What's up?") + + found_rows = list( + SlackInstallation.objects.filter(enterprise_id=context.enterprise_id) + .filter(team_id=context.team_id) + .filter(incoming_webhook_url__isnull=False) + .order_by(F("installed_at").desc())[:1] + ) + if len(found_rows) > 0: + webhook_url = found_rows[0].incoming_webhook_url + logger.info(f"webhook_url: {webhook_url}") + client = WebhookClient(webhook_url) + client.send(text=":wave: This is a message posted using Incoming Webhook!") + + +# lazy listener example +def noop(): + pass + + +app.event("app_mention")( + ack=event_test, + lazy=[noop], +) -@app.command("/hello-bolt-python") +@app.command("/hello-django-app") def command(ack): - ack("This is a Django app!") + ack(":wave: Hello from a Django app :smile:") diff --git a/examples/django/slackapp/settings.py b/examples/django/slackapp/settings.py index 1553e7df3..56bdc50e9 100644 --- a/examples/django/slackapp/settings.py +++ b/examples/django/slackapp/settings.py @@ -22,7 +22,7 @@ }, "root": { "handlers": ["console"], - "level": "INFO", + "level": "DEBUG", }, "loggers": { "django": { @@ -30,7 +30,7 @@ "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), "propagate": False, }, - "django.db.backends": { + "django.db": { "level": "DEBUG", }, "slack_bolt": { @@ -105,10 +105,25 @@ # https://docs.djangoproject.com/en/3.0/ref/settings/#databases DATABASES = { + # python manage.py migrate + # python manage.py runserver 0.0.0.0:3000 + # "default": { + # "ENGINE": "django.db.backends.sqlite3", + # "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + # }, + + # docker-compose -f mysql-docker-compose.yml up --build + # pip install mysqlclient + # python manage.py migrate + # python manage.py runserver 0.0.0.0:3000 "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "db.sqlite3"), - } + "ENGINE": "django.db.backends.mysql", + "NAME": "slackapp", + "USER": "app", + "PASSWORD": "password", + "HOST": "127.0.0.1", + "PORT": 33306, + }, } # Password validation diff --git a/slack_bolt/adapter/django/handler.py b/slack_bolt/adapter/django/handler.py index 24eb91b0e..2862a380b 100644 --- a/slack_bolt/adapter/django/handler.py +++ b/slack_bolt/adapter/django/handler.py @@ -1,8 +1,19 @@ -from typing import Optional +import logging +from logging import Logger +from threading import current_thread, Thread +from typing import Optional, Callable from django.http import HttpRequest, HttpResponse from slack_bolt.app import App +from slack_bolt.error import BoltError +from slack_bolt.lazy_listener import ThreadLazyListenerRunner +from slack_bolt.lazy_listener.internals import build_runnable_function +from slack_bolt.listener.listener_completion_handler import ( + ListenerCompletionHandler, + DefaultListenerCompletionHandler, +) +from slack_bolt.listener.thread_runner import ThreadListenerRunner from slack_bolt.oauth import OAuthFlow from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse @@ -43,9 +54,86 @@ def to_django_response(bolt_resp: BoltResponse) -> HttpResponse: return resp +from django.db import connections + + +def release_thread_local_connections(logger: Logger, execution_type: str): + connections.close_all() + if logger.level <= logging.DEBUG: + current: Thread = current_thread() + logger.debug( + f"Released thread-bound DB connections (thread name: {current.name}, execution type: {execution_type})" + ) + + +class DjangoListenerCompletionHandler(ListenerCompletionHandler): + """Django sets DB connections as a thread-local variable per thread. + If the thread is not managed on the Django app side, the connections won't be released by Django. + This handler releases the connections every time a ThreadListenerRunner execution completes. + """ + + def handle(self, request: BoltRequest, response: Optional[BoltResponse]) -> None: + release_thread_local_connections(request.context.logger, "listener") + + +class DjangoThreadLazyListenerRunner(ThreadLazyListenerRunner): + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + func: Callable[[], None] = build_runnable_function( + func=function, + logger=self.logger, + request=request, + ) + + def wrapped_func(): + try: + func() + finally: + release_thread_local_connections( + request.context.logger, "lazy-listener" + ) + + self.executor.submit(wrapped_func) + + class SlackRequestHandler: def __init__(self, app: App): # type: ignore self.app = app + listener_runner = self.app.listener_runner + # This runner closes all thread-local connections in the thread when an execution completes + self.app.listener_runner.lazy_listener_runner = DjangoThreadLazyListenerRunner( + logger=listener_runner.logger, + executor=listener_runner.listener_executor, + ) + + if not isinstance(listener_runner, ThreadListenerRunner): + raise BoltError( + "Custom listener_runners are not compatible with this Django adapter." + ) + + if app.process_before_response is True: + # As long as the app access Django models in the same thread, + # Django cleans the connections up for you. + self.app.logger.debug("App.process_before_response is set to True") + return + + current_completion_handler = listener_runner.listener_completion_handler + if current_completion_handler is not None and not isinstance( + current_completion_handler, DefaultListenerCompletionHandler + ): + message = """As you've already set app.listener_runner.listener_completion_handler to your own one, + Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerCompletionHandler. + We strongly recommend having the following lines of code in your listener_completion_handler: + + from django.db import connections + connections.close_all() + """ + self.app.logger.warning(message) + return + # for proper management of thread-local Django DB connections + self.app.listener_runner.listener_completion_handler = ( + DjangoListenerCompletionHandler() + ) + self.app.logger.debug("DjangoListenerCompletionHandler has been enabled") def handle(self, req: HttpRequest) -> HttpResponse: if req.method == "GET": diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index b0ce87d7c..1abc01a03 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -21,6 +21,9 @@ from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner from slack_bolt.listener.custom_listener import CustomListener from slack_bolt.listener.listener import Listener +from slack_bolt.listener.listener_completion_handler import ( + DefaultListenerCompletionHandler, +) from slack_bolt.listener.listener_error_handler import ( DefaultListenerErrorHandler, CustomListenerErrorHandler, @@ -255,12 +258,16 @@ def message_hello(message, say): self._listeners: List[Listener] = [] listener_executor = ThreadPoolExecutor(max_workers=5) + self._process_before_response = process_before_response self._listener_runner = ThreadListenerRunner( logger=self._framework_logger, process_before_response=process_before_response, listener_error_handler=DefaultListenerErrorHandler( logger=self._framework_logger ), + listener_completion_handler=DefaultListenerCompletionHandler( + logger=self._framework_logger + ), listener_executor=listener_executor, lazy_listener_runner=ThreadLazyListenerRunner( logger=self._framework_logger, @@ -339,6 +346,10 @@ def listener_runner(self) -> ThreadListenerRunner: """The thread executor for asynchronously running listeners.""" return self._listener_runner + @property + def process_before_response(self) -> bool: + return self._process_before_response or False + # ------------------------- # standalone server diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 97ee2f9d5..cf4385701 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -7,6 +7,9 @@ from aiohttp import web from slack_bolt.app.async_server import AsyncSlackAppServer +from slack_bolt.listener.async_listener_completion_handler import ( + AsyncDefaultListenerCompletionHandler, +) from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner from slack_bolt.middleware.message_listener_matches.async_message_listener_matches import ( AsyncMessageListenerMatches, @@ -279,12 +282,16 @@ async def message_hello(message, say): # async function self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] self._async_listeners: List[AsyncListener] = [] + self._process_before_response = process_before_response self._async_listener_runner = AsyncioListenerRunner( logger=self._framework_logger, process_before_response=process_before_response, listener_error_handler=AsyncDefaultListenerErrorHandler( logger=self._framework_logger ), + listener_completion_handler=AsyncDefaultListenerCompletionHandler( + logger=self._framework_logger + ), lazy_listener_runner=AsyncioLazyListenerRunner( logger=self._framework_logger, ), @@ -355,6 +362,10 @@ def listener_runner(self) -> AsyncioListenerRunner: """The asyncio-based executor for asynchronously running listeners.""" return self._async_listener_runner + @property + def process_before_response(self) -> bool: + return self._process_before_response or False + # ------------------------- # standalone server diff --git a/slack_bolt/listener/async_internals.py b/slack_bolt/listener/async_internals.py new file mode 100644 index 000000000..6a8fc8ab1 --- /dev/null +++ b/slack_bolt/listener/async_internals.py @@ -0,0 +1,59 @@ +from logging import Logger +from typing import Dict, Any, Optional + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.request.payload_utils import ( + to_options, + to_shortcut, + to_action, + to_view, + to_command, + to_event, + to_message, + to_step, +) +from slack_bolt.response import BoltResponse + + +def _build_all_available_args( + logger: Logger, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + error: Optional[Exception] = None, +) -> Dict[str, Any]: + all_available_args = { + "logger": logger, + "error": error, + "client": request.context.client, + "req": request, + "request": request, + "resp": response, + "response": response, + "context": request.context, + "body": request.body, + # payload + "body": request.body, + "options": to_options(request.body), + "shortcut": to_shortcut(request.body), + "action": to_action(request.body), + "view": to_view(request.body), + "command": to_command(request.body), + "event": to_event(request.body), + "message": to_message(request.body), + "step": to_step(request.body), + # utilities + "say": request.context.say, + "respond": request.context.respond, + } + all_available_args["payload"] = ( + all_available_args["options"] + or all_available_args["shortcut"] + or all_available_args["action"] + or all_available_args["view"] + or all_available_args["command"] + or all_available_args["event"] + or all_available_args["message"] + or all_available_args["step"] + or request.body + ) + return all_available_args diff --git a/slack_bolt/listener/async_listener_completion_handler.py b/slack_bolt/listener/async_listener_completion_handler.py new file mode 100644 index 000000000..e14a58a5d --- /dev/null +++ b/slack_bolt/listener/async_listener_completion_handler.py @@ -0,0 +1,67 @@ +import inspect +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Awaitable, Optional + +from slack_bolt.listener.async_internals import ( + _build_all_available_args, +) +from slack_bolt.listener.internals import ( + _convert_all_available_args_to_kwargs, +) + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse + + +class AsyncListenerCompletionHandler(metaclass=ABCMeta): + @abstractmethod + async def handle( + self, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + """Handles an unhandled exception. + + Args: + error: The raised exception. + request: The request. + response: The response. + """ + raise NotImplementedError() + + +class AsyncCustomListenerCompletionHandler(AsyncListenerCompletionHandler): + def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]): + self.func = func + self.logger = logger + self.arg_names = inspect.getfullargspec(func).args + + async def handle( + self, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + all_available_args = _build_all_available_args( + logger=self.logger, + request=request, + response=response, + ) + kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( + all_available_args=all_available_args, + arg_names=self.arg_names, + logger=self.logger, + ) + await self.func(**kwargs) + + +class AsyncDefaultListenerCompletionHandler(AsyncListenerCompletionHandler): + def __init__(self, logger: Logger): + self.logger = logger + + async def handle( + self, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ): + pass diff --git a/slack_bolt/listener/async_listener_error_handler.py b/slack_bolt/listener/async_listener_error_handler.py index c4e55e7d9..3aa0360d8 100644 --- a/slack_bolt/listener/async_listener_error_handler.py +++ b/slack_bolt/listener/async_listener_error_handler.py @@ -3,20 +3,16 @@ from logging import Logger from typing import Callable, Dict, Any, Awaitable, Optional +from slack_bolt.listener.async_internals import ( + _build_all_available_args, +) +from slack_bolt.listener.internals import ( + _convert_all_available_args_to_kwargs, +) + from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.request.payload_utils import ( - to_options, - to_shortcut, - to_action, - to_view, - to_command, - to_event, - to_message, - to_step, -) - class AsyncListenerErrorHandler(metaclass=ABCMeta): @abstractmethod @@ -48,51 +44,17 @@ async def handle( request: AsyncBoltRequest, response: Optional[BoltResponse], ) -> None: - all_available_args = { - "logger": self.logger, - "error": error, - "client": request.context.client, - "req": request, - "request": request, - "resp": response, - "response": response, - "context": request.context, - "body": request.body, - # payload - "body": request.body, - "options": to_options(request.body), - "shortcut": to_shortcut(request.body), - "action": to_action(request.body), - "view": to_view(request.body), - "command": to_command(request.body), - "event": to_event(request.body), - "message": to_message(request.body), - "step": to_step(request.body), - # utilities - "say": request.context.say, - "respond": request.context.respond, - } - all_available_args["payload"] = ( - all_available_args["options"] - or all_available_args["shortcut"] - or all_available_args["action"] - or all_available_args["view"] - or all_available_args["command"] - or all_available_args["event"] - or all_available_args["message"] - or all_available_args["step"] - or request.body + all_available_args = _build_all_available_args( + logger=self.logger, + error=error, + request=request, + response=response, + ) + kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( + all_available_args=all_available_args, + arg_names=self.arg_names, + logger=self.logger, ) - - kwargs: Dict[str, Any] = { # type: ignore - k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore - } - found_arg_names = kwargs.keys() - for name in self.arg_names: - if name not in found_arg_names: - self.logger.warning(f"{name} is not a valid argument") - kwargs[name] = None - await self.func(**kwargs) diff --git a/slack_bolt/listener/asyncio_runner.py b/slack_bolt/listener/asyncio_runner.py index 8ebcbde45..37a532f3c 100644 --- a/slack_bolt/listener/asyncio_runner.py +++ b/slack_bolt/listener/asyncio_runner.py @@ -7,6 +7,9 @@ from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.lazy_listener.async_runner import AsyncLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener +from slack_bolt.listener.async_listener_completion_handler import ( + AsyncListenerCompletionHandler, +) from slack_bolt.listener.async_listener_error_handler import AsyncListenerErrorHandler from slack_bolt.logger.messages import ( debug_responding, @@ -22,6 +25,7 @@ class AsyncioListenerRunner: logger: Logger process_before_response: bool listener_error_handler: AsyncListenerErrorHandler + listener_completion_handler: AsyncListenerCompletionHandler lazy_listener_runner: AsyncLazyListenerRunner def __init__( @@ -29,11 +33,13 @@ def __init__( logger: Logger, process_before_response: bool, listener_error_handler: AsyncListenerErrorHandler, + listener_completion_handler: AsyncListenerCompletionHandler, lazy_listener_runner: AsyncLazyListenerRunner, ): self.logger = logger self.process_before_response = process_before_response self.listener_error_handler = listener_error_handler + self.listener_completion_handler = listener_completion_handler self.lazy_listener_runner = lazy_listener_runner async def run( @@ -68,6 +74,10 @@ async def run( response=response, ) ack.response = response + finally: + await self.listener_completion_handler.handle( + request=request, response=response + ) for lazy_func in listener.lazy_functions: if request.lazy_function_name: @@ -121,6 +131,10 @@ async def run_ack_function_asynchronously( response=response, ) ack.response = response + finally: + await self.listener_completion_handler.handle( + request=request, response=response + ) _f: Future = asyncio.ensure_future( run_ack_function_asynchronously(ack, request, response) diff --git a/slack_bolt/listener/internals.py b/slack_bolt/listener/internals.py new file mode 100644 index 000000000..9c1ac3a31 --- /dev/null +++ b/slack_bolt/listener/internals.py @@ -0,0 +1,76 @@ +from logging import Logger +from typing import Optional, Dict, Any, List + +from slack_bolt.request.request import BoltRequest +from slack_bolt.response.response import BoltResponse + +from slack_bolt.request.payload_utils import ( + to_options, + to_shortcut, + to_action, + to_view, + to_command, + to_event, + to_message, + to_step, +) + + +def _build_all_available_args( + logger: Logger, + request: BoltRequest, + response: Optional[BoltResponse], + error: Optional[Exception] = None, +) -> Dict[str, Any]: + all_available_args = { + "logger": logger, + "error": error, + "client": request.context.client, + "req": request, + "request": request, + "resp": response, + "response": response, + "context": request.context, + "body": request.body, + # payload + "body": request.body, + "options": to_options(request.body), + "shortcut": to_shortcut(request.body), + "action": to_action(request.body), + "view": to_view(request.body), + "command": to_command(request.body), + "event": to_event(request.body), + "message": to_message(request.body), + "step": to_step(request.body), + # utilities + "say": request.context.say, + "respond": request.context.respond, + } + all_available_args["payload"] = ( + all_available_args["options"] + or all_available_args["shortcut"] + or all_available_args["action"] + or all_available_args["view"] + or all_available_args["command"] + or all_available_args["event"] + or all_available_args["message"] + or all_available_args["step"] + or request.body + ) + return all_available_args + + +def _convert_all_available_args_to_kwargs( + all_available_args: Dict[str, Any], + arg_names: List[str], + logger: Logger, +) -> Dict[str, Any]: + kwargs: Dict[str, Any] = { # type: ignore + k: v for k, v in all_available_args.items() if k in arg_names # type: ignore + } + found_arg_names = kwargs.keys() + for name in arg_names: + if name not in found_arg_names: + logger.warning(f"{name} is not a valid argument") + kwargs[name] = None + return kwargs diff --git a/slack_bolt/listener/listener_completion_handler.py b/slack_bolt/listener/listener_completion_handler.py new file mode 100644 index 000000000..2419bc56e --- /dev/null +++ b/slack_bolt/listener/listener_completion_handler.py @@ -0,0 +1,63 @@ +import inspect +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Optional + +from slack_bolt.listener.internals import ( + _build_all_available_args, + _convert_all_available_args_to_kwargs, +) +from slack_bolt.request.request import BoltRequest +from slack_bolt.response.response import BoltResponse + + +class ListenerCompletionHandler(metaclass=ABCMeta): + @abstractmethod + def handle( + self, + request: BoltRequest, + response: Optional[BoltResponse], + ) -> None: + """Handles an unhandled exception. + + Args: + request: The request. + response: The response. + """ + raise NotImplementedError() + + +class CustomListenerCompletionHandler(ListenerCompletionHandler): + def __init__(self, logger: Logger, func: Callable[..., None]): + self.func = func + self.logger = logger + self.arg_names = inspect.getfullargspec(func).args + + def handle( + self, + request: BoltRequest, + response: Optional[BoltResponse], + ): + all_available_args = _build_all_available_args( + logger=self.logger, + request=request, + response=response, + ) + kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( + all_available_args=all_available_args, + arg_names=self.arg_names, + logger=self.logger, + ) + self.func(**kwargs) + + +class DefaultListenerCompletionHandler(ListenerCompletionHandler): + def __init__(self, logger: Logger): + self.logger = logger + + def handle( + self, + request: BoltRequest, + response: Optional[BoltResponse], + ): + pass diff --git a/slack_bolt/listener/listener_error_handler.py b/slack_bolt/listener/listener_error_handler.py index d4cbd98b0..fe5c9b173 100644 --- a/slack_bolt/listener/listener_error_handler.py +++ b/slack_bolt/listener/listener_error_handler.py @@ -3,20 +3,14 @@ from logging import Logger from typing import Callable, Dict, Any, Optional +from slack_bolt.listener.internals import ( + _build_all_available_args, + _convert_all_available_args_to_kwargs, +) + from slack_bolt.request.request import BoltRequest from slack_bolt.response.response import BoltResponse -from slack_bolt.request.payload_utils import ( - to_options, - to_shortcut, - to_action, - to_view, - to_command, - to_event, - to_message, - to_step, -) - class ListenerErrorHandler(metaclass=ABCMeta): @abstractmethod @@ -48,51 +42,17 @@ def handle( request: BoltRequest, response: Optional[BoltResponse], ): - all_available_args = { - "logger": self.logger, - "error": error, - "client": request.context.client, - "req": request, - "request": request, - "resp": response, - "response": response, - "context": request.context, - "body": request.body, - # payload - "body": request.body, - "options": to_options(request.body), - "shortcut": to_shortcut(request.body), - "action": to_action(request.body), - "view": to_view(request.body), - "command": to_command(request.body), - "event": to_event(request.body), - "message": to_message(request.body), - "step": to_step(request.body), - # utilities - "say": request.context.say, - "respond": request.context.respond, - } - all_available_args["payload"] = ( - all_available_args["options"] - or all_available_args["shortcut"] - or all_available_args["action"] - or all_available_args["view"] - or all_available_args["command"] - or all_available_args["event"] - or all_available_args["message"] - or all_available_args["step"] - or request.body + all_available_args = _build_all_available_args( + logger=self.logger, + error=error, + request=request, + response=response, + ) + kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( + all_available_args=all_available_args, + arg_names=self.arg_names, + logger=self.logger, ) - - kwargs: Dict[str, Any] = { # type: ignore - k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore - } - found_arg_names = kwargs.keys() - for name in self.arg_names: - if name not in found_arg_names: - self.logger.warning(f"{name} is not a valid argument") - kwargs[name] = None - self.func(**kwargs) diff --git a/slack_bolt/listener/thread_runner.py b/slack_bolt/listener/thread_runner.py index 8a02320e4..74d20dd98 100644 --- a/slack_bolt/listener/thread_runner.py +++ b/slack_bolt/listener/thread_runner.py @@ -5,6 +5,7 @@ from slack_bolt.lazy_listener import LazyListenerRunner from slack_bolt.listener import Listener +from slack_bolt.listener.listener_completion_handler import ListenerCompletionHandler from slack_bolt.listener.listener_error_handler import ListenerErrorHandler from slack_bolt.logger.messages import ( debug_responding, @@ -20,6 +21,7 @@ class ThreadListenerRunner: logger: Logger process_before_response: bool listener_error_handler: ListenerErrorHandler + listener_completion_handler: ListenerCompletionHandler listener_executor: ThreadPoolExecutor lazy_listener_runner: LazyListenerRunner @@ -28,12 +30,14 @@ def __init__( logger: Logger, process_before_response: bool, listener_error_handler: ListenerErrorHandler, + listener_completion_handler: ListenerCompletionHandler, listener_executor: ThreadPoolExecutor, lazy_listener_runner: LazyListenerRunner, ): self.logger = logger self.process_before_response = process_before_response self.listener_error_handler = listener_error_handler + self.listener_completion_handler = listener_completion_handler self.listener_executor = listener_executor self.lazy_listener_runner = lazy_listener_runner @@ -69,6 +73,11 @@ def run( # type: ignore response=response, ) ack.response = response + finally: + self.listener_completion_handler.handle( + request=request, + response=response, + ) for lazy_func in listener.lazy_functions: if request.lazy_function_name: @@ -122,6 +131,11 @@ def run_ack_function_asynchronously(): response=response, ) ack.response = response + finally: + self.listener_completion_handler.handle( + request=request, + response=response, + ) self.listener_executor.submit(run_ack_function_asynchronously) diff --git a/tests/adapter_tests/django/test_django.py b/tests/adapter_tests/django/test_django.py index f387427a9..036ab5608 100644 --- a/tests/adapter_tests/django/test_django.py +++ b/tests/adapter_tests/django/test_django.py @@ -174,6 +174,88 @@ def command_handler(ack): assert response.status_code == 200 assert_auth_test_count(self, 1) + def test_commands_process_before_response(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + + def command_handler(ack): + 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 + + request = self.rf.post( + "/slack/events", + data=body, + content_type="application/x-www-form-urlencoded", + ) + request.headers = self.build_headers(timestamp, body) + + response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_commands_lazy(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + def lazy_handler(): + pass + + app.command("/hello-world")(ack=command_handler, lazy=[lazy_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 + + request = self.rf.post( + "/slack/events", + data=body, + content_type="application/x-www-form-urlencoded", + ) + request.headers = self.build_headers(timestamp, body) + + response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + def test_oauth(self): app = App( client=self.web_client, From b3b55df9cdb8f2acaeb4d3b379903a949a411178 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 10 Apr 2021 08:17:28 +0900 Subject: [PATCH 304/865] Apply suggestions from code review Co-authored-by: Michael Brooks --- slack_bolt/listener/async_internals.py | 1 - slack_bolt/listener/internals.py | 1 - 2 files changed, 2 deletions(-) diff --git a/slack_bolt/listener/async_internals.py b/slack_bolt/listener/async_internals.py index 6a8fc8ab1..60347bfb6 100644 --- a/slack_bolt/listener/async_internals.py +++ b/slack_bolt/listener/async_internals.py @@ -30,7 +30,6 @@ def _build_all_available_args( "resp": response, "response": response, "context": request.context, - "body": request.body, # payload "body": request.body, "options": to_options(request.body), diff --git a/slack_bolt/listener/internals.py b/slack_bolt/listener/internals.py index 9c1ac3a31..9b0682c1d 100644 --- a/slack_bolt/listener/internals.py +++ b/slack_bolt/listener/internals.py @@ -31,7 +31,6 @@ def _build_all_available_args( "resp": response, "response": response, "context": request.context, - "body": request.body, # payload "body": request.body, "options": to_options(request.body), From b594174dfb3b1ea91a6927c34556a5a8f57aefc8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 10 Apr 2021 10:29:06 +0900 Subject: [PATCH 305/865] Upgrade slack-sdk to 3.5.0rc1 --- setup.py | 2 +- slack_bolt/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index cdb5bcd01..b4d1e966c 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.4.2,<4",], + install_requires=["slack_sdk>=3.5.0rc1,<4",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index c7e5cd96f..536b2e8cc 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.4.4" +__version__ = "1.5.0b1" From 8babac6c69e2ec2f5c7a24d9785438b80b4962c7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 9 Apr 2021 22:44:19 +0900 Subject: [PATCH 306/865] Enable installation_store authorize to fallback to bots (prep for #254) --- slack_bolt/authorization/async_authorize.py | 86 ++++++---- slack_bolt/authorization/authorize.py | 81 +++++---- .../test_installation_store_authorize.py | 150 +++++++++++++++++ .../test_installation_store_authorize.py | 159 ++++++++++++++++++ 4 files changed, 413 insertions(+), 63 deletions(-) create mode 100644 tests/scenario_tests/test_installation_store_authorize.py create mode 100644 tests/scenario_tests_async/test_installation_store_authorize.py diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index 80cfbd415..b5020b792 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -93,6 +93,7 @@ async def __call__( class AsyncInstallationStoreAuthorize(AsyncAuthorize): authorize_result_cache: Dict[str, AuthorizeResult] find_installation_available: Optional[bool] + find_bot_available: Optional[bool] def __init__( self, @@ -110,6 +111,7 @@ def __init__( self.cache_enabled = cache_enabled self.authorize_result_cache = {} self.find_installation_available = None + self.find_bot_available = None async def __call__( self, @@ -124,12 +126,15 @@ async def __call__( self.find_installation_available = hasattr( self.installation_store, "async_find_installation" ) + if self.find_bot_available is None: + self.find_bot_available = hasattr(self.installation_store, "async_find_bot") bot_token: Optional[str] = None user_token: Optional[str] = None if not self.bot_only and self.find_installation_available: - # since v1.1, this is the default way + # Since v1.1, this is the default way. + # If you want to use find_bot / delete_bot only, you can set bot_only as True. try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. @@ -140,47 +145,64 @@ async def __call__( team_id=team_id, is_enterprise_install=context.is_enterprise_install, ) - if installation is None: - self._debug_log_for_not_found(enterprise_id, team_id) - return None - - if installation.user_id != user_id: - # First off, remove the user token as the installer is a different user - installation.user_token = None - installation.user_scopes = [] - - # try to fetch the request user's installation - # to reflect the user's access token if exists - user_installation = ( - await self.installation_store.async_find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, + + if installation is not None: + if installation.user_id != user_id: + # First off, remove the user token as the installer is a different user + installation.user_token = None + installation.user_scopes = [] + + # try to fetch the request user's installation + # to reflect the user's access token if exists + user_installation = ( + await self.installation_store.async_find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) ) + if user_installation is not None: + # Overwrite the installation with the one for this user + installation = user_installation + + bot_token, user_token = ( + installation.bot_token, + installation.user_token, ) - if user_installation is not None: - # Overwrite the installation with the one for this user - installation = user_installation - bot_token, user_token = installation.bot_token, installation.user_token except NotImplementedError as _: self.find_installation_available = False - if self.bot_only or not self.find_installation_available: - # Use find_bot to get bot value (legacy) - bot: Optional[Bot] = await self.installation_store.async_find_bot( - enterprise_id=enterprise_id, - team_id=team_id, - is_enterprise_install=context.is_enterprise_install, + if ( + # If you intentionally use only find_bot / delete_bot, + self.bot_only + # If find_installation method is not available, + or not self.find_installation_available + # If find_installation did not return data and find_bot method is available, + or ( + self.find_bot_available is True + and bot_token is None + and user_token is None ) - if bot is None: - self._debug_log_for_not_found(enterprise_id, team_id) - return None - bot_token, user_token = bot.bot_token, None + ): + try: + bot: Optional[Bot] = await self.installation_store.async_find_bot( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + if bot is not None: + bot_token = bot.bot_token + except NotImplementedError as _: + self.find_bot_available = False + except Exception as e: + self.logger.info(f"Failed to call find_bot method: {e}") token: Optional[str] = bot_token or user_token if token is None: + # No valid token was found + self._debug_log_for_not_found(enterprise_id, team_id) return None # Check cache to see if the bot object already exists diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 5b533b576..9ff336ec3 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -96,6 +96,7 @@ class InstallationStoreAuthorize(Authorize): authorize_result_cache: Dict[str, AuthorizeResult] bot_only: bool find_installation_available: bool + find_bot_available: bool def __init__( self, @@ -115,6 +116,7 @@ def __init__( self.find_installation_available = hasattr( installation_store, "find_installation" ) + self.find_bot_available = hasattr(installation_store, "find_bot") def __call__( self, @@ -129,7 +131,8 @@ def __call__( user_token: Optional[str] = None if not self.bot_only and self.find_installation_available: - # since v1.1, this is the default way + # Since v1.1, this is the default way. + # If you want to use find_bot / delete_bot only, you can set bot_only as True. try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. @@ -140,45 +143,61 @@ def __call__( team_id=team_id, is_enterprise_install=context.is_enterprise_install, ) - if installation is None: - self._debug_log_for_not_found(enterprise_id, team_id) - return None - - if installation.user_id != user_id: - # First off, remove the user token as the installer is a different user - installation.user_token = None - installation.user_scopes = [] - - # try to fetch the request user's installation - # to reflect the user's access token if exists - user_installation = self.installation_store.find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, + if installation is not None: + if installation.user_id != user_id: + # First off, remove the user token as the installer is a different user + installation.user_token = None + installation.user_scopes = [] + + # try to fetch the request user's installation + # to reflect the user's access token if exists + user_installation = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) + if user_installation is not None: + # Overwrite the installation with the one for this user + installation = user_installation + + bot_token, user_token = ( + installation.bot_token, + installation.user_token, ) - if user_installation is not None: - # Overwrite the installation with the one for this user - installation = user_installation - bot_token, user_token = installation.bot_token, installation.user_token except NotImplementedError as _: self.find_installation_available = False - if self.bot_only or not self.find_installation_available: - # Use find_bot to get bot value (legacy) - bot: Optional[Bot] = self.installation_store.find_bot( - enterprise_id=enterprise_id, - team_id=team_id, - is_enterprise_install=context.is_enterprise_install, + if ( + # If you intentionally use only find_bot / delete_bot, + self.bot_only + # If find_installation method is not available, + or not self.find_installation_available + # If find_installation did not return data and find_bot method is available, + or ( + self.find_bot_available is True + and bot_token is None + and user_token is None ) - if bot is None: - self._debug_log_for_not_found(enterprise_id, team_id) - return None - bot_token, user_token = bot.bot_token, None + ): + try: + bot: Optional[Bot] = self.installation_store.find_bot( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + if bot is not None: + bot_token = bot.bot_token + except NotImplementedError as _: + self.find_bot_available = False + except Exception as e: + self.logger.info(f"Failed to call find_bot method: {e}") token: Optional[str] = bot_token or user_token if token is None: + # No valid token was found + self._debug_log_for_not_found(enterprise_id, team_id) return None # Check cache to see if the bot object already exists diff --git a/tests/scenario_tests/test_installation_store_authorize.py b/tests/scenario_tests/test_installation_store_authorize.py new file mode 100644 index 000000000..056c16582 --- /dev/null +++ b/tests/scenario_tests/test_installation_store_authorize.py @@ -0,0 +1,150 @@ +import json +from time import time +from typing import Optional +from urllib.parse import quote + +from slack_sdk import WebClient +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import BoltRequest +from slack_bolt.app import App +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" +valid_user_token = "xoxp-valid" + + +class MyInstallationStore(InstallationStore): + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T111", + bot_token=valid_token, + bot_id="B111", + bot_user_id="W111", + bot_scopes=["commands"], + installed_at=time(), + ) + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return None + + +class TestInstallationStoreAuthorize: + signing_secret = "secret" + 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_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> BoltRequest: + timestamp = str(int(time())) + return BoltRequest( + body=raw_body, headers=self.build_headers(timestamp, raw_body) + ) + + def test_success(self): + app = App( + client=self.web_client, + installation_store=MyInstallationStore(), + signing_secret=self.signing_secret, + ) + app.action("a")(simple_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + +body = { + "type": "block_actions", + "user": { + "id": "W99999", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "111.222", + "channel_id": "C111", + "is_ephemeral": True, + }, + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button", "emoji": True}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], +} + +raw_body = f"payload={quote(json.dumps(body))}" + + +def simple_listener(ack, body, payload, action): + assert body["trigger_id"] == "111.222.valid" + assert body["actions"][0] == payload + assert payload == action + assert action["action_id"] == "a" + ack() diff --git a/tests/scenario_tests_async/test_installation_store_authorize.py b/tests/scenario_tests_async/test_installation_store_authorize.py new file mode 100644 index 000000000..e35dd39f1 --- /dev/null +++ b/tests/scenario_tests_async/test_installation_store_authorize.py @@ -0,0 +1,159 @@ +import asyncio +import json +from time import time +from typing import Optional +from urllib.parse import quote + +import pytest +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +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, + assert_auth_test_count_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" +valid_user_token = "xoxp-valid" + + +class MyInstallationStore(AsyncInstallationStore): + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T111", + bot_token=valid_token, + bot_id="B111", + bot_user_id="W111", + bot_scopes=["commands"], + installed_at=time(), + ) + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return None + + +class TestAsyncInstallationStoreAuthorize: + signing_secret = "secret" + 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, + ) + + @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) + + 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): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + 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_success(self): + app = AsyncApp( + client=self.web_client, + installation_store=MyInstallationStore(), + signing_secret=self.signing_secret, + ) + app.action("a")(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + +body = { + "type": "block_actions", + "user": { + "id": "W99999", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "111.222", + "channel_id": "C111", + "is_ephemeral": True, + }, + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button", "emoji": True}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], +} + +raw_body = f"payload={quote(json.dumps(body))}" + + +async def simple_listener(ack, body, payload, action): + assert body["trigger_id"] == "111.222.valid" + assert body["actions"][0] == payload + assert payload == action + assert action["action_id"] == "a" + await ack() From 8786783233627060532910184fb57f2a1a2b6d0a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 12 Apr 2021 11:48:43 +0900 Subject: [PATCH 307/865] Fix #254 Add built-in tokens_revoked/app_uninstalled event handlers --- slack_bolt/app/app.py | 29 +++ slack_bolt/app/async_app.py | 31 +++ slack_bolt/listener/async_builtins.py | 37 ++++ slack_bolt/listener/builtins.py | 35 ++++ slack_bolt/logger/messages.py | 7 + tests/mock_web_api_server.py | 4 +- .../test_events_token_revocations.py | 181 ++++++++++++++++++ .../test_events_token_revocations.py | 174 +++++++++++++++++ 8 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 slack_bolt/listener/async_builtins.py create mode 100644 slack_bolt/listener/builtins.py create mode 100644 tests/scenario_tests/test_events_token_revocations.py create mode 100644 tests/scenario_tests_async/test_events_token_revocations.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 1abc01a03..e512812dd 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -19,6 +19,7 @@ ) from slack_bolt.error import BoltError from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner +from slack_bolt.listener.builtins import TokenRevocationListeners from slack_bolt.listener.custom_listener import CustomListener from slack_bolt.listener.listener import Listener from slack_bolt.listener.listener_completion_handler import ( @@ -48,6 +49,7 @@ warning_bot_only_conflicts, debug_return_listener_middleware_response, info_default_oauth_settings_loaded, + error_installation_store_required_for_builtin_listeners, ) from slack_bolt.middleware import ( Middleware, @@ -250,6 +252,12 @@ def message_hello(message, say): self._oauth_flow.settings.installation_store_bot_only = app_bot_only self._authorize.bot_only = app_bot_only + self._tokens_revocation_listeners: Optional[TokenRevocationListeners] = None + if self._installation_store is not None: + self._tokens_revocation_listeners = TokenRevocationListeners( + self._installation_store + ) + # -------------------------------------- # Middleware Initialization # -------------------------------------- @@ -1089,6 +1097,27 @@ def __call__(*args, **kwargs): return __call__ + # ------------------------- + # built-in listener functions + + def default_tokens_revoked_event_listener( + self, + ) -> Callable[..., Optional[BoltResponse]]: + if self._tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._tokens_revocation_listeners.handle_tokens_revoked_events + + def default_app_uninstalled_event_listener( + self, + ) -> Callable[..., Optional[BoltResponse]]: + if self._tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._tokens_revocation_listeners.handle_app_uninstalled_events + + def enable_token_revocation_listeners(self) -> None: + self.event("tokens_revoked")(self.default_tokens_revoked_event_listener()) + self.event("app_uninstalled")(self.default_app_uninstalled_event_listener()) + # ------------------------- def _init_context(self, req: BoltRequest): diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index cf4385701..f11497071 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -7,6 +7,7 @@ from aiohttp import web from slack_bolt.app.async_server import AsyncSlackAppServer +from slack_bolt.listener.async_builtins import AsyncTokenRevocationListeners from slack_bolt.listener.async_listener_completion_handler import ( AsyncDefaultListenerCompletionHandler, ) @@ -49,6 +50,7 @@ warning_bot_only_conflicts, debug_return_listener_middleware_response, info_default_oauth_settings_loaded, + error_installation_store_required_for_builtin_listeners, ) from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -275,6 +277,14 @@ async def message_hello(message, say): # async function ) self._async_authorize.bot_only = app_bot_only + self._async_tokens_revocation_listeners: Optional[ + AsyncTokenRevocationListeners + ] = None + if self._async_installation_store is not None: + self._async_tokens_revocation_listeners = AsyncTokenRevocationListeners( + self._async_installation_store + ) + # -------------------------------------- # Middleware Initialization # -------------------------------------- @@ -1151,6 +1161,27 @@ def __call__(*args, **kwargs): return __call__ + # ------------------------- + # built-in listener functions + + def default_tokens_revoked_event_listener( + self, + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: + if self._async_tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._async_tokens_revocation_listeners.handle_tokens_revoked_events + + def default_app_uninstalled_event_listener( + self, + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: + if self._async_tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._async_tokens_revocation_listeners.handle_app_uninstalled_events + + def enable_token_revocation_listeners(self) -> None: + self.event("tokens_revoked")(self.default_tokens_revoked_event_listener()) + self.event("app_uninstalled")(self.default_app_uninstalled_event_listener()) + # ------------------------- def _init_context(self, req: AsyncBoltRequest): diff --git a/slack_bolt/listener/async_builtins.py b/slack_bolt/listener/async_builtins.py new file mode 100644 index 000000000..87f588dad --- /dev/null +++ b/slack_bolt/listener/async_builtins.py @@ -0,0 +1,37 @@ +from slack_bolt.context.async_context import AsyncBoltContext +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) + + +class AsyncTokenRevocationListeners: + """Listener functions to handle token revocation / uninstallation events""" + + installation_store: AsyncInstallationStore + + def __init__(self, installation_store: AsyncInstallationStore): + self.installation_store = installation_store + + async def handle_tokens_revoked_events( + self, event: dict, context: AsyncBoltContext + ) -> None: + user_ids = event.get("tokens", {}).get("oauth", []) + if len(user_ids) > 0: + for user_id in user_ids: + await self.installation_store.async_delete_installation( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + user_id=user_id, + ) + bots = event.get("tokens", {}).get("bot", []) + if len(bots) > 0: + await self.installation_store.async_delete_bot( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) + + async def handle_app_uninstalled_events(self, context: AsyncBoltContext) -> None: + await self.installation_store.async_delete_all( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) diff --git a/slack_bolt/listener/builtins.py b/slack_bolt/listener/builtins.py new file mode 100644 index 000000000..66784d182 --- /dev/null +++ b/slack_bolt/listener/builtins.py @@ -0,0 +1,35 @@ +from slack_sdk.oauth import InstallationStore + +from slack_bolt.context.context import BoltContext +from slack_sdk.oauth.installation_store.installation_store import InstallationStore + + +class TokenRevocationListeners: + """Listener functions to handle token revocation / uninstallation events""" + + installation_store: InstallationStore + + def __init__(self, installation_store: InstallationStore): + self.installation_store = installation_store + + def handle_tokens_revoked_events(self, event: dict, context: BoltContext) -> None: + user_ids = event.get("tokens", {}).get("oauth", []) + if len(user_ids) > 0: + for user_id in user_ids: + self.installation_store.delete_installation( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + user_id=user_id, + ) + bots = event.get("tokens", {}).get("bot", []) + if len(bots) > 0: + self.installation_store.delete_bot( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) + + def handle_app_uninstalled_events(self, context: BoltContext) -> None: + self.installation_store.delete_all( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 57d12dfab..7d16c2861 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -58,6 +58,13 @@ def error_message_event_type(event_type: str) -> str: ) +def error_installation_store_required_for_builtin_listeners() -> str: + return ( + "To use the event listeners for token revocation handling, " + "setting a valid `installation_store` to `App`/`AsyncApp` is required." + ) + + # ------------------------------- # Warning # ------------------------------- diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index 799e38266..ebc4ce2dd 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -310,7 +310,7 @@ def assert_auth_test_count(test: TestCase, expected_count: int): error = None while retry_count < 3: try: - test.mock_received_requests["/auth.test"] == expected_count + test.mock_received_requests.get("/auth.test", 0) == expected_count break except Exception as e: error = e @@ -328,7 +328,7 @@ async def assert_auth_test_count_async(test: TestCase, expected_count: int): error = None while retry_count < 3: try: - test.mock_received_requests["/auth.test"] == expected_count + test.mock_received_requests.get("/auth.test", 0) == expected_count break except Exception as e: error = e diff --git a/tests/scenario_tests/test_events_token_revocations.py b/tests/scenario_tests/test_events_token_revocations.py new file mode 100644 index 000000000..5bd9bffe7 --- /dev/null +++ b/tests/scenario_tests/test_events_token_revocations.py @@ -0,0 +1,181 @@ +import json +from time import time, sleep +from typing import Optional + +import pytest as pytest +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Installation +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest +from slack_bolt.error import BoltError +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" + + +class MyInstallationStore(InstallationStore): + def __init__(self): + self.delete_bot_called = False + self.delete_installation_called = False + self.delete_all_called = False + + def delete_bot( + self, *, enterprise_id: Optional[str], team_id: Optional[str] + ) -> None: + self.delete_bot_called = True + + def delete_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None + ) -> None: + self.delete_installation_called = True + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False + ) -> Optional[Installation]: + assert enterprise_id == "E111" + assert team_id is None + return Installation( + enterprise_id="E111", + team_id=None, + user_id=user_id, + bot_token=valid_token, + bot_id="B111", + ) + + def delete_all(self, *, enterprise_id: Optional[str], team_id: Optional[str]): + super().delete_all(enterprise_id=enterprise_id, team_id=team_id) + self.delete_all_called = True + + +class TestEventsTokenRevocations: + signing_secret = "secret" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client: WebClient = WebClient( + token=None, + 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_no_installation_store(self): + self.web_client.token = valid_token + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + with pytest.raises(BoltError): + app.default_tokens_revoked_event_listener() + with pytest.raises(BoltError): + app.default_app_uninstalled_event_listener() + with pytest.raises(BoltError): + app.enable_token_revocation_listeners() + + def test_tokens_revoked(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MyInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["W111"], "bot": ["W222"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 404 + + # Enable the built-in event listeners + app.enable_token_revocation_listeners() + response = app.dispatch(request) + assert response.status == 200 + + # auth.test API call must be skipped + assert_auth_test_count(self, 0) + sleep(1) # wait a bit after auto ack() + assert app.installation_store.delete_bot_called is True + assert app.installation_store.delete_installation_called is True + assert app.installation_store.delete_all_called is False + + def test_app_uninstalled(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MyInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 404 + + # Enable the built-in event listeners + app.enable_token_revocation_listeners() + response = app.dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert_auth_test_count(self, 0) + sleep(1) # wait a bit after auto ack() + assert app.installation_store.delete_bot_called is True + assert app.installation_store.delete_installation_called is True + assert app.installation_store.delete_all_called is True diff --git a/tests/scenario_tests_async/test_events_token_revocations.py b/tests/scenario_tests_async/test_events_token_revocations.py new file mode 100644 index 000000000..e67e03f78 --- /dev/null +++ b/tests/scenario_tests_async/test_events_token_revocations.py @@ -0,0 +1,174 @@ +import asyncio +import json +from time import time +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.error import BoltError +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, + assert_auth_test_count_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" + + +class MyInstallationStore(AsyncInstallationStore): + def __init__(self): + self.delete_bot_called = False + self.delete_installation_called = False + self.delete_all_called = False + + async def async_delete_bot( + self, *, enterprise_id: Optional[str], team_id: Optional[str] + ) -> None: + self.delete_bot_called = True + + async def async_delete_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None + ) -> None: + self.delete_installation_called = True + + async def async_delete_all( + self, *, enterprise_id: Optional[str], team_id: Optional[str] + ): + self.delete_all_called = True + return await super().async_delete_all( + enterprise_id=enterprise_id, team_id=team_id + ) + + +class TestEventsTokenRevocations: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient(token=None, 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) + + 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + @pytest.mark.asyncio + async def test_no_installation_store(self): + self.web_client.token = valid_token + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + with pytest.raises(BoltError): + app.default_tokens_revoked_event_listener() + with pytest.raises(BoltError): + app.default_app_uninstalled_event_listener() + with pytest.raises(BoltError): + app.enable_token_revocation_listeners() + + @pytest.mark.asyncio + async def test_tokens_revoked(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MyInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["W111"], "bot": ["W222"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 404 + + # Enable the built-in event listeners + app.enable_token_revocation_listeners() + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + await assert_auth_test_count_async(self, 0) + await asyncio.sleep(1) # wait a bit after auto ack() + assert app.installation_store.delete_bot_called is True + assert app.installation_store.delete_installation_called is True + assert app.installation_store.delete_all_called is False + + @pytest.mark.asyncio + async def test_app_uninstalled(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MyInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 404 + + # Enable the built-in event listeners + app.enable_token_revocation_listeners() + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + await assert_auth_test_count_async(self, 0) + await asyncio.sleep(1) # wait a bit after auto ack() + assert app.installation_store.delete_bot_called is True + assert app.installation_store.delete_installation_called is True + assert app.installation_store.delete_all_called is True From fd10ebd1c765ffe8a1bb39dc39831da4734062e1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 10 Apr 2021 11:17:16 +0900 Subject: [PATCH 308/865] Fix #260 Enable to use respond utility in app.view listeners (only when response_urls exists) --- slack_bolt/request/async_internals.py | 23 +- slack_bolt/request/internals.py | 16 ++ tests/mock_web_api_server.py | 6 + tests/scenario_tests/test_view_submission.py | 217 ++++++++++++------ .../test_view_submission.py | 210 +++++++++++------ 5 files changed, 323 insertions(+), 149 deletions(-) diff --git a/slack_bolt/request/async_internals.py b/slack_bolt/request/async_internals.py index 19c20dd02..3b0eca3bf 100644 --- a/slack_bolt/request/async_internals.py +++ b/slack_bolt/request/async_internals.py @@ -6,25 +6,34 @@ extract_team_id, extract_user_id, extract_channel_id, + debug_multiple_response_urls_detected, ) def build_async_context( context: AsyncBoltContext, - payload: Dict[str, Any], + body: Dict[str, Any], ) -> AsyncBoltContext: - enterprise_id = extract_enterprise_id(payload) + enterprise_id = extract_enterprise_id(body) if enterprise_id: context["enterprise_id"] = enterprise_id - team_id = extract_team_id(payload) + team_id = extract_team_id(body) if team_id: context["team_id"] = team_id - user_id = extract_user_id(payload) + user_id = extract_user_id(body) if user_id: context["user_id"] = user_id - channel_id = extract_channel_id(payload) + channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id - if "response_url" in payload: - context["response_url"] = payload["response_url"] + if "response_url" in body: + context["response_url"] = body["response_url"] + elif "response_urls" in body: + # In the case where response_url_enabled: true in a modal exists + response_urls = body["response_urls"] + if len(response_urls) >= 1: + if len(response_urls) > 1: + context.logger.debug(debug_multiple_response_urls_detected()) + response_url = response_urls[0].get("response_url") + context["response_url"] = response_url return context diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 95e2d0664..4b70df1b3 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -143,6 +143,14 @@ def build_context(context: BoltContext, body: Dict[str, Any]) -> BoltContext: context["channel_id"] = channel_id if "response_url" in body: context["response_url"] = body["response_url"] + elif "response_urls" in body: + # In the case where response_url_enabled: true in a modal exists + response_urls = body["response_urls"] + if len(response_urls) >= 1: + if len(response_urls) > 1: + context.logger.debug(debug_multiple_response_urls_detected()) + response_url = response_urls[0].get("response_url") + context["response_url"] = response_url return context @@ -177,3 +185,11 @@ def error_message_raw_body_required_in_http_mode() -> str: def error_message_unknown_request_body_type() -> str: return "`body` must be either str or dict" + + +def debug_multiple_response_urls_detected() -> str: + return ( + "`response_urls` in the body has multiple URLs in it. " + "If you would like to use non-primary one, " + "please manually extract the one from body['response_urls']." + ) diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index ebc4ce2dd..9e6d008e1 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -92,6 +92,12 @@ def set_common_headers(self): def _handle(self): self.received_requests[self.path] = self.received_requests.get(self.path, 0) + 1 try: + if self.path == "/webhook": + self.send_response(200) + self.set_common_headers() + self.wfile.write("OK".encode("utf-8")) + return + if self.path == "/received_requests.json": self.send_response(200) self.set_common_headers() diff --git a/tests/scenario_tests/test_view_submission.py b/tests/scenario_tests/test_view_submission.py index 9ea3c367b..52d667122 100644 --- a/tests/scenario_tests/test_view_submission.py +++ b/tests/scenario_tests/test_view_submission.py @@ -15,6 +15,133 @@ from tests.utils import remove_os_env_temporarily, restore_os_env +body = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "hspI", + "label": { + "type": "plain_text", + "text": "Label", + }, + "optional": False, + "element": {"type": "plain_text_input", "action_id": "maBWU"}, + } + ], + "private_metadata": "This is for you!", + "callback_id": "view-id", + "state": { + "values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}} + }, + "hash": "1596530361.3wRYuk3R", + "title": { + "type": "plain_text", + "text": "My App", + }, + "clear_on_close": False, + "notify_on_close": False, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], +} + +raw_body = f"payload={quote(json.dumps(body))}" + + +def simple_listener(ack, body, payload, view): + assert body["trigger_id"] == "111.222.valid" + assert body["view"] == payload + assert payload == view + assert view["private_metadata"] == "This is for you!" + ack() + + +response_url_payload_body = { + "type": "view_submission", + "team": {"id": "T111", "domain": "test-test-test"}, + "user": { + "id": "U111", + "username": "test-test-test", + "name": "test-test-test", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [], + "callback_id": "view-id", + "state": {}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [ + { + "block_id": "b", + "action_id": "a", + "channel_id": "C111", + "response_url": "http://localhost:8888/webhook", + } + ], + "is_enterprise_install": False, +} + + +raw_response_url_body = f"payload={quote(json.dumps(response_url_payload_body))}" + + class TestViewSubmission: signing_secret = "secret" valid_token = "xoxb-valid" @@ -46,11 +173,9 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_valid_request(self) -> BoltRequest: + def build_valid_request(self, body: str = raw_body) -> BoltRequest: timestamp = str(int(time())) - return BoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) - ) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) def test_mock_server_is_running(self): resp = self.web_client.api_test() @@ -123,76 +248,18 @@ def test_failure_2(self): assert response.status == 404 assert_auth_test_count(self, 1) + def test_response_urls(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) -body = { - "type": "view_submission", - "team": { - "id": "T111", - "domain": "workspace-domain", - "enterprise_id": "E111", - "enterprise_name": "Sandbox Org", - }, - "user": { - "id": "W111", - "username": "primary-owner", - "name": "primary-owner", - "team_id": "T111", - }, - "api_app_id": "A111", - "token": "verification_token", - "trigger_id": "111.222.valid", - "view": { - "id": "V111", - "team_id": "T111", - "type": "modal", - "blocks": [ - { - "type": "input", - "block_id": "hspI", - "label": { - "type": "plain_text", - "text": "Label", - }, - "optional": False, - "element": {"type": "plain_text_input", "action_id": "maBWU"}, - } - ], - "private_metadata": "This is for you!", - "callback_id": "view-id", - "state": { - "values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}} - }, - "hash": "1596530361.3wRYuk3R", - "title": { - "type": "plain_text", - "text": "My App", - }, - "clear_on_close": False, - "notify_on_close": False, - "close": { - "type": "plain_text", - "text": "Cancel", - }, - "submit": { - "type": "plain_text", - "text": "Submit", - }, - "previous_view_id": None, - "root_view_id": "V111", - "app_id": "A111", - "external_id": "", - "app_installed_team_id": "T111", - "bot_id": "B111", - }, - "response_urls": [], -} - -raw_body = f"payload={quote(json.dumps(body))}" - + @app.view("view-id") + def check(ack, respond): + respond("Hi") + ack() -def simple_listener(ack, body, payload, view): - assert body["trigger_id"] == "111.222.valid" - assert body["view"] == payload - assert payload == view - assert view["private_metadata"] == "This is for you!" - ack() + request = self.build_valid_request(raw_response_url_body) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) diff --git a/tests/scenario_tests_async/test_view_submission.py b/tests/scenario_tests_async/test_view_submission.py index b9538537e..0e89b6bb3 100644 --- a/tests/scenario_tests_async/test_view_submission.py +++ b/tests/scenario_tests_async/test_view_submission.py @@ -17,6 +17,133 @@ from tests.utils import remove_os_env_temporarily, restore_os_env +body = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "hspI", + "label": { + "type": "plain_text", + "text": "Label", + }, + "optional": False, + "element": {"type": "plain_text_input", "action_id": "maBWU"}, + } + ], + "private_metadata": "This is for you!", + "callback_id": "view-id", + "state": { + "values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}} + }, + "hash": "1596530361.3wRYuk3R", + "title": { + "type": "plain_text", + "text": "My App", + }, + "clear_on_close": False, + "notify_on_close": False, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], +} + +raw_body = f"payload={quote(json.dumps(body))}" + + +def simple_listener(ack, body, payload, view): + assert body["trigger_id"] == "111.222.valid" + assert body["view"] == payload + assert payload == view + assert view["private_metadata"] == "This is for you!" + ack() + + +response_url_payload_body = { + "type": "view_submission", + "team": {"id": "T111", "domain": "test-test-test"}, + "user": { + "id": "U111", + "username": "test-test-test", + "name": "test-test-test", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [], + "callback_id": "view-id", + "state": {}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [ + { + "block_id": "b", + "action_id": "a", + "channel_id": "C111", + "response_url": "http://localhost:8888/webhook", + } + ], + "is_enterprise_install": False, +} + + +raw_response_url_body = f"payload={quote(json.dumps(response_url_payload_body))}" + + class TestAsyncViewSubmission: signing_secret = "secret" valid_token = "xoxb-valid" @@ -52,11 +179,9 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_valid_request(self) -> AsyncBoltRequest: + def build_valid_request(self, body: str = raw_body) -> AsyncBoltRequest: timestamp = str(int(time())) - return AsyncBoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) - ) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) @pytest.mark.asyncio async def test_mock_server_is_running(self): @@ -135,71 +260,22 @@ async def test_failure_2(self): assert response.status == 404 await assert_auth_test_count_async(self, 1) + @pytest.mark.asyncio + async def test_response_urls(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) -body = { - "type": "view_submission", - "team": { - "id": "T111", - "domain": "workspace-domain", - "enterprise_id": "E111", - "enterprise_name": "Sandbox Org", - }, - "user": { - "id": "W111", - "username": "primary-owner", - "name": "primary-owner", - "team_id": "T111", - }, - "api_app_id": "A111", - "token": "verification_token", - "trigger_id": "111.222.valid", - "view": { - "id": "V111", - "team_id": "T111", - "type": "modal", - "blocks": [ - { - "type": "input", - "block_id": "hspI", - "label": { - "type": "plain_text", - "text": "Label", - }, - "optional": False, - "element": {"type": "plain_text_input", "action_id": "maBWU"}, - } - ], - "private_metadata": "This is for you!", - "callback_id": "view-id", - "state": { - "values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}} - }, - "hash": "1596530361.3wRYuk3R", - "title": { - "type": "plain_text", - "text": "My App", - }, - "clear_on_close": False, - "notify_on_close": False, - "close": { - "type": "plain_text", - "text": "Cancel", - }, - "submit": { - "type": "plain_text", - "text": "Submit", - }, - "previous_view_id": None, - "root_view_id": "V111", - "app_id": "A111", - "external_id": "", - "app_installed_team_id": "T111", - "bot_id": "B111", - }, - "response_urls": [], -} + @app.view("view-id") + async def check(ack, respond): + await respond("Hi") + await ack() -raw_body = f"payload={quote(json.dumps(body))}" + request = self.build_valid_request(raw_response_url_body) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) async def simple_listener(ack, body, payload, view): From 684611c2c5b0b71d980543a5fb389d6b8bcb1cfe Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 12 Apr 2021 13:21:11 +0900 Subject: [PATCH 309/865] Fix #273 Enable developers to customize the way to handle unmatched requests --- slack_bolt/app/app.py | 53 ++++++++++- slack_bolt/app/async_app.py | 57 ++++++++++- slack_bolt/error/__init__.py | 20 ++++ .../listener/async_listener_error_handler.py | 12 ++- slack_bolt/listener/listener_error_handler.py | 10 +- slack_bolt/logger/messages.py | 13 ++- tests/scenario_tests/test_error_handler.py | 91 +++++++++++++++++- .../test_error_handler.py | 94 +++++++++++++++++++ 8 files changed, 334 insertions(+), 16 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index e512812dd..86f59b296 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -17,7 +17,7 @@ InstallationStoreAuthorize, CallableAuthorize, ) -from slack_bolt.error import BoltError +from slack_bolt.error import BoltError, BoltUnhandledRequestError from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner from slack_bolt.listener.builtins import TokenRevocationListeners from slack_bolt.listener.custom_listener import CustomListener @@ -50,6 +50,7 @@ debug_return_listener_middleware_response, info_default_oauth_settings_loaded, error_installation_store_required_for_builtin_listeners, + warning_unhandled_by_global_middleware, ) from slack_bolt.middleware import ( Middleware, @@ -85,6 +86,8 @@ def __init__( name: Optional[str] = None, # Set True when you run this app on a FaaS platform process_before_response: bool = False, + # Set True if you want to handle an unhandled request as an exception + raise_error_for_unhandled_request: bool = False, # Basic Information > Credentials > Signing Secret signing_secret: Optional[str] = None, # for single-workspace apps @@ -94,7 +97,7 @@ def __init__( # for multi-workspace apps authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[InstallationStore] = None, - # for v1.0.x compatibility + # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, # for the OAuth flow oauth_settings: Optional[OAuthSettings] = None, @@ -132,6 +135,9 @@ def message_hello(message, say): logger: The custom logger that can be used in this app. name: The application name that will be used in logging. If absent, the source file name will be used. process_before_response: True if this app runs on Function as a Service. (Default: False) + raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests + and use @app.error listeners instead of + the built-in handler, which pints warning logs and returns 404 to Slack (Default: False) signing_secret: The Signing Secret value used for verifying requests from Slack. token: The bot/user access token required only for single-workspace app. token_verification_enabled: Verifies the validity of the given token if True. @@ -154,6 +160,7 @@ def message_hello(message, say): "SLACK_VERIFICATION_TOKEN", None ) self._framework_logger = logger or get_bolt_logger(App) + self._raise_error_for_unhandled_request = raise_error_for_unhandled_request self._token: Optional[str] = token @@ -411,9 +418,26 @@ def middleware_next(): resp = middleware.process(req=req, resp=resp, next=middleware_next) if not middleware_state["next_called"]: if resp is None: - return BoltResponse( + # next() method was not called without providing the response to return to Slack + # This should not be an intentional handling in usual use cases. + resp = BoltResponse( status=404, body={"error": "no next() calls in middleware"} ) + if self._raise_error_for_unhandled_request is True: + self._listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + last_global_middleware_name=middleware.name, + ), + request=req, + response=resp, + ) + return resp + self._framework_logger.warning( + warning_unhandled_by_global_middleware(middleware.name, req) + ) + return resp return resp for listener in self._listeners: @@ -452,8 +476,27 @@ def middleware_next(): if listener_response is not None: return listener_response + if resp is None: + resp = BoltResponse(status=404, body={"error": "unhandled request"}) + if self._raise_error_for_unhandled_request is True: + self._listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + ), + request=req, + response=resp, + ) + return resp + return self._handle_unmatched_requests(req, resp) + + def _handle_unmatched_requests( + self, req: BoltRequest, resp: BoltResponse + ) -> BoltResponse: + # TODO: provide more info like suggestion of listeners + # e.g., You can handle this type of message with @app.event("app_mention") self._framework_logger.warning(warning_unhandled_request(req)) - return BoltResponse(status=404, body={"error": "unhandled request"}) + return resp # ------------------------- # middleware @@ -563,7 +606,7 @@ def step( # global error handler def error( - self, func: Callable[..., None] + self, func: Callable[..., Optional[BoltResponse]] ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Updates the global error handler. This method can be used as either a decorator or a method. diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index f11497071..ce1b968bf 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -33,7 +33,7 @@ AsyncCallableAuthorize, AsyncInstallationStoreAuthorize, ) -from slack_bolt.error import BoltError +from slack_bolt.error import BoltError, BoltUnhandledRequestError from slack_bolt.logger.messages import ( warning_client_prioritized_and_token_skipped, warning_token_skipped, @@ -51,6 +51,7 @@ debug_return_listener_middleware_response, info_default_oauth_settings_loaded, error_installation_store_required_for_builtin_listeners, + warning_unhandled_by_global_middleware, ) from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -96,6 +97,8 @@ def __init__( name: Optional[str] = None, # Set True when you run this app on a FaaS platform process_before_response: bool = False, + # Set True if you want to handle an unhandled request as an exception + raise_error_for_unhandled_request: bool = False, # Basic Information > Credentials > Signing Secret signing_secret: Optional[str] = None, # for single-workspace apps @@ -103,6 +106,7 @@ def __init__( client: Optional[AsyncWebClient] = None, # for multi-workspace apps installation_store: Optional[AsyncInstallationStore] = None, + # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, # for the OAuth flow @@ -141,6 +145,9 @@ async def message_hello(message, say): # async function logger: The custom logger that can be used in this app. name: The application name that will be used in logging. If absent, the source file name will be used. process_before_response: True if this app runs on Function as a Service. (Default: False) + raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests + and use @app.error listeners instead of + the built-in handler, which pints warning logs and returns 404 to Slack (Default: False) signing_secret: The Signing Secret value used for verifying requests from Slack. token: The bot/user access token required only for single-workspace app. client: The singleton `slack_sdk.web.async_client.AsyncWebClient` instance for this app. @@ -161,6 +168,7 @@ async def message_hello(message, say): # async function "SLACK_VERIFICATION_TOKEN", None ) self._framework_logger = logger or get_bolt_logger(AsyncApp) + self._raise_error_for_unhandled_request = raise_error_for_unhandled_request self._token: Optional[str] = token @@ -464,9 +472,26 @@ async def async_middleware_next(): ) if not middleware_state["next_called"]: if resp is None: - return BoltResponse( + # next() method was not called without providing the response to return to Slack + # This should not be an intentional handling in usual use cases. + resp = BoltResponse( status=404, body={"error": "no next() calls in middleware"} ) + if self._raise_error_for_unhandled_request is True: + await self._async_listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + last_global_middleware_name=middleware.name, + ), + request=req, + response=resp, + ) + return resp + self._framework_logger.warning( + warning_unhandled_by_global_middleware(middleware.name, req) + ) + return resp return resp for listener in self._async_listeners: @@ -508,8 +533,27 @@ async def async_middleware_next(): if listener_response is not None: return listener_response + if resp is None: + resp = BoltResponse(status=404, body={"error": "unhandled request"}) + if self._raise_error_for_unhandled_request is True: + await self._async_listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + ), + request=req, + response=resp, + ) + return resp + return self._handle_unmatched_requests(req, resp) + + def _handle_unmatched_requests( + self, req: AsyncBoltRequest, resp: BoltResponse + ) -> BoltResponse: + # TODO: provide more info like suggestion of listeners + # e.g., You can handle this type of message with @app.event("app_mention") self._framework_logger.warning(warning_unhandled_request(req)) - return BoltResponse(status=404, body={"error": "unhandled request"}) + return resp # ------------------------- # middleware @@ -623,8 +667,8 @@ def step( # global error handler def error( - self, func: Callable[..., Awaitable[None]] - ) -> Callable[..., Awaitable[None]]: + self, func: Callable[..., Awaitable[Optional[BoltResponse]]] + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: """Updates the global error handler. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -642,6 +686,9 @@ async def custom_error_handler(error, body, logger): func: The function that is supposed to be executed when getting an unhandled error in Bolt app. """ + if not inspect.iscoroutinefunction(func): + name = get_name_for_callable(func) + raise BoltError(error_listener_function_must_be_coro_func(name)) self._async_listener_runner.listener_error_handler = ( AsyncCustomListenerErrorHandler( logger=self._framework_logger, diff --git a/slack_bolt/error/__init__.py b/slack_bolt/error/__init__.py index 4201356dc..0e03032b1 100644 --- a/slack_bolt/error/__init__.py +++ b/slack_bolt/error/__init__.py @@ -1,5 +1,25 @@ """Bolt specific error types.""" +from typing import Optional, Union class BoltError(Exception): """General class in a Bolt app""" + + +class BoltUnhandledRequestError(BoltError): + request: "BoltRequest" # type: ignore + body: dict + current_response: Optional["BoltResponse"] # type: ignore + last_global_middleware_name: Optional[str] + + def __init__( # type: ignore + self, + *, + request: Union["BoltRequest", "AsyncBoltRequest"], # type: ignore + current_response: Optional["BoltResponse"], # type: ignore + last_global_middleware_name: Optional[str] = None, + ): + self.request = request + self.body = request.body if request is not None else {} + self.current_response = current_response + self.last_global_middleware_name = last_global_middleware_name diff --git a/slack_bolt/listener/async_listener_error_handler.py b/slack_bolt/listener/async_listener_error_handler.py index 3aa0360d8..17d643d09 100644 --- a/slack_bolt/listener/async_listener_error_handler.py +++ b/slack_bolt/listener/async_listener_error_handler.py @@ -33,7 +33,9 @@ async def handle( class AsyncCustomListenerErrorHandler(AsyncListenerErrorHandler): - def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]): + def __init__( + self, logger: Logger, func: Callable[..., Awaitable[Optional[BoltResponse]]] + ): self.func = func self.logger = logger self.arg_names = inspect.getfullargspec(func).args @@ -55,7 +57,13 @@ async def handle( arg_names=self.arg_names, logger=self.logger, ) - await self.func(**kwargs) + returned_response = await self.func(**kwargs) + if returned_response is not None and isinstance( + returned_response, BoltResponse + ): + response.status = returned_response.status + response.headers = returned_response.headers + response.body = returned_response.body class AsyncDefaultListenerErrorHandler(AsyncListenerErrorHandler): diff --git a/slack_bolt/listener/listener_error_handler.py b/slack_bolt/listener/listener_error_handler.py index fe5c9b173..0ee9dde8e 100644 --- a/slack_bolt/listener/listener_error_handler.py +++ b/slack_bolt/listener/listener_error_handler.py @@ -31,7 +31,7 @@ def handle( class CustomListenerErrorHandler(ListenerErrorHandler): - def __init__(self, logger: Logger, func: Callable[..., None]): + def __init__(self, logger: Logger, func: Callable[..., Optional[BoltResponse]]): self.func = func self.logger = logger self.arg_names = inspect.getfullargspec(func).args @@ -53,7 +53,13 @@ def handle( arg_names=self.arg_names, logger=self.logger, ) - self.func(**kwargs) + returned_response = self.func(**kwargs) + if returned_response is not None and isinstance( + returned_response, BoltResponse + ): + response.status = returned_response.status + response.headers = returned_response.headers + response.body = returned_response.body class DefaultListenerErrorHandler(ListenerErrorHandler): diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 7d16c2861..294997a65 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -85,7 +85,18 @@ def warning_installation_store_conflicts() -> str: return "As you gave both `installation_store` and `oauth_settings`/`auth_flow`, the top level one is unused." -def warning_unhandled_request(req: Union[BoltRequest, "AsyncBoltRequest"]) -> str: # type: ignore +def warning_unhandled_by_global_middleware( # type: ignore + name: str, req: Union[BoltRequest, "AsyncBoltRequest"] # type: ignore +) -> str: # type: ignore + return ( + f"A global middleware ({name}) skipped calling `next()` " + f"without providing a response for the request ({req.body})" + ) + + +def warning_unhandled_request( # type: ignore + req: Union[BoltRequest, "AsyncBoltRequest"], # type: ignore +) -> str: # type: ignore return f"Unhandled request ({req.body})" diff --git a/tests/scenario_tests/test_error_handler.py b/tests/scenario_tests/test_error_handler.py index 7ebcc224d..be744efe0 100644 --- a/tests/scenario_tests/test_error_handler.py +++ b/tests/scenario_tests/test_error_handler.py @@ -5,8 +5,9 @@ from slack_sdk import WebClient from slack_sdk.signature import SignatureVerifier -from slack_bolt import BoltRequest +from slack_bolt import BoltRequest, BoltResponse from slack_bolt.app import App +from slack_bolt.error import BoltUnhandledRequestError from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -153,3 +154,91 @@ def failing_listener(): response = app.dispatch(request) assert response.status == 500 assert response.headers["x-test-result"] == ["1"] + + def test_unhandled_errors(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + ) + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @app.error + def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + def test_unhandled_errors_process_before_response(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + process_before_response=True, + ) + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @app.error + def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + def test_unhandled_errors_no_next(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + ) + + @app.middleware + def broken_middleware(): + pass + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "no next() calls in middleware"}' + + @app.error + def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + def test_unhandled_errors_process_before_response_no_next(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + process_before_response=True, + ) + + @app.middleware + def broken_middleware(): + pass + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "no next() calls in middleware"}' + + @app.error + def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" diff --git a/tests/scenario_tests_async/test_error_handler.py b/tests/scenario_tests_async/test_error_handler.py index d0e553ff4..d534a764e 100644 --- a/tests/scenario_tests_async/test_error_handler.py +++ b/tests/scenario_tests_async/test_error_handler.py @@ -7,7 +7,9 @@ from slack_sdk.signature import SignatureVerifier from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt import BoltResponse from slack_bolt.async_app import AsyncApp +from slack_bolt.error import BoltUnhandledRequestError from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( setup_mock_web_api_server, @@ -163,3 +165,95 @@ async def failing_listener(): response = await app.async_dispatch(request) assert response.status == 500 assert response.headers["x-test-result"] == ["1"] + + @pytest.mark.asyncio + async def test_unhandled_errors(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + ) + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @app.error + async def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + @pytest.mark.asyncio + async def test_unhandled_errors_process_before_response(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + process_before_response=True, + ) + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @app.error + async def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + @pytest.mark.asyncio + async def test_unhandled_errors_no_next(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + ) + + @app.middleware + async def broken_middleware(): + pass + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "no next() calls in middleware"}' + + @app.error + async def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + @pytest.mark.asyncio + async def test_unhandled_errors_process_before_response_no_next(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + process_before_response=True, + ) + + @app.middleware + async def broken_middleware(): + pass + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "no next() calls in middleware"}' + + @app.error + async def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" From ac030dd8d3775ad0ee5cbdf6b597156613707702 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 20 Apr 2021 14:57:29 +0900 Subject: [PATCH 310/865] version 1.5.0 --- setup.py | 2 +- slack_bolt/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index b4d1e966c..6a42a72dd 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.5.0rc1,<4",], + install_requires=["slack_sdk>=3.5.0,<4",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 536b2e8cc..c73df5eee 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.5.0b1" +__version__ = "1.5.0" From 99ed6a6c1bb09c8f186b916eaf54e963073a112c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 20 Apr 2021 14:57:45 +0900 Subject: [PATCH 311/865] Update the documents --- .../adapter/aws_lambda/chalice_handler.html | 114 ++++++- .../slack_bolt/adapter/django/handler.html | 237 +++++++++++++- docs/api-docs/slack_bolt/app/app.html | 288 ++++++++++++++++- docs/api-docs/slack_bolt/app/async_app.html | 305 +++++++++++++++++- .../authorization/async_authorize.html | 177 ++++++---- .../slack_bolt/authorization/authorize.html | 167 ++++++---- docs/api-docs/slack_bolt/error/index.html | 91 +++++- .../lazy_listener/thread_runner.html | 4 + .../slack_bolt/listener/async_builtins.html | 207 ++++++++++++ .../slack_bolt/listener/async_internals.html | 116 +++++++ .../async_listener_completion_handler.html | 292 +++++++++++++++++ .../async_listener_error_handler.html | 152 +++------ .../slack_bolt/listener/asyncio_runner.html | 40 ++- .../slack_bolt/listener/builtins.html | 201 ++++++++++++ docs/api-docs/slack_bolt/listener/index.html | 30 ++ .../slack_bolt/listener/internals.html | 133 ++++++++ .../listener/listener_completion_handler.html | 285 ++++++++++++++++ .../listener/listener_error_handler.html | 146 +++------ .../slack_bolt/listener/thread_runner.html | 44 ++- docs/api-docs/slack_bolt/logger/messages.html | 60 +++- .../slack_bolt/request/async_internals.html | 47 ++- .../slack_bolt/request/internals.html | 44 ++- docs/api-docs/slack_bolt/version.html | 2 +- .../slack_bolt/workflows/step/async_step.html | 2 + .../workflows/step/async_step_middleware.html | 2 + .../slack_bolt/workflows/step/step.html | 2 + .../workflows/step/step_middleware.html | 2 + .../step/utilities/async_complete.html | 2 + .../step/utilities/async_configure.html | 2 + .../workflows/step/utilities/async_fail.html | 2 + .../step/utilities/async_update.html | 2 + .../workflows/step/utilities/complete.html | 2 + .../workflows/step/utilities/configure.html | 2 + .../workflows/step/utilities/fail.html | 2 + .../workflows/step/utilities/update.html | 2 + 35 files changed, 2814 insertions(+), 392 deletions(-) create mode 100644 docs/api-docs/slack_bolt/listener/async_builtins.html create mode 100644 docs/api-docs/slack_bolt/listener/async_internals.html create mode 100644 docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html create mode 100644 docs/api-docs/slack_bolt/listener/builtins.html create mode 100644 docs/api-docs/slack_bolt/listener/internals.html create mode 100644 docs/api-docs/slack_bolt/listener/listener_completion_handler.html diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html index f3eaa5810..032b92679 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html @@ -27,8 +27,12 @@

    Module slack_bolt.adapter.aws_lambda.chalice_handlerExpand source code
    import logging
    +import json
    +from os import getenv
     
     from chalice.app import Request, Response, Chalice
    +from chalice.config import Config
    +from chalice.test import BaseClient, LambdaContext, InvokeResponse
     
     from slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner import (
         ChaliceLazyListenerRunner,
    @@ -41,14 +45,43 @@ 

    Module slack_bolt.adapter.aws_lambda.chalice_handlerClasses

    self.app = app self.chalice = chalice self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler) + + lambda_client = None + if getenv("AWS_CHALICE_CLI_MODE") == "true": + lambda_client = LocalLambdaClient(self.chalice, Config()) + self.app.listener_runner.lazy_listener_runner = ChaliceLazyListenerRunner( - logger=self.logger + logger=self.logger, lambda_client=lambda_client ) + if self.app.oauth_flow is not None: self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?" @@ -338,6 +377,71 @@

    Methods

    +
    +class LocalLambdaClient +(app: chalice.app.Chalice, config: chalice.config.Config) +
    +
    +

    Lambda client implementing invoke for use when running with Chalice CLI

    +
    + +Expand source code + +
    class LocalLambdaClient(BaseClient):
    +    """Lambda client implementing `invoke` for use when running with Chalice CLI"""
    +
    +    def __init__(self, app: Chalice, config: Config) -> None:
    +        self._app = app
    +        self._config = config
    +
    +    def invoke(
    +        self,
    +        FunctionName: str = None,
    +        InvocationType: str = "Event",
    +        Payload: str = "{}",
    +    ) -> InvokeResponse:
    +        scoped = self._config.scope(self._config.chalice_stage, FunctionName)
    +        lambda_context = LambdaContext(
    +            FunctionName, memory_size=scoped.lambda_memory_size
    +        )
    +
    +        with self._patched_env_vars(scoped.environment_variables):
    +            response = self._app(json.loads(Payload), lambda_context)
    +        return InvokeResponse(payload=response)
    +
    +

    Ancestors

    +
      +
    • chalice.test.BaseClient
    • +
    +

    Methods

    +
    +
    +def invoke(self, FunctionName: str = None, InvocationType: str = 'Event', Payload: str = '{}') ‑> chalice.test.InvokeResponse +
    +
    +
    +
    + +Expand source code + +
    def invoke(
    +    self,
    +    FunctionName: str = None,
    +    InvocationType: str = "Event",
    +    Payload: str = "{}",
    +) -> InvokeResponse:
    +    scoped = self._config.scope(self._config.chalice_stage, FunctionName)
    +    lambda_context = LambdaContext(
    +        FunctionName, memory_size=scoped.lambda_memory_size
    +    )
    +
    +    with self._patched_env_vars(scoped.environment_variables):
    +        response = self._app(json.loads(Payload), lambda_context)
    +    return InvokeResponse(payload=response)
    +
    +
    +
    +

    @@ -368,6 +472,12 @@

    handle +
  • +

    LocalLambdaClient

    + +
  • diff --git a/docs/api-docs/slack_bolt/adapter/django/handler.html b/docs/api-docs/slack_bolt/adapter/django/handler.html index 57c631413..690876c6a 100644 --- a/docs/api-docs/slack_bolt/adapter/django/handler.html +++ b/docs/api-docs/slack_bolt/adapter/django/handler.html @@ -26,11 +26,22 @@

    Module slack_bolt.adapter.django.handler

    Expand source code -
    from typing import Optional
    +
    import logging
    +from logging import Logger
    +from threading import current_thread, Thread
    +from typing import Optional, Callable
     
     from django.http import HttpRequest, HttpResponse
     
     from slack_bolt.app import App
    +from slack_bolt.error import BoltError
    +from slack_bolt.lazy_listener import ThreadLazyListenerRunner
    +from slack_bolt.lazy_listener.internals import build_runnable_function
    +from slack_bolt.listener.listener_completion_handler import (
    +    ListenerCompletionHandler,
    +    DefaultListenerCompletionHandler,
    +)
    +from slack_bolt.listener.thread_runner import ThreadListenerRunner
     from slack_bolt.oauth import OAuthFlow
     from slack_bolt.request import BoltRequest
     from slack_bolt.response import BoltResponse
    @@ -71,9 +82,86 @@ 

    Module slack_bolt.adapter.django.handler

    return resp +from django.db import connections + + +def release_thread_local_connections(logger: Logger, execution_type: str): + connections.close_all() + if logger.level <= logging.DEBUG: + current: Thread = current_thread() + logger.debug( + f"Released thread-bound DB connections (thread name: {current.name}, execution type: {execution_type})" + ) + + +class DjangoListenerCompletionHandler(ListenerCompletionHandler): + """Django sets DB connections as a thread-local variable per thread. + If the thread is not managed on the Django app side, the connections won't be released by Django. + This handler releases the connections every time a ThreadListenerRunner execution completes. + """ + + def handle(self, request: BoltRequest, response: Optional[BoltResponse]) -> None: + release_thread_local_connections(request.context.logger, "listener") + + +class DjangoThreadLazyListenerRunner(ThreadLazyListenerRunner): + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + func: Callable[[], None] = build_runnable_function( + func=function, + logger=self.logger, + request=request, + ) + + def wrapped_func(): + try: + func() + finally: + release_thread_local_connections( + request.context.logger, "lazy-listener" + ) + + self.executor.submit(wrapped_func) + + class SlackRequestHandler: def __init__(self, app: App): # type: ignore self.app = app + listener_runner = self.app.listener_runner + # This runner closes all thread-local connections in the thread when an execution completes + self.app.listener_runner.lazy_listener_runner = DjangoThreadLazyListenerRunner( + logger=listener_runner.logger, + executor=listener_runner.listener_executor, + ) + + if not isinstance(listener_runner, ThreadListenerRunner): + raise BoltError( + "Custom listener_runners are not compatible with this Django adapter." + ) + + if app.process_before_response is True: + # As long as the app access Django models in the same thread, + # Django cleans the connections up for you. + self.app.logger.debug("App.process_before_response is set to True") + return + + current_completion_handler = listener_runner.listener_completion_handler + if current_completion_handler is not None and not isinstance( + current_completion_handler, DefaultListenerCompletionHandler + ): + message = """As you've already set app.listener_runner.listener_completion_handler to your own one, + Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerCompletionHandler. + We strongly recommend having the following lines of code in your listener_completion_handler: + + from django.db import connections + connections.close_all() + """ + self.app.logger.warning(message) + return + # for proper management of thread-local Django DB connections + self.app.listener_runner.listener_completion_handler = ( + DjangoListenerCompletionHandler() + ) + self.app.logger.debug("DjangoListenerCompletionHandler has been enabled") def handle(self, req: HttpRequest) -> HttpResponse: if req.method == "GET": @@ -99,6 +187,24 @@

    Module slack_bolt.adapter.django.handler

    Functions

    +
    +def release_thread_local_connections(logger: logging.Logger, execution_type: str) +
    +
    +
    +
    + +Expand source code + +
    def release_thread_local_connections(logger: Logger, execution_type: str):
    +    connections.close_all()
    +    if logger.level <= logging.DEBUG:
    +        current: Thread = current_thread()
    +        logger.debug(
    +            f"Released thread-bound DB connections (thread name: {current.name}, execution type: {execution_type})"
    +        )
    +
    +
    def to_bolt_request(req: django.http.request.HttpRequest) ‑> BoltRequest
    @@ -157,6 +263,89 @@

    Functions

    Classes

    +
    +class DjangoListenerCompletionHandler +
    +
    +

    Django sets DB connections as a thread-local variable per thread. +If the thread is not managed on the Django app side, the connections won't be released by Django. +This handler releases the connections every time a ThreadListenerRunner execution completes.

    +
    + +Expand source code + +
    class DjangoListenerCompletionHandler(ListenerCompletionHandler):
    +    """Django sets DB connections as a thread-local variable per thread.
    +    If the thread is not managed on the Django app side, the connections won't be released by Django.
    +    This handler releases the connections every time a ThreadListenerRunner execution completes.
    +    """
    +
    +    def handle(self, request: BoltRequest, response: Optional[BoltResponse]) -> None:
    +        release_thread_local_connections(request.context.logger, "listener")
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    +
    +class DjangoThreadLazyListenerRunner +(logger: logging.Logger, executor: concurrent.futures.thread.ThreadPoolExecutor) +
    +
    +
    +
    + +Expand source code + +
    class DjangoThreadLazyListenerRunner(ThreadLazyListenerRunner):
    +    def start(self, function: Callable[..., None], request: BoltRequest) -> None:
    +        func: Callable[[], None] = build_runnable_function(
    +            func=function,
    +            logger=self.logger,
    +            request=request,
    +        )
    +
    +        def wrapped_func():
    +            try:
    +                func()
    +            finally:
    +                release_thread_local_connections(
    +                    request.context.logger, "lazy-listener"
    +                )
    +
    +        self.executor.submit(wrapped_func)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var logger : logging.Logger
    +
    +
    +
    +
    +

    Inherited members

    + +
    class SlackRequestHandler (app: App) @@ -170,6 +359,42 @@

    Classes

    class SlackRequestHandler:
         def __init__(self, app: App):  # type: ignore
             self.app = app
    +        listener_runner = self.app.listener_runner
    +        # This runner closes all thread-local connections in the thread when an execution completes
    +        self.app.listener_runner.lazy_listener_runner = DjangoThreadLazyListenerRunner(
    +            logger=listener_runner.logger,
    +            executor=listener_runner.listener_executor,
    +        )
    +
    +        if not isinstance(listener_runner, ThreadListenerRunner):
    +            raise BoltError(
    +                "Custom listener_runners are not compatible with this Django adapter."
    +            )
    +
    +        if app.process_before_response is True:
    +            # As long as the app access Django models in the same thread,
    +            # Django cleans the connections up for you.
    +            self.app.logger.debug("App.process_before_response is set to True")
    +            return
    +
    +        current_completion_handler = listener_runner.listener_completion_handler
    +        if current_completion_handler is not None and not isinstance(
    +            current_completion_handler, DefaultListenerCompletionHandler
    +        ):
    +            message = """As you've already set app.listener_runner.listener_completion_handler to your own one,
    +            Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerCompletionHandler.
    +            We strongly recommend having the following lines of code in your listener_completion_handler:
    +
    +            from django.db import connections
    +            connections.close_all()
    +            """
    +            self.app.logger.warning(message)
    +            return
    +        # for proper management of thread-local Django DB connections
    +        self.app.listener_runner.listener_completion_handler = (
    +            DjangoListenerCompletionHandler()
    +        )
    +        self.app.logger.debug("DjangoListenerCompletionHandler has been enabled")
     
         def handle(self, req: HttpRequest) -> HttpResponse:
             if req.method == "GET":
    @@ -233,6 +458,7 @@ 

    Index

  • Functions

    @@ -240,6 +466,15 @@

    Index

  • Classes

    • +

      DjangoListenerCompletionHandler

      +
    • +
    • +

      DjangoThreadLazyListenerRunner

      + +
    • +
    • SlackRequestHandler

      • handle
      • diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index a89ddcc2a..559314723 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -45,10 +45,14 @@

        Module slack_bolt.app.app

        InstallationStoreAuthorize, CallableAuthorize, ) -from slack_bolt.error import BoltError +from slack_bolt.error import BoltError, BoltUnhandledRequestError from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner +from slack_bolt.listener.builtins import TokenRevocationListeners from slack_bolt.listener.custom_listener import CustomListener from slack_bolt.listener.listener import Listener +from slack_bolt.listener.listener_completion_handler import ( + DefaultListenerCompletionHandler, +) from slack_bolt.listener.listener_error_handler import ( DefaultListenerErrorHandler, CustomListenerErrorHandler, @@ -73,6 +77,8 @@

        Module slack_bolt.app.app

        warning_bot_only_conflicts, debug_return_listener_middleware_response, info_default_oauth_settings_loaded, + error_installation_store_required_for_builtin_listeners, + warning_unhandled_by_global_middleware, ) from slack_bolt.middleware import ( Middleware, @@ -108,6 +114,8 @@

        Module slack_bolt.app.app

        name: Optional[str] = None, # Set True when you run this app on a FaaS platform process_before_response: bool = False, + # Set True if you want to handle an unhandled request as an exception + raise_error_for_unhandled_request: bool = False, # Basic Information > Credentials > Signing Secret signing_secret: Optional[str] = None, # for single-workspace apps @@ -117,7 +125,7 @@

        Module slack_bolt.app.app

        # for multi-workspace apps authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[InstallationStore] = None, - # for v1.0.x compatibility + # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, # for the OAuth flow oauth_settings: Optional[OAuthSettings] = None, @@ -155,6 +163,9 @@

        Module slack_bolt.app.app

        logger: The custom logger that can be used in this app. name: The application name that will be used in logging. If absent, the source file name will be used. process_before_response: True if this app runs on Function as a Service. (Default: False) + raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests + and use @app.error listeners instead of + the built-in handler, which pints warning logs and returns 404 to Slack (Default: False) signing_secret: The Signing Secret value used for verifying requests from Slack. token: The bot/user access token required only for single-workspace app. token_verification_enabled: Verifies the validity of the given token if True. @@ -177,6 +188,7 @@

        Module slack_bolt.app.app

        "SLACK_VERIFICATION_TOKEN", None ) self._framework_logger = logger or get_bolt_logger(App) + self._raise_error_for_unhandled_request = raise_error_for_unhandled_request self._token: Optional[str] = token @@ -275,6 +287,12 @@

        Module slack_bolt.app.app

        self._oauth_flow.settings.installation_store_bot_only = app_bot_only self._authorize.bot_only = app_bot_only + self._tokens_revocation_listeners: Optional[TokenRevocationListeners] = None + if self._installation_store is not None: + self._tokens_revocation_listeners = TokenRevocationListeners( + self._installation_store + ) + # -------------------------------------- # Middleware Initialization # -------------------------------------- @@ -283,12 +301,16 @@

        Module slack_bolt.app.app

        self._listeners: List[Listener] = [] listener_executor = ThreadPoolExecutor(max_workers=5) + self._process_before_response = process_before_response self._listener_runner = ThreadListenerRunner( logger=self._framework_logger, process_before_response=process_before_response, listener_error_handler=DefaultListenerErrorHandler( logger=self._framework_logger ), + listener_completion_handler=DefaultListenerCompletionHandler( + logger=self._framework_logger + ), listener_executor=listener_executor, lazy_listener_runner=ThreadLazyListenerRunner( logger=self._framework_logger, @@ -367,6 +389,10 @@

        Module slack_bolt.app.app

        """The thread executor for asynchronously running listeners.""" return self._listener_runner + @property + def process_before_response(self) -> bool: + return self._process_before_response or False + # ------------------------- # standalone server @@ -420,9 +446,26 @@

        Module slack_bolt.app.app

        resp = middleware.process(req=req, resp=resp, next=middleware_next) if not middleware_state["next_called"]: if resp is None: - return BoltResponse( + # next() method was not called without providing the response to return to Slack + # This should not be an intentional handling in usual use cases. + resp = BoltResponse( status=404, body={"error": "no next() calls in middleware"} ) + if self._raise_error_for_unhandled_request is True: + self._listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + last_global_middleware_name=middleware.name, + ), + request=req, + response=resp, + ) + return resp + self._framework_logger.warning( + warning_unhandled_by_global_middleware(middleware.name, req) + ) + return resp return resp for listener in self._listeners: @@ -461,8 +504,27 @@

        Module slack_bolt.app.app

        if listener_response is not None: return listener_response + if resp is None: + resp = BoltResponse(status=404, body={"error": "unhandled request"}) + if self._raise_error_for_unhandled_request is True: + self._listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + ), + request=req, + response=resp, + ) + return resp + return self._handle_unmatched_requests(req, resp) + + def _handle_unmatched_requests( + self, req: BoltRequest, resp: BoltResponse + ) -> BoltResponse: + # TODO: provide more info like suggestion of listeners + # e.g., You can handle this type of message with @app.event("app_mention") self._framework_logger.warning(warning_unhandled_request(req)) - return BoltResponse(status=404, body={"error": "unhandled request"}) + return resp # ------------------------- # middleware @@ -572,7 +634,7 @@

        Module slack_bolt.app.app

        # global error handler def error( - self, func: Callable[..., None] + self, func: Callable[..., Optional[BoltResponse]] ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Updates the global error handler. This method can be used as either a decorator or a method. @@ -1106,6 +1168,27 @@

        Module slack_bolt.app.app

        return __call__ + # ------------------------- + # built-in listener functions + + def default_tokens_revoked_event_listener( + self, + ) -> Callable[..., Optional[BoltResponse]]: + if self._tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._tokens_revocation_listeners.handle_tokens_revoked_events + + def default_app_uninstalled_event_listener( + self, + ) -> Callable[..., Optional[BoltResponse]]: + if self._tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._tokens_revocation_listeners.handle_app_uninstalled_events + + def enable_token_revocation_listeners(self) -> None: + self.event("tokens_revoked")(self.default_tokens_revoked_event_listener()) + self.event("app_uninstalled")(self.default_app_uninstalled_event_listener()) + # ------------------------- def _init_context(self, req: BoltRequest): @@ -1303,7 +1386,7 @@

        Classes

        class App -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None)

        Bolt App that provides functionalities to register middleware/listeners.

        @@ -1337,6 +1420,10 @@

        Args

        The application name that will be used in logging. If absent, the source file name will be used.
        process_before_response
        True if this app runs on Function as a Service. (Default: False)
        +
        raise_error_for_unhandled_request
        +
        True if you want to raise exceptions for unhandled requests +and use @app.error listeners instead of +the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
        signing_secret
        The Signing Secret value used for verifying requests from Slack.
        token
        @@ -1372,6 +1459,8 @@

        Args

        name: Optional[str] = None, # Set True when you run this app on a FaaS platform process_before_response: bool = False, + # Set True if you want to handle an unhandled request as an exception + raise_error_for_unhandled_request: bool = False, # Basic Information > Credentials > Signing Secret signing_secret: Optional[str] = None, # for single-workspace apps @@ -1381,7 +1470,7 @@

        Args

        # for multi-workspace apps authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[InstallationStore] = None, - # for v1.0.x compatibility + # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, # for the OAuth flow oauth_settings: Optional[OAuthSettings] = None, @@ -1419,6 +1508,9 @@

        Args

        logger: The custom logger that can be used in this app. name: The application name that will be used in logging. If absent, the source file name will be used. process_before_response: True if this app runs on Function as a Service. (Default: False) + raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests + and use @app.error listeners instead of + the built-in handler, which pints warning logs and returns 404 to Slack (Default: False) signing_secret: The Signing Secret value used for verifying requests from Slack. token: The bot/user access token required only for single-workspace app. token_verification_enabled: Verifies the validity of the given token if True. @@ -1441,6 +1533,7 @@

        Args

        "SLACK_VERIFICATION_TOKEN", None ) self._framework_logger = logger or get_bolt_logger(App) + self._raise_error_for_unhandled_request = raise_error_for_unhandled_request self._token: Optional[str] = token @@ -1539,6 +1632,12 @@

        Args

        self._oauth_flow.settings.installation_store_bot_only = app_bot_only self._authorize.bot_only = app_bot_only + self._tokens_revocation_listeners: Optional[TokenRevocationListeners] = None + if self._installation_store is not None: + self._tokens_revocation_listeners = TokenRevocationListeners( + self._installation_store + ) + # -------------------------------------- # Middleware Initialization # -------------------------------------- @@ -1547,12 +1646,16 @@

        Args

        self._listeners: List[Listener] = [] listener_executor = ThreadPoolExecutor(max_workers=5) + self._process_before_response = process_before_response self._listener_runner = ThreadListenerRunner( logger=self._framework_logger, process_before_response=process_before_response, listener_error_handler=DefaultListenerErrorHandler( logger=self._framework_logger ), + listener_completion_handler=DefaultListenerCompletionHandler( + logger=self._framework_logger + ), listener_executor=listener_executor, lazy_listener_runner=ThreadLazyListenerRunner( logger=self._framework_logger, @@ -1631,6 +1734,10 @@

        Args

        """The thread executor for asynchronously running listeners.""" return self._listener_runner + @property + def process_before_response(self) -> bool: + return self._process_before_response or False + # ------------------------- # standalone server @@ -1684,9 +1791,26 @@

        Args

        resp = middleware.process(req=req, resp=resp, next=middleware_next) if not middleware_state["next_called"]: if resp is None: - return BoltResponse( + # next() method was not called without providing the response to return to Slack + # This should not be an intentional handling in usual use cases. + resp = BoltResponse( status=404, body={"error": "no next() calls in middleware"} ) + if self._raise_error_for_unhandled_request is True: + self._listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + last_global_middleware_name=middleware.name, + ), + request=req, + response=resp, + ) + return resp + self._framework_logger.warning( + warning_unhandled_by_global_middleware(middleware.name, req) + ) + return resp return resp for listener in self._listeners: @@ -1725,8 +1849,27 @@

        Args

        if listener_response is not None: return listener_response + if resp is None: + resp = BoltResponse(status=404, body={"error": "unhandled request"}) + if self._raise_error_for_unhandled_request is True: + self._listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + ), + request=req, + response=resp, + ) + return resp + return self._handle_unmatched_requests(req, resp) + + def _handle_unmatched_requests( + self, req: BoltRequest, resp: BoltResponse + ) -> BoltResponse: + # TODO: provide more info like suggestion of listeners + # e.g., You can handle this type of message with @app.event("app_mention") self._framework_logger.warning(warning_unhandled_request(req)) - return BoltResponse(status=404, body={"error": "unhandled request"}) + return resp # ------------------------- # middleware @@ -1836,7 +1979,7 @@

        Args

        # global error handler def error( - self, func: Callable[..., None] + self, func: Callable[..., Optional[BoltResponse]] ) -> Optional[Callable[..., Optional[BoltResponse]]]: """Updates the global error handler. This method can be used as either a decorator or a method. @@ -2370,6 +2513,27 @@

        Args

        return __call__ + # ------------------------- + # built-in listener functions + + def default_tokens_revoked_event_listener( + self, + ) -> Callable[..., Optional[BoltResponse]]: + if self._tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._tokens_revocation_listeners.handle_tokens_revoked_events + + def default_app_uninstalled_event_listener( + self, + ) -> Callable[..., Optional[BoltResponse]]: + if self._tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._tokens_revocation_listeners.handle_app_uninstalled_events + + def enable_token_revocation_listeners(self) -> None: + self.event("tokens_revoked")(self.default_tokens_revoked_event_listener()) + self.event("app_uninstalled")(self.default_app_uninstalled_event_listener()) + # ------------------------- def _init_context(self, req: BoltRequest): @@ -2523,6 +2687,18 @@

        Instance variables

        return self._oauth_flow
  • +
    var process_before_response : bool
    +
    +
    +
    + +Expand source code + +
    @property
    +def process_before_response(self) -> bool:
    +    return self._process_before_response or False
    +
    +

    Methods

    @@ -2760,6 +2936,40 @@

    Args

    return __call__
    +
    +def default_app_uninstalled_event_listener(self) ‑> Callable[..., Optional[BoltResponse]] +
    +
    +
    +
    + +Expand source code + +
    def default_app_uninstalled_event_listener(
    +    self,
    +) -> Callable[..., Optional[BoltResponse]]:
    +    if self._tokens_revocation_listeners is None:
    +        raise BoltError(error_installation_store_required_for_builtin_listeners())
    +    return self._tokens_revocation_listeners.handle_app_uninstalled_events
    +
    +
    +
    +def default_tokens_revoked_event_listener(self) ‑> Callable[..., Optional[BoltResponse]] +
    +
    +
    +
    + +Expand source code + +
    def default_tokens_revoked_event_listener(
    +    self,
    +) -> Callable[..., Optional[BoltResponse]]:
    +    if self._tokens_revocation_listeners is None:
    +        raise BoltError(error_installation_store_required_for_builtin_listeners())
    +    return self._tokens_revocation_listeners.handle_tokens_revoked_events
    +
    +
    def dialog_cancellation(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]]
    @@ -2888,9 +3098,26 @@

    Returns

    resp = middleware.process(req=req, resp=resp, next=middleware_next) if not middleware_state["next_called"]: if resp is None: - return BoltResponse( + # next() method was not called without providing the response to return to Slack + # This should not be an intentional handling in usual use cases. + resp = BoltResponse( status=404, body={"error": "no next() calls in middleware"} ) + if self._raise_error_for_unhandled_request is True: + self._listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + last_global_middleware_name=middleware.name, + ), + request=req, + response=resp, + ) + return resp + self._framework_logger.warning( + warning_unhandled_by_global_middleware(middleware.name, req) + ) + return resp return resp for listener in self._listeners: @@ -2929,12 +3156,37 @@

    Returns

    if listener_response is not None: return listener_response - self._framework_logger.warning(warning_unhandled_request(req)) - return BoltResponse(status=404, body={"error": "unhandled request"})
    + if resp is None: + resp = BoltResponse(status=404, body={"error": "unhandled request"}) + if self._raise_error_for_unhandled_request is True: + self._listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + ), + request=req, + response=resp, + ) + return resp + return self._handle_unmatched_requests(req, resp) + + +
    +def enable_token_revocation_listeners(self) ‑> NoneType +
    +
    +
    +
    + +Expand source code + +
    def enable_token_revocation_listeners(self) -> None:
    +    self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
    +    self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
    -def error(self, func: Callable[..., NoneType]) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def error(self, func: Callable[..., Optional[BoltResponse]]) ‑> Optional[Callable[..., Optional[BoltResponse]]]

    Updates the global error handler. This method can be used as either a decorator or a method.

    @@ -2959,7 +3211,7 @@

    Args

    Expand source code
    def error(
    -    self, func: Callable[..., None]
    +    self, func: Callable[..., Optional[BoltResponse]]
     ) -> Optional[Callable[..., Optional[BoltResponse]]]:
         """Updates the global error handler. This method can be used as either a decorator or a method.
     
    @@ -3928,17 +4180,20 @@ 

    Index

    • App

      -
        +
        • action
        • attachment_action
        • block_action
        • block_suggestion
        • client
        • command
        • +
        • default_app_uninstalled_event_listener
        • +
        • default_tokens_revoked_event_listener
        • dialog_cancellation
        • dialog_submission
        • dialog_suggestion
        • dispatch
        • +
        • enable_token_revocation_listeners
        • error
        • event
        • global_shortcut
        • @@ -3951,6 +4206,7 @@

          Appname
        • oauth_flow
        • options
        • +
        • process_before_response
        • shortcut
        • start
        • step
        • diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index 6ae9f8582..90e10ae97 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -35,6 +35,10 @@

          Module slack_bolt.app.async_app

          from aiohttp import web from slack_bolt.app.async_server import AsyncSlackAppServer +from slack_bolt.listener.async_builtins import AsyncTokenRevocationListeners +from slack_bolt.listener.async_listener_completion_handler import ( + AsyncDefaultListenerCompletionHandler, +) from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner from slack_bolt.middleware.message_listener_matches.async_message_listener_matches import ( AsyncMessageListenerMatches, @@ -57,7 +61,7 @@

          Module slack_bolt.app.async_app

          AsyncCallableAuthorize, AsyncInstallationStoreAuthorize, ) -from slack_bolt.error import BoltError +from slack_bolt.error import BoltError, BoltUnhandledRequestError from slack_bolt.logger.messages import ( warning_client_prioritized_and_token_skipped, warning_token_skipped, @@ -74,6 +78,8 @@

          Module slack_bolt.app.async_app

          warning_bot_only_conflicts, debug_return_listener_middleware_response, info_default_oauth_settings_loaded, + error_installation_store_required_for_builtin_listeners, + warning_unhandled_by_global_middleware, ) from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -119,6 +125,8 @@

          Module slack_bolt.app.async_app

          name: Optional[str] = None, # Set True when you run this app on a FaaS platform process_before_response: bool = False, + # Set True if you want to handle an unhandled request as an exception + raise_error_for_unhandled_request: bool = False, # Basic Information > Credentials > Signing Secret signing_secret: Optional[str] = None, # for single-workspace apps @@ -126,6 +134,7 @@

          Module slack_bolt.app.async_app

          client: Optional[AsyncWebClient] = None, # for multi-workspace apps installation_store: Optional[AsyncInstallationStore] = None, + # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, # for the OAuth flow @@ -164,6 +173,9 @@

          Module slack_bolt.app.async_app

          logger: The custom logger that can be used in this app. name: The application name that will be used in logging. If absent, the source file name will be used. process_before_response: True if this app runs on Function as a Service. (Default: False) + raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests + and use @app.error listeners instead of + the built-in handler, which pints warning logs and returns 404 to Slack (Default: False) signing_secret: The Signing Secret value used for verifying requests from Slack. token: The bot/user access token required only for single-workspace app. client: The singleton `slack_sdk.web.async_client.AsyncWebClient` instance for this app. @@ -184,6 +196,7 @@

          Module slack_bolt.app.async_app

          "SLACK_VERIFICATION_TOKEN", None ) self._framework_logger = logger or get_bolt_logger(AsyncApp) + self._raise_error_for_unhandled_request = raise_error_for_unhandled_request self._token: Optional[str] = token @@ -300,6 +313,14 @@

          Module slack_bolt.app.async_app

          ) self._async_authorize.bot_only = app_bot_only + self._async_tokens_revocation_listeners: Optional[ + AsyncTokenRevocationListeners + ] = None + if self._async_installation_store is not None: + self._async_tokens_revocation_listeners = AsyncTokenRevocationListeners( + self._async_installation_store + ) + # -------------------------------------- # Middleware Initialization # -------------------------------------- @@ -307,12 +328,16 @@

          Module slack_bolt.app.async_app

          self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] self._async_listeners: List[AsyncListener] = [] + self._process_before_response = process_before_response self._async_listener_runner = AsyncioListenerRunner( logger=self._framework_logger, process_before_response=process_before_response, listener_error_handler=AsyncDefaultListenerErrorHandler( logger=self._framework_logger ), + listener_completion_handler=AsyncDefaultListenerCompletionHandler( + logger=self._framework_logger + ), lazy_listener_runner=AsyncioLazyListenerRunner( logger=self._framework_logger, ), @@ -383,6 +408,10 @@

          Module slack_bolt.app.async_app

          """The asyncio-based executor for asynchronously running listeners.""" return self._async_listener_runner + @property + def process_before_response(self) -> bool: + return self._process_before_response or False + # ------------------------- # standalone server @@ -471,9 +500,26 @@

          Module slack_bolt.app.async_app

          ) if not middleware_state["next_called"]: if resp is None: - return BoltResponse( + # next() method was not called without providing the response to return to Slack + # This should not be an intentional handling in usual use cases. + resp = BoltResponse( status=404, body={"error": "no next() calls in middleware"} ) + if self._raise_error_for_unhandled_request is True: + await self._async_listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + last_global_middleware_name=middleware.name, + ), + request=req, + response=resp, + ) + return resp + self._framework_logger.warning( + warning_unhandled_by_global_middleware(middleware.name, req) + ) + return resp return resp for listener in self._async_listeners: @@ -515,8 +561,27 @@

          Module slack_bolt.app.async_app

          if listener_response is not None: return listener_response + if resp is None: + resp = BoltResponse(status=404, body={"error": "unhandled request"}) + if self._raise_error_for_unhandled_request is True: + await self._async_listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + ), + request=req, + response=resp, + ) + return resp + return self._handle_unmatched_requests(req, resp) + + def _handle_unmatched_requests( + self, req: AsyncBoltRequest, resp: BoltResponse + ) -> BoltResponse: + # TODO: provide more info like suggestion of listeners + # e.g., You can handle this type of message with @app.event("app_mention") self._framework_logger.warning(warning_unhandled_request(req)) - return BoltResponse(status=404, body={"error": "unhandled request"}) + return resp # ------------------------- # middleware @@ -630,8 +695,8 @@

          Module slack_bolt.app.async_app

          # global error handler def error( - self, func: Callable[..., Awaitable[None]] - ) -> Callable[..., Awaitable[None]]: + self, func: Callable[..., Awaitable[Optional[BoltResponse]]] + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: """Updates the global error handler. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -649,6 +714,9 @@

          Module slack_bolt.app.async_app

          func: The function that is supposed to be executed when getting an unhandled error in Bolt app. """ + if not inspect.iscoroutinefunction(func): + name = get_name_for_callable(func) + raise BoltError(error_listener_function_must_be_coro_func(name)) self._async_listener_runner.listener_error_handler = ( AsyncCustomListenerErrorHandler( logger=self._framework_logger, @@ -1168,6 +1236,27 @@

          Module slack_bolt.app.async_app

          return __call__ + # ------------------------- + # built-in listener functions + + def default_tokens_revoked_event_listener( + self, + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: + if self._async_tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._async_tokens_revocation_listeners.handle_tokens_revoked_events + + def default_app_uninstalled_event_listener( + self, + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: + if self._async_tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._async_tokens_revocation_listeners.handle_app_uninstalled_events + + def enable_token_revocation_listeners(self) -> None: + self.event("tokens_revoked")(self.default_tokens_revoked_event_listener()) + self.event("app_uninstalled")(self.default_app_uninstalled_event_listener()) + # ------------------------- def _init_context(self, req: AsyncBoltRequest): @@ -1264,7 +1353,7 @@

          Classes

          class AsyncApp -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: Optional[bool] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, verification_token: Optional[str] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: Optional[bool] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, verification_token: Optional[str] = None)

          Bolt App that provides functionalities to register middleware/listeners.

          @@ -1298,6 +1387,10 @@

          Args

          The application name that will be used in logging. If absent, the source file name will be used.
          process_before_response
          True if this app runs on Function as a Service. (Default: False)
          +
          raise_error_for_unhandled_request
          +
          True if you want to raise exceptions for unhandled requests +and use @app.error listeners instead of +the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
          signing_secret
          The Signing Secret value used for verifying requests from Slack.
          token
          @@ -1331,6 +1424,8 @@

          Args

          name: Optional[str] = None, # Set True when you run this app on a FaaS platform process_before_response: bool = False, + # Set True if you want to handle an unhandled request as an exception + raise_error_for_unhandled_request: bool = False, # Basic Information > Credentials > Signing Secret signing_secret: Optional[str] = None, # for single-workspace apps @@ -1338,6 +1433,7 @@

          Args

          client: Optional[AsyncWebClient] = None, # for multi-workspace apps installation_store: Optional[AsyncInstallationStore] = None, + # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, # for the OAuth flow @@ -1376,6 +1472,9 @@

          Args

          logger: The custom logger that can be used in this app. name: The application name that will be used in logging. If absent, the source file name will be used. process_before_response: True if this app runs on Function as a Service. (Default: False) + raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests + and use @app.error listeners instead of + the built-in handler, which pints warning logs and returns 404 to Slack (Default: False) signing_secret: The Signing Secret value used for verifying requests from Slack. token: The bot/user access token required only for single-workspace app. client: The singleton `slack_sdk.web.async_client.AsyncWebClient` instance for this app. @@ -1396,6 +1495,7 @@

          Args

          "SLACK_VERIFICATION_TOKEN", None ) self._framework_logger = logger or get_bolt_logger(AsyncApp) + self._raise_error_for_unhandled_request = raise_error_for_unhandled_request self._token: Optional[str] = token @@ -1512,6 +1612,14 @@

          Args

          ) self._async_authorize.bot_only = app_bot_only + self._async_tokens_revocation_listeners: Optional[ + AsyncTokenRevocationListeners + ] = None + if self._async_installation_store is not None: + self._async_tokens_revocation_listeners = AsyncTokenRevocationListeners( + self._async_installation_store + ) + # -------------------------------------- # Middleware Initialization # -------------------------------------- @@ -1519,12 +1627,16 @@

          Args

          self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] self._async_listeners: List[AsyncListener] = [] + self._process_before_response = process_before_response self._async_listener_runner = AsyncioListenerRunner( logger=self._framework_logger, process_before_response=process_before_response, listener_error_handler=AsyncDefaultListenerErrorHandler( logger=self._framework_logger ), + listener_completion_handler=AsyncDefaultListenerCompletionHandler( + logger=self._framework_logger + ), lazy_listener_runner=AsyncioLazyListenerRunner( logger=self._framework_logger, ), @@ -1595,6 +1707,10 @@

          Args

          """The asyncio-based executor for asynchronously running listeners.""" return self._async_listener_runner + @property + def process_before_response(self) -> bool: + return self._process_before_response or False + # ------------------------- # standalone server @@ -1683,9 +1799,26 @@

          Args

          ) if not middleware_state["next_called"]: if resp is None: - return BoltResponse( + # next() method was not called without providing the response to return to Slack + # This should not be an intentional handling in usual use cases. + resp = BoltResponse( status=404, body={"error": "no next() calls in middleware"} ) + if self._raise_error_for_unhandled_request is True: + await self._async_listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + last_global_middleware_name=middleware.name, + ), + request=req, + response=resp, + ) + return resp + self._framework_logger.warning( + warning_unhandled_by_global_middleware(middleware.name, req) + ) + return resp return resp for listener in self._async_listeners: @@ -1727,8 +1860,27 @@

          Args

          if listener_response is not None: return listener_response + if resp is None: + resp = BoltResponse(status=404, body={"error": "unhandled request"}) + if self._raise_error_for_unhandled_request is True: + await self._async_listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + ), + request=req, + response=resp, + ) + return resp + return self._handle_unmatched_requests(req, resp) + + def _handle_unmatched_requests( + self, req: AsyncBoltRequest, resp: BoltResponse + ) -> BoltResponse: + # TODO: provide more info like suggestion of listeners + # e.g., You can handle this type of message with @app.event("app_mention") self._framework_logger.warning(warning_unhandled_request(req)) - return BoltResponse(status=404, body={"error": "unhandled request"}) + return resp # ------------------------- # middleware @@ -1842,8 +1994,8 @@

          Args

          # global error handler def error( - self, func: Callable[..., Awaitable[None]] - ) -> Callable[..., Awaitable[None]]: + self, func: Callable[..., Awaitable[Optional[BoltResponse]]] + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: """Updates the global error handler. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -1861,6 +2013,9 @@

          Args

          func: The function that is supposed to be executed when getting an unhandled error in Bolt app. """ + if not inspect.iscoroutinefunction(func): + name = get_name_for_callable(func) + raise BoltError(error_listener_function_must_be_coro_func(name)) self._async_listener_runner.listener_error_handler = ( AsyncCustomListenerErrorHandler( logger=self._framework_logger, @@ -2380,6 +2535,27 @@

          Args

          return __call__ + # ------------------------- + # built-in listener functions + + def default_tokens_revoked_event_listener( + self, + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: + if self._async_tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._async_tokens_revocation_listeners.handle_tokens_revoked_events + + def default_app_uninstalled_event_listener( + self, + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: + if self._async_tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._async_tokens_revocation_listeners.handle_app_uninstalled_events + + def enable_token_revocation_listeners(self) -> None: + self.event("tokens_revoked")(self.default_tokens_revoked_event_listener()) + self.event("app_uninstalled")(self.default_app_uninstalled_event_listener()) + # ------------------------- def _init_context(self, req: AsyncBoltRequest): @@ -2551,6 +2727,18 @@

          Instance variables

          return self._async_oauth_flow

    +
    var process_before_response : bool
    +
    +
    +
    + +Expand source code + +
    @property
    +def process_before_response(self) -> bool:
    +    return self._process_before_response or False
    +
    +

    Methods

    @@ -2671,9 +2859,26 @@

    Returns

    ) if not middleware_state["next_called"]: if resp is None: - return BoltResponse( + # next() method was not called without providing the response to return to Slack + # This should not be an intentional handling in usual use cases. + resp = BoltResponse( status=404, body={"error": "no next() calls in middleware"} ) + if self._raise_error_for_unhandled_request is True: + await self._async_listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + last_global_middleware_name=middleware.name, + ), + request=req, + response=resp, + ) + return resp + self._framework_logger.warning( + warning_unhandled_by_global_middleware(middleware.name, req) + ) + return resp return resp for listener in self._async_listeners: @@ -2715,8 +2920,19 @@

    Returns

    if listener_response is not None: return listener_response - self._framework_logger.warning(warning_unhandled_request(req)) - return BoltResponse(status=404, body={"error": "unhandled request"})
    + if resp is None: + resp = BoltResponse(status=404, body={"error": "unhandled request"}) + if self._raise_error_for_unhandled_request is True: + await self._async_listener_runner.listener_error_handler.handle( + error=BoltUnhandledRequestError( + request=req, + current_response=resp, + ), + request=req, + response=resp, + ) + return resp + return self._handle_unmatched_requests(req, resp)
    @@ -2879,6 +3095,40 @@

    Args

    return __call__
    +
    +def default_app_uninstalled_event_listener(self) ‑> Callable[..., Awaitable[Optional[BoltResponse]]] +
    +
    +
    +
    + +Expand source code + +
    def default_app_uninstalled_event_listener(
    +    self,
    +) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
    +    if self._async_tokens_revocation_listeners is None:
    +        raise BoltError(error_installation_store_required_for_builtin_listeners())
    +    return self._async_tokens_revocation_listeners.handle_app_uninstalled_events
    +
    +
    +
    +def default_tokens_revoked_event_listener(self) ‑> Callable[..., Awaitable[Optional[BoltResponse]]] +
    +
    +
    +
    + +Expand source code + +
    def default_tokens_revoked_event_listener(
    +    self,
    +) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
    +    if self._async_tokens_revocation_listeners is None:
    +        raise BoltError(error_installation_store_required_for_builtin_listeners())
    +    return self._async_tokens_revocation_listeners.handle_tokens_revoked_events
    +
    +
    def dialog_cancellation(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]
    @@ -2966,8 +3216,22 @@

    Args

    return __call__ +
    +def enable_token_revocation_listeners(self) ‑> NoneType +
    +
    +
    +
    + +Expand source code + +
    def enable_token_revocation_listeners(self) -> None:
    +    self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
    +    self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
    +
    +
    -def error(self, func: Callable[..., Awaitable[NoneType]]) ‑> Callable[..., Awaitable[NoneType]] +def error(self, func: Callable[..., Awaitable[Optional[BoltResponse]]]) ‑> Callable[..., Awaitable[Optional[BoltResponse]]]

    Updates the global error handler. This method can be used as either a decorator or a method.

    @@ -2992,8 +3256,8 @@

    Args

    Expand source code
    def error(
    -    self, func: Callable[..., Awaitable[None]]
    -) -> Callable[..., Awaitable[None]]:
    +    self, func: Callable[..., Awaitable[Optional[BoltResponse]]]
    +) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
         """Updates the global error handler. This method can be used as either a decorator or a method.
     
             # Use this method as a decorator
    @@ -3011,6 +3275,9 @@ 

    Args

    func: The function that is supposed to be executed when getting an unhandled error in Bolt app. """ + if not inspect.iscoroutinefunction(func): + name = get_name_for_callable(func) + raise BoltError(error_listener_function_must_be_coro_func(name)) self._async_listener_runner.listener_error_handler = ( AsyncCustomListenerErrorHandler( logger=self._framework_logger, @@ -3876,7 +4143,7 @@

    Index

    • AsyncApp

      -
        +
        • AsyncSlackAppServer
        • action
        • async_dispatch
        • @@ -3885,9 +4152,12 @@

          block_suggestion
        • client
        • command
        • +
        • default_app_uninstalled_event_listener
        • +
        • default_tokens_revoked_event_listener
        • dialog_cancellation
        • dialog_submission
        • dialog_suggestion
        • +
        • enable_token_revocation_listeners
        • error
        • event
        • global_shortcut
        • @@ -3900,6 +4170,7 @@

          name
        • oauth_flow
        • options
        • +
        • process_before_response
        • server
        • shortcut
        • start
        • diff --git a/docs/api-docs/slack_bolt/authorization/async_authorize.html b/docs/api-docs/slack_bolt/authorization/async_authorize.html index e5d4d30de..df4ef1743 100644 --- a/docs/api-docs/slack_bolt/authorization/async_authorize.html +++ b/docs/api-docs/slack_bolt/authorization/async_authorize.html @@ -121,6 +121,7 @@

          Module slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeAncestors

          class AsyncInstallationStoreAuthorize(AsyncAuthorize):
               authorize_result_cache: Dict[str, AuthorizeResult]
               find_installation_available: Optional[bool]
          +    find_bot_available: Optional[bool]
           
               def __init__(
                   self,
          @@ -387,6 +410,7 @@ 

          Ancestors

          self.cache_enabled = cache_enabled self.authorize_result_cache = {} self.find_installation_available = None + self.find_bot_available = None async def __call__( self, @@ -401,12 +425,15 @@

          Ancestors

          self.find_installation_available = hasattr( self.installation_store, "async_find_installation" ) + if self.find_bot_available is None: + self.find_bot_available = hasattr(self.installation_store, "async_find_bot") bot_token: Optional[str] = None user_token: Optional[str] = None if not self.bot_only and self.find_installation_available: - # since v1.1, this is the default way + # Since v1.1, this is the default way. + # If you want to use find_bot / delete_bot only, you can set bot_only as True. try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. @@ -417,47 +444,64 @@

          Ancestors

          team_id=team_id, is_enterprise_install=context.is_enterprise_install, ) - if installation is None: - self._debug_log_for_not_found(enterprise_id, team_id) - return None - - if installation.user_id != user_id: - # First off, remove the user token as the installer is a different user - installation.user_token = None - installation.user_scopes = [] - - # try to fetch the request user's installation - # to reflect the user's access token if exists - user_installation = ( - await self.installation_store.async_find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, + + if installation is not None: + if installation.user_id != user_id: + # First off, remove the user token as the installer is a different user + installation.user_token = None + installation.user_scopes = [] + + # try to fetch the request user's installation + # to reflect the user's access token if exists + user_installation = ( + await self.installation_store.async_find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) ) + if user_installation is not None: + # Overwrite the installation with the one for this user + installation = user_installation + + bot_token, user_token = ( + installation.bot_token, + installation.user_token, ) - if user_installation is not None: - # Overwrite the installation with the one for this user - installation = user_installation - bot_token, user_token = installation.bot_token, installation.user_token except NotImplementedError as _: self.find_installation_available = False - if self.bot_only or not self.find_installation_available: - # Use find_bot to get bot value (legacy) - bot: Optional[Bot] = await self.installation_store.async_find_bot( - enterprise_id=enterprise_id, - team_id=team_id, - is_enterprise_install=context.is_enterprise_install, + if ( + # If you intentionally use only find_bot / delete_bot, + self.bot_only + # If find_installation method is not available, + or not self.find_installation_available + # If find_installation did not return data and find_bot method is available, + or ( + self.find_bot_available is True + and bot_token is None + and user_token is None ) - if bot is None: - self._debug_log_for_not_found(enterprise_id, team_id) - return None - bot_token, user_token = bot.bot_token, None + ): + try: + bot: Optional[Bot] = await self.installation_store.async_find_bot( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + if bot is not None: + bot_token = bot.bot_token + except NotImplementedError as _: + self.find_bot_available = False + except Exception as e: + self.logger.info(f"Failed to call find_bot method: {e}") token: Optional[str] = bot_token or user_token if token is None: + # No valid token was found + self._debug_log_for_not_found(enterprise_id, team_id) return None # Check cache to see if the bot object already exists @@ -501,6 +545,10 @@

          Class variables

          +
          var find_bot_available : Optional[bool]
          +
          +
          +
          var find_installation_available : Optional[bool]
          @@ -533,6 +581,7 @@

          AsyncInstallationStoreAuthorize

          diff --git a/docs/api-docs/slack_bolt/authorization/authorize.html b/docs/api-docs/slack_bolt/authorization/authorize.html index d9f4819db..d498acdbf 100644 --- a/docs/api-docs/slack_bolt/authorization/authorize.html +++ b/docs/api-docs/slack_bolt/authorization/authorize.html @@ -124,6 +124,7 @@

          Module slack_bolt.authorization.authorize

          authorize_result_cache: Dict[str, AuthorizeResult] bot_only: bool find_installation_available: bool + find_bot_available: bool def __init__( self, @@ -143,6 +144,7 @@

          Module slack_bolt.authorization.authorize

          self.find_installation_available = hasattr( installation_store, "find_installation" ) + self.find_bot_available = hasattr(installation_store, "find_bot") def __call__( self, @@ -157,7 +159,8 @@

          Module slack_bolt.authorization.authorize

          user_token: Optional[str] = None if not self.bot_only and self.find_installation_available: - # since v1.1, this is the default way + # Since v1.1, this is the default way. + # If you want to use find_bot / delete_bot only, you can set bot_only as True. try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. @@ -168,45 +171,61 @@

          Module slack_bolt.authorization.authorize

          team_id=team_id, is_enterprise_install=context.is_enterprise_install, ) - if installation is None: - self._debug_log_for_not_found(enterprise_id, team_id) - return None - - if installation.user_id != user_id: - # First off, remove the user token as the installer is a different user - installation.user_token = None - installation.user_scopes = [] - - # try to fetch the request user's installation - # to reflect the user's access token if exists - user_installation = self.installation_store.find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, + if installation is not None: + if installation.user_id != user_id: + # First off, remove the user token as the installer is a different user + installation.user_token = None + installation.user_scopes = [] + + # try to fetch the request user's installation + # to reflect the user's access token if exists + user_installation = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) + if user_installation is not None: + # Overwrite the installation with the one for this user + installation = user_installation + + bot_token, user_token = ( + installation.bot_token, + installation.user_token, ) - if user_installation is not None: - # Overwrite the installation with the one for this user - installation = user_installation - bot_token, user_token = installation.bot_token, installation.user_token except NotImplementedError as _: self.find_installation_available = False - if self.bot_only or not self.find_installation_available: - # Use find_bot to get bot value (legacy) - bot: Optional[Bot] = self.installation_store.find_bot( - enterprise_id=enterprise_id, - team_id=team_id, - is_enterprise_install=context.is_enterprise_install, + if ( + # If you intentionally use only find_bot / delete_bot, + self.bot_only + # If find_installation method is not available, + or not self.find_installation_available + # If find_installation did not return data and find_bot method is available, + or ( + self.find_bot_available is True + and bot_token is None + and user_token is None ) - if bot is None: - self._debug_log_for_not_found(enterprise_id, team_id) - return None - bot_token, user_token = bot.bot_token, None + ): + try: + bot: Optional[Bot] = self.installation_store.find_bot( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + if bot is not None: + bot_token = bot.bot_token + except NotImplementedError as _: + self.find_bot_available = False + except Exception as e: + self.logger.info(f"Failed to call find_bot method: {e}") token: Optional[str] = bot_token or user_token if token is None: + # No valid token was found + self._debug_log_for_not_found(enterprise_id, team_id) return None # Check cache to see if the bot object already exists @@ -372,6 +391,7 @@

          Ancestors

          authorize_result_cache: Dict[str, AuthorizeResult] bot_only: bool find_installation_available: bool + find_bot_available: bool def __init__( self, @@ -391,6 +411,7 @@

          Ancestors

          self.find_installation_available = hasattr( installation_store, "find_installation" ) + self.find_bot_available = hasattr(installation_store, "find_bot") def __call__( self, @@ -405,7 +426,8 @@

          Ancestors

          user_token: Optional[str] = None if not self.bot_only and self.find_installation_available: - # since v1.1, this is the default way + # Since v1.1, this is the default way. + # If you want to use find_bot / delete_bot only, you can set bot_only as True. try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. @@ -416,45 +438,61 @@

          Ancestors

          team_id=team_id, is_enterprise_install=context.is_enterprise_install, ) - if installation is None: - self._debug_log_for_not_found(enterprise_id, team_id) - return None - - if installation.user_id != user_id: - # First off, remove the user token as the installer is a different user - installation.user_token = None - installation.user_scopes = [] - - # try to fetch the request user's installation - # to reflect the user's access token if exists - user_installation = self.installation_store.find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, + if installation is not None: + if installation.user_id != user_id: + # First off, remove the user token as the installer is a different user + installation.user_token = None + installation.user_scopes = [] + + # try to fetch the request user's installation + # to reflect the user's access token if exists + user_installation = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) + if user_installation is not None: + # Overwrite the installation with the one for this user + installation = user_installation + + bot_token, user_token = ( + installation.bot_token, + installation.user_token, ) - if user_installation is not None: - # Overwrite the installation with the one for this user - installation = user_installation - bot_token, user_token = installation.bot_token, installation.user_token except NotImplementedError as _: self.find_installation_available = False - if self.bot_only or not self.find_installation_available: - # Use find_bot to get bot value (legacy) - bot: Optional[Bot] = self.installation_store.find_bot( - enterprise_id=enterprise_id, - team_id=team_id, - is_enterprise_install=context.is_enterprise_install, + if ( + # If you intentionally use only find_bot / delete_bot, + self.bot_only + # If find_installation method is not available, + or not self.find_installation_available + # If find_installation did not return data and find_bot method is available, + or ( + self.find_bot_available is True + and bot_token is None + and user_token is None ) - if bot is None: - self._debug_log_for_not_found(enterprise_id, team_id) - return None - bot_token, user_token = bot.bot_token, None + ): + try: + bot: Optional[Bot] = self.installation_store.find_bot( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + if bot is not None: + bot_token = bot.bot_token + except NotImplementedError as _: + self.find_bot_available = False + except Exception as e: + self.logger.info(f"Failed to call find_bot method: {e}") token: Optional[str] = bot_token or user_token if token is None: + # No valid token was found + self._debug_log_for_not_found(enterprise_id, team_id) return None # Check cache to see if the bot object already exists @@ -502,6 +540,10 @@

          Class variables

          +
          var find_bot_available : bool
          +
          +
          +
          var find_installation_available : bool
          @@ -535,6 +577,7 @@

        • authorize_result_cache
        • bot_only
        • +
        • find_bot_available
        • find_installation_available
        diff --git a/docs/api-docs/slack_bolt/error/index.html b/docs/api-docs/slack_bolt/error/index.html index ebd564c85..9b104c4e3 100644 --- a/docs/api-docs/slack_bolt/error/index.html +++ b/docs/api-docs/slack_bolt/error/index.html @@ -28,8 +28,30 @@

        Module slack_bolt.error

        Expand source code
        """Bolt specific error types."""
        +from typing import Optional, Union
        +
        +
         class BoltError(Exception):
        -    """General class in a Bolt app"""
        + """General class in a Bolt app""" + + +class BoltUnhandledRequestError(BoltError): + request: "BoltRequest" # type: ignore + body: dict + current_response: Optional["BoltResponse"] # type: ignore + last_global_middleware_name: Optional[str] + + def __init__( # type: ignore + self, + *, + request: Union["BoltRequest", "AsyncBoltRequest"], # type: ignore + current_response: Optional["BoltResponse"], # type: ignore + last_global_middleware_name: Optional[str] = None, + ): + self.request = request + self.body = request.body if request is not None else {} + self.current_response = current_response + self.last_global_middleware_name = last_global_middleware_name
    @@ -59,6 +81,64 @@

    Ancestors

  • builtins.Exception
  • builtins.BaseException
  • +

    Subclasses

    + +
    +
    +class BoltUnhandledRequestError +(*, request: Union[ForwardRef('BoltRequest'), ForwardRef('AsyncBoltRequest')], current_response: Optional[ForwardRef('BoltResponse')], last_global_middleware_name: Optional[str] = None) +
    +
    +

    General class in a Bolt app

    +
    + +Expand source code + +
    class BoltUnhandledRequestError(BoltError):
    +    request: "BoltRequest"  # type: ignore
    +    body: dict
    +    current_response: Optional["BoltResponse"]  # type: ignore
    +    last_global_middleware_name: Optional[str]
    +
    +    def __init__(  # type: ignore
    +        self,
    +        *,
    +        request: Union["BoltRequest", "AsyncBoltRequest"],  # type: ignore
    +        current_response: Optional["BoltResponse"],  # type: ignore
    +        last_global_middleware_name: Optional[str] = None,
    +    ):
    +        self.request = request
    +        self.body = request.body if request is not None else {}
    +        self.current_response = current_response
    +        self.last_global_middleware_name = last_global_middleware_name
    +
    +

    Ancestors

    +
      +
    • BoltError
    • +
    • builtins.Exception
    • +
    • builtins.BaseException
    • +
    +

    Class variables

    +
    +
    var body : dict
    +
    +
    +
    +
    var current_response : Optional[BoltResponse]
    +
    +
    +
    +
    var last_global_middleware_name : Optional[str]
    +
    +
    +
    +
    var request : BoltRequest
    +
    +
    +
    +
    @@ -79,6 +159,15 @@

    Index

  • BoltError

  • +
  • +

    BoltUnhandledRequestError

    + +
  • diff --git a/docs/api-docs/slack_bolt/lazy_listener/thread_runner.html b/docs/api-docs/slack_bolt/lazy_listener/thread_runner.html index 022d4b2c1..9505ade3d 100644 --- a/docs/api-docs/slack_bolt/lazy_listener/thread_runner.html +++ b/docs/api-docs/slack_bolt/lazy_listener/thread_runner.html @@ -99,6 +99,10 @@

    Ancestors

    +

    Subclasses

    +

    Class variables

    var logger : logging.Logger
    diff --git a/docs/api-docs/slack_bolt/listener/async_builtins.html b/docs/api-docs/slack_bolt/listener/async_builtins.html new file mode 100644 index 000000000..f04b99904 --- /dev/null +++ b/docs/api-docs/slack_bolt/listener/async_builtins.html @@ -0,0 +1,207 @@ + + + + + + +slack_bolt.listener.async_builtins API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.listener.async_builtins

    +
    +
    +
    + +Expand source code + +
    from slack_bolt.context.async_context import AsyncBoltContext
    +from slack_sdk.oauth.installation_store.async_installation_store import (
    +    AsyncInstallationStore,
    +)
    +
    +
    +class AsyncTokenRevocationListeners:
    +    """Listener functions to handle token revocation / uninstallation events"""
    +
    +    installation_store: AsyncInstallationStore
    +
    +    def __init__(self, installation_store: AsyncInstallationStore):
    +        self.installation_store = installation_store
    +
    +    async def handle_tokens_revoked_events(
    +        self, event: dict, context: AsyncBoltContext
    +    ) -> None:
    +        user_ids = event.get("tokens", {}).get("oauth", [])
    +        if len(user_ids) > 0:
    +            for user_id in user_ids:
    +                await self.installation_store.async_delete_installation(
    +                    enterprise_id=context.enterprise_id,
    +                    team_id=context.team_id,
    +                    user_id=user_id,
    +                )
    +        bots = event.get("tokens", {}).get("bot", [])
    +        if len(bots) > 0:
    +            await self.installation_store.async_delete_bot(
    +                enterprise_id=context.enterprise_id,
    +                team_id=context.team_id,
    +            )
    +
    +    async def handle_app_uninstalled_events(self, context: AsyncBoltContext) -> None:
    +        await self.installation_store.async_delete_all(
    +            enterprise_id=context.enterprise_id,
    +            team_id=context.team_id,
    +        )
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class AsyncTokenRevocationListeners +(installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore) +
    +
    +

    Listener functions to handle token revocation / uninstallation events

    +
    + +Expand source code + +
    class AsyncTokenRevocationListeners:
    +    """Listener functions to handle token revocation / uninstallation events"""
    +
    +    installation_store: AsyncInstallationStore
    +
    +    def __init__(self, installation_store: AsyncInstallationStore):
    +        self.installation_store = installation_store
    +
    +    async def handle_tokens_revoked_events(
    +        self, event: dict, context: AsyncBoltContext
    +    ) -> None:
    +        user_ids = event.get("tokens", {}).get("oauth", [])
    +        if len(user_ids) > 0:
    +            for user_id in user_ids:
    +                await self.installation_store.async_delete_installation(
    +                    enterprise_id=context.enterprise_id,
    +                    team_id=context.team_id,
    +                    user_id=user_id,
    +                )
    +        bots = event.get("tokens", {}).get("bot", [])
    +        if len(bots) > 0:
    +            await self.installation_store.async_delete_bot(
    +                enterprise_id=context.enterprise_id,
    +                team_id=context.team_id,
    +            )
    +
    +    async def handle_app_uninstalled_events(self, context: AsyncBoltContext) -> None:
    +        await self.installation_store.async_delete_all(
    +            enterprise_id=context.enterprise_id,
    +            team_id=context.team_id,
    +        )
    +
    +

    Class variables

    +
    +
    var installation_store : slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore
    +
    +
    +
    +
    +

    Methods

    +
    +
    +async def handle_app_uninstalled_events(self, context: AsyncBoltContext) ‑> NoneType +
    +
    +
    +
    + +Expand source code + +
    async def handle_app_uninstalled_events(self, context: AsyncBoltContext) -> None:
    +    await self.installation_store.async_delete_all(
    +        enterprise_id=context.enterprise_id,
    +        team_id=context.team_id,
    +    )
    +
    +
    +
    +async def handle_tokens_revoked_events(self, event: dict, context: AsyncBoltContext) ‑> NoneType +
    +
    +
    +
    + +Expand source code + +
    async def handle_tokens_revoked_events(
    +    self, event: dict, context: AsyncBoltContext
    +) -> None:
    +    user_ids = event.get("tokens", {}).get("oauth", [])
    +    if len(user_ids) > 0:
    +        for user_id in user_ids:
    +            await self.installation_store.async_delete_installation(
    +                enterprise_id=context.enterprise_id,
    +                team_id=context.team_id,
    +                user_id=user_id,
    +            )
    +    bots = event.get("tokens", {}).get("bot", [])
    +    if len(bots) > 0:
    +        await self.installation_store.async_delete_bot(
    +            enterprise_id=context.enterprise_id,
    +            team_id=context.team_id,
    +        )
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/async_internals.html b/docs/api-docs/slack_bolt/listener/async_internals.html new file mode 100644 index 000000000..5b7f4a507 --- /dev/null +++ b/docs/api-docs/slack_bolt/listener/async_internals.html @@ -0,0 +1,116 @@ + + + + + + +slack_bolt.listener.async_internals API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.listener.async_internals

    +
    +
    +
    + +Expand source code + +
    from logging import Logger
    +from typing import Dict, Any, Optional
    +
    +from slack_bolt.request.async_request import AsyncBoltRequest
    +from slack_bolt.request.payload_utils import (
    +    to_options,
    +    to_shortcut,
    +    to_action,
    +    to_view,
    +    to_command,
    +    to_event,
    +    to_message,
    +    to_step,
    +)
    +from slack_bolt.response import BoltResponse
    +
    +
    +def _build_all_available_args(
    +    logger: Logger,
    +    request: AsyncBoltRequest,
    +    response: Optional[BoltResponse],
    +    error: Optional[Exception] = None,
    +) -> Dict[str, Any]:
    +    all_available_args = {
    +        "logger": logger,
    +        "error": error,
    +        "client": request.context.client,
    +        "req": request,
    +        "request": request,
    +        "resp": response,
    +        "response": response,
    +        "context": request.context,
    +        # payload
    +        "body": request.body,
    +        "options": to_options(request.body),
    +        "shortcut": to_shortcut(request.body),
    +        "action": to_action(request.body),
    +        "view": to_view(request.body),
    +        "command": to_command(request.body),
    +        "event": to_event(request.body),
    +        "message": to_message(request.body),
    +        "step": to_step(request.body),
    +        # utilities
    +        "say": request.context.say,
    +        "respond": request.context.respond,
    +    }
    +    all_available_args["payload"] = (
    +        all_available_args["options"]
    +        or all_available_args["shortcut"]
    +        or all_available_args["action"]
    +        or all_available_args["view"]
    +        or all_available_args["command"]
    +        or all_available_args["event"]
    +        or all_available_args["message"]
    +        or all_available_args["step"]
    +        or request.body
    +    )
    +    return all_available_args
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html b/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html new file mode 100644 index 000000000..3ec3f124a --- /dev/null +++ b/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html @@ -0,0 +1,292 @@ + + + + + + +slack_bolt.listener.async_listener_completion_handler API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.listener.async_listener_completion_handler

    +
    +
    +
    + +Expand source code + +
    import inspect
    +from abc import ABCMeta, abstractmethod
    +from logging import Logger
    +from typing import Callable, Dict, Any, Awaitable, Optional
    +
    +from slack_bolt.listener.async_internals import (
    +    _build_all_available_args,
    +)
    +from slack_bolt.listener.internals import (
    +    _convert_all_available_args_to_kwargs,
    +)
    +
    +from slack_bolt.request.async_request import AsyncBoltRequest
    +from slack_bolt.response import BoltResponse
    +
    +
    +class AsyncListenerCompletionHandler(metaclass=ABCMeta):
    +    @abstractmethod
    +    async def handle(
    +        self,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        """Handles an unhandled exception.
    +
    +        Args:
    +            error: The raised exception.
    +            request: The request.
    +            response: The response.
    +        """
    +        raise NotImplementedError()
    +
    +
    +class AsyncCustomListenerCompletionHandler(AsyncListenerCompletionHandler):
    +    def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]):
    +        self.func = func
    +        self.logger = logger
    +        self.arg_names = inspect.getfullargspec(func).args
    +
    +    async def handle(
    +        self,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        all_available_args = _build_all_available_args(
    +            logger=self.logger,
    +            request=request,
    +            response=response,
    +        )
    +        kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs(
    +            all_available_args=all_available_args,
    +            arg_names=self.arg_names,
    +            logger=self.logger,
    +        )
    +        await self.func(**kwargs)
    +
    +
    +class AsyncDefaultListenerCompletionHandler(AsyncListenerCompletionHandler):
    +    def __init__(self, logger: Logger):
    +        self.logger = logger
    +
    +    async def handle(
    +        self,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        pass
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class AsyncCustomListenerCompletionHandler +(logger: logging.Logger, func: Callable[..., Awaitable[NoneType]]) +
    +
    +
    +
    + +Expand source code + +
    class AsyncCustomListenerCompletionHandler(AsyncListenerCompletionHandler):
    +    def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]):
    +        self.func = func
    +        self.logger = logger
    +        self.arg_names = inspect.getfullargspec(func).args
    +
    +    async def handle(
    +        self,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        all_available_args = _build_all_available_args(
    +            logger=self.logger,
    +            request=request,
    +            response=response,
    +        )
    +        kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs(
    +            all_available_args=all_available_args,
    +            arg_names=self.arg_names,
    +            logger=self.logger,
    +        )
    +        await self.func(**kwargs)
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    +
    +class AsyncDefaultListenerCompletionHandler +(logger: logging.Logger) +
    +
    +
    +
    + +Expand source code + +
    class AsyncDefaultListenerCompletionHandler(AsyncListenerCompletionHandler):
    +    def __init__(self, logger: Logger):
    +        self.logger = logger
    +
    +    async def handle(
    +        self,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        pass
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    +
    +class AsyncListenerCompletionHandler +
    +
    +
    +
    + +Expand source code + +
    class AsyncListenerCompletionHandler(metaclass=ABCMeta):
    +    @abstractmethod
    +    async def handle(
    +        self,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        """Handles an unhandled exception.
    +
    +        Args:
    +            error: The raised exception.
    +            request: The request.
    +            response: The response.
    +        """
    +        raise NotImplementedError()
    +
    +

    Subclasses

    + +

    Methods

    +
    +
    +async def handle(self, request: AsyncBoltRequest, response: Optional[BoltResponse]) ‑> NoneType +
    +
    +

    Handles an unhandled exception.

    +

    Args

    +
    +
    error
    +
    The raised exception.
    +
    request
    +
    The request.
    +
    response
    +
    The response.
    +
    +
    + +Expand source code + +
    @abstractmethod
    +async def handle(
    +    self,
    +    request: AsyncBoltRequest,
    +    response: Optional[BoltResponse],
    +) -> None:
    +    """Handles an unhandled exception.
    +
    +    Args:
    +        error: The raised exception.
    +        request: The request.
    +        response: The response.
    +    """
    +    raise NotImplementedError()
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/async_listener_error_handler.html b/docs/api-docs/slack_bolt/listener/async_listener_error_handler.html index 156ca10f9..5d1b73662 100644 --- a/docs/api-docs/slack_bolt/listener/async_listener_error_handler.html +++ b/docs/api-docs/slack_bolt/listener/async_listener_error_handler.html @@ -31,20 +31,16 @@

    Module slack_bolt.listener.async_listener_error_handler< from logging import Logger from typing import Callable, Dict, Any, Awaitable, Optional +from slack_bolt.listener.async_internals import ( + _build_all_available_args, +) +from slack_bolt.listener.internals import ( + _convert_all_available_args_to_kwargs, +) + from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.request.payload_utils import ( - to_options, - to_shortcut, - to_action, - to_view, - to_command, - to_event, - to_message, - to_step, -) - class AsyncListenerErrorHandler(metaclass=ABCMeta): @abstractmethod @@ -65,7 +61,9 @@

    Module slack_bolt.listener.async_listener_error_handler< class AsyncCustomListenerErrorHandler(AsyncListenerErrorHandler): - def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]): + def __init__( + self, logger: Logger, func: Callable[..., Awaitable[Optional[BoltResponse]]] + ): self.func = func self.logger = logger self.arg_names = inspect.getfullargspec(func).args @@ -76,52 +74,24 @@

    Module slack_bolt.listener.async_listener_error_handler< request: AsyncBoltRequest, response: Optional[BoltResponse], ) -> None: - all_available_args = { - "logger": self.logger, - "error": error, - "client": request.context.client, - "req": request, - "request": request, - "resp": response, - "response": response, - "context": request.context, - "body": request.body, - # payload - "body": request.body, - "options": to_options(request.body), - "shortcut": to_shortcut(request.body), - "action": to_action(request.body), - "view": to_view(request.body), - "command": to_command(request.body), - "event": to_event(request.body), - "message": to_message(request.body), - "step": to_step(request.body), - # utilities - "say": request.context.say, - "respond": request.context.respond, - } - all_available_args["payload"] = ( - all_available_args["options"] - or all_available_args["shortcut"] - or all_available_args["action"] - or all_available_args["view"] - or all_available_args["command"] - or all_available_args["event"] - or all_available_args["message"] - or all_available_args["step"] - or request.body + all_available_args = _build_all_available_args( + logger=self.logger, + error=error, + request=request, + response=response, ) - - kwargs: Dict[str, Any] = { # type: ignore - k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore - } - found_arg_names = kwargs.keys() - for name in self.arg_names: - if name not in found_arg_names: - self.logger.warning(f"{name} is not a valid argument") - kwargs[name] = None - - await self.func(**kwargs) + kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( + all_available_args=all_available_args, + arg_names=self.arg_names, + logger=self.logger, + ) + returned_response = await self.func(**kwargs) + if returned_response is not None and isinstance( + returned_response, BoltResponse + ): + response.status = returned_response.status + response.headers = returned_response.headers + response.body = returned_response.body class AsyncDefaultListenerErrorHandler(AsyncListenerErrorHandler): @@ -149,7 +119,7 @@

    Classes

    class AsyncCustomListenerErrorHandler -(logger: logging.Logger, func: Callable[..., Awaitable[NoneType]]) +(logger: logging.Logger, func: Callable[..., Awaitable[Optional[BoltResponse]]])
    @@ -158,7 +128,9 @@

    Classes

    Expand source code
    class AsyncCustomListenerErrorHandler(AsyncListenerErrorHandler):
    -    def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]):
    +    def __init__(
    +        self, logger: Logger, func: Callable[..., Awaitable[Optional[BoltResponse]]]
    +    ):
             self.func = func
             self.logger = logger
             self.arg_names = inspect.getfullargspec(func).args
    @@ -169,52 +141,24 @@ 

    Classes

    request: AsyncBoltRequest, response: Optional[BoltResponse], ) -> None: - all_available_args = { - "logger": self.logger, - "error": error, - "client": request.context.client, - "req": request, - "request": request, - "resp": response, - "response": response, - "context": request.context, - "body": request.body, - # payload - "body": request.body, - "options": to_options(request.body), - "shortcut": to_shortcut(request.body), - "action": to_action(request.body), - "view": to_view(request.body), - "command": to_command(request.body), - "event": to_event(request.body), - "message": to_message(request.body), - "step": to_step(request.body), - # utilities - "say": request.context.say, - "respond": request.context.respond, - } - all_available_args["payload"] = ( - all_available_args["options"] - or all_available_args["shortcut"] - or all_available_args["action"] - or all_available_args["view"] - or all_available_args["command"] - or all_available_args["event"] - or all_available_args["message"] - or all_available_args["step"] - or request.body + all_available_args = _build_all_available_args( + logger=self.logger, + error=error, + request=request, + response=response, ) - - kwargs: Dict[str, Any] = { # type: ignore - k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore - } - found_arg_names = kwargs.keys() - for name in self.arg_names: - if name not in found_arg_names: - self.logger.warning(f"{name} is not a valid argument") - kwargs[name] = None - - await self.func(**kwargs)
    + kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( + all_available_args=all_available_args, + arg_names=self.arg_names, + logger=self.logger, + ) + returned_response = await self.func(**kwargs) + if returned_response is not None and isinstance( + returned_response, BoltResponse + ): + response.status = returned_response.status + response.headers = returned_response.headers + response.body = returned_response.body

    Ancestors

      diff --git a/docs/api-docs/slack_bolt/listener/asyncio_runner.html b/docs/api-docs/slack_bolt/listener/asyncio_runner.html index 85a809379..20071b098 100644 --- a/docs/api-docs/slack_bolt/listener/asyncio_runner.html +++ b/docs/api-docs/slack_bolt/listener/asyncio_runner.html @@ -35,6 +35,9 @@

      Module slack_bolt.listener.asyncio_runner

      from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.lazy_listener.async_runner import AsyncLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener +from slack_bolt.listener.async_listener_completion_handler import ( + AsyncListenerCompletionHandler, +) from slack_bolt.listener.async_listener_error_handler import AsyncListenerErrorHandler from slack_bolt.logger.messages import ( debug_responding, @@ -50,6 +53,7 @@

      Module slack_bolt.listener.asyncio_runner

      logger: Logger process_before_response: bool listener_error_handler: AsyncListenerErrorHandler + listener_completion_handler: AsyncListenerCompletionHandler lazy_listener_runner: AsyncLazyListenerRunner def __init__( @@ -57,11 +61,13 @@

      Module slack_bolt.listener.asyncio_runner

      logger: Logger, process_before_response: bool, listener_error_handler: AsyncListenerErrorHandler, + listener_completion_handler: AsyncListenerCompletionHandler, lazy_listener_runner: AsyncLazyListenerRunner, ): self.logger = logger self.process_before_response = process_before_response self.listener_error_handler = listener_error_handler + self.listener_completion_handler = listener_completion_handler self.lazy_listener_runner = lazy_listener_runner async def run( @@ -96,6 +102,10 @@

      Module slack_bolt.listener.asyncio_runner

      response=response, ) ack.response = response + finally: + await self.listener_completion_handler.handle( + request=request, response=response + ) for lazy_func in listener.lazy_functions: if request.lazy_function_name: @@ -149,6 +159,10 @@

      Module slack_bolt.listener.asyncio_runner

      response=response, ) ack.response = response + finally: + await self.listener_completion_handler.handle( + request=request, response=response + ) _f: Future = asyncio.ensure_future( run_ack_function_asynchronously(ack, request, response) @@ -224,7 +238,7 @@

      Classes

      class AsyncioListenerRunner -(logger: logging.Logger, process_before_response: bool, listener_error_handler: AsyncListenerErrorHandler, lazy_listener_runner: AsyncLazyListenerRunner) +(logger: logging.Logger, process_before_response: bool, listener_error_handler: AsyncListenerErrorHandler, listener_completion_handler: AsyncListenerCompletionHandler, lazy_listener_runner: AsyncLazyListenerRunner)
      @@ -236,6 +250,7 @@

      Classes

      logger: Logger process_before_response: bool listener_error_handler: AsyncListenerErrorHandler + listener_completion_handler: AsyncListenerCompletionHandler lazy_listener_runner: AsyncLazyListenerRunner def __init__( @@ -243,11 +258,13 @@

      Classes

      logger: Logger, process_before_response: bool, listener_error_handler: AsyncListenerErrorHandler, + listener_completion_handler: AsyncListenerCompletionHandler, lazy_listener_runner: AsyncLazyListenerRunner, ): self.logger = logger self.process_before_response = process_before_response self.listener_error_handler = listener_error_handler + self.listener_completion_handler = listener_completion_handler self.lazy_listener_runner = lazy_listener_runner async def run( @@ -282,6 +299,10 @@

      Classes

      response=response, ) ack.response = response + finally: + await self.listener_completion_handler.handle( + request=request, response=response + ) for lazy_func in listener.lazy_functions: if request.lazy_function_name: @@ -335,6 +356,10 @@

      Classes

      response=response, ) ack.response = response + finally: + await self.listener_completion_handler.handle( + request=request, response=response + ) _f: Future = asyncio.ensure_future( run_ack_function_asynchronously(ack, request, response) @@ -404,6 +429,10 @@

      Class variables

      +
      var listener_completion_handlerAsyncListenerCompletionHandler
      +
      +
      +
      var listener_error_handlerAsyncListenerErrorHandler
      @@ -460,6 +489,10 @@

      Methods

      response=response, ) ack.response = response + finally: + await self.listener_completion_handler.handle( + request=request, response=response + ) for lazy_func in listener.lazy_functions: if request.lazy_function_name: @@ -513,6 +546,10 @@

      Methods

      response=response, ) ack.response = response + finally: + await self.listener_completion_handler.handle( + request=request, response=response + ) _f: Future = asyncio.ensure_future( run_ack_function_asynchronously(ack, request, response) @@ -574,6 +611,7 @@

      Index

      AsyncioListenerRunner

      • lazy_listener_runner
      • +
      • listener_completion_handler
      • listener_error_handler
      • logger
      • process_before_response
      • diff --git a/docs/api-docs/slack_bolt/listener/builtins.html b/docs/api-docs/slack_bolt/listener/builtins.html new file mode 100644 index 000000000..bc0783730 --- /dev/null +++ b/docs/api-docs/slack_bolt/listener/builtins.html @@ -0,0 +1,201 @@ + + + + + + +slack_bolt.listener.builtins API documentation + + + + + + + + + + + +
        +
        +
        +

        Module slack_bolt.listener.builtins

        +
        +
        +
        + +Expand source code + +
        from slack_sdk.oauth import InstallationStore
        +
        +from slack_bolt.context.context import BoltContext
        +from slack_sdk.oauth.installation_store.installation_store import InstallationStore
        +
        +
        +class TokenRevocationListeners:
        +    """Listener functions to handle token revocation / uninstallation events"""
        +
        +    installation_store: InstallationStore
        +
        +    def __init__(self, installation_store: InstallationStore):
        +        self.installation_store = installation_store
        +
        +    def handle_tokens_revoked_events(self, event: dict, context: BoltContext) -> None:
        +        user_ids = event.get("tokens", {}).get("oauth", [])
        +        if len(user_ids) > 0:
        +            for user_id in user_ids:
        +                self.installation_store.delete_installation(
        +                    enterprise_id=context.enterprise_id,
        +                    team_id=context.team_id,
        +                    user_id=user_id,
        +                )
        +        bots = event.get("tokens", {}).get("bot", [])
        +        if len(bots) > 0:
        +            self.installation_store.delete_bot(
        +                enterprise_id=context.enterprise_id,
        +                team_id=context.team_id,
        +            )
        +
        +    def handle_app_uninstalled_events(self, context: BoltContext) -> None:
        +        self.installation_store.delete_all(
        +            enterprise_id=context.enterprise_id,
        +            team_id=context.team_id,
        +        )
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +

        Classes

        +
        +
        +class TokenRevocationListeners +(installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore) +
        +
        +

        Listener functions to handle token revocation / uninstallation events

        +
        + +Expand source code + +
        class TokenRevocationListeners:
        +    """Listener functions to handle token revocation / uninstallation events"""
        +
        +    installation_store: InstallationStore
        +
        +    def __init__(self, installation_store: InstallationStore):
        +        self.installation_store = installation_store
        +
        +    def handle_tokens_revoked_events(self, event: dict, context: BoltContext) -> None:
        +        user_ids = event.get("tokens", {}).get("oauth", [])
        +        if len(user_ids) > 0:
        +            for user_id in user_ids:
        +                self.installation_store.delete_installation(
        +                    enterprise_id=context.enterprise_id,
        +                    team_id=context.team_id,
        +                    user_id=user_id,
        +                )
        +        bots = event.get("tokens", {}).get("bot", [])
        +        if len(bots) > 0:
        +            self.installation_store.delete_bot(
        +                enterprise_id=context.enterprise_id,
        +                team_id=context.team_id,
        +            )
        +
        +    def handle_app_uninstalled_events(self, context: BoltContext) -> None:
        +        self.installation_store.delete_all(
        +            enterprise_id=context.enterprise_id,
        +            team_id=context.team_id,
        +        )
        +
        +

        Class variables

        +
        +
        var installation_store : slack_sdk.oauth.installation_store.installation_store.InstallationStore
        +
        +
        +
        +
        +

        Methods

        +
        +
        +def handle_app_uninstalled_events(self, context: BoltContext) ‑> NoneType +
        +
        +
        +
        + +Expand source code + +
        def handle_app_uninstalled_events(self, context: BoltContext) -> None:
        +    self.installation_store.delete_all(
        +        enterprise_id=context.enterprise_id,
        +        team_id=context.team_id,
        +    )
        +
        +
        +
        +def handle_tokens_revoked_events(self, event: dict, context: BoltContext) ‑> NoneType +
        +
        +
        +
        + +Expand source code + +
        def handle_tokens_revoked_events(self, event: dict, context: BoltContext) -> None:
        +    user_ids = event.get("tokens", {}).get("oauth", [])
        +    if len(user_ids) > 0:
        +        for user_id in user_ids:
        +            self.installation_store.delete_installation(
        +                enterprise_id=context.enterprise_id,
        +                team_id=context.team_id,
        +                user_id=user_id,
        +            )
        +    bots = event.get("tokens", {}).get("bot", [])
        +    if len(bots) > 0:
        +        self.installation_store.delete_bot(
        +            enterprise_id=context.enterprise_id,
        +            team_id=context.team_id,
        +        )
        +
        +
        +
        +
        +
        +
        +
        + +
        + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/index.html b/docs/api-docs/slack_bolt/listener/index.html index 1a86aaa21..0c6b922c1 100644 --- a/docs/api-docs/slack_bolt/listener/index.html +++ b/docs/api-docs/slack_bolt/listener/index.html @@ -49,10 +49,22 @@

        Module slack_bolt.listener

        Sub-modules

        +
        slack_bolt.listener.async_builtins
        +
        +
        +
        +
        slack_bolt.listener.async_internals
        +
        +
        +
        slack_bolt.listener.async_listener
        +
        slack_bolt.listener.async_listener_completion_handler
        +
        +
        +
        slack_bolt.listener.async_listener_error_handler
        @@ -61,14 +73,26 @@

        Sub-modules

        +
        slack_bolt.listener.builtins
        +
        +
        +
        slack_bolt.listener.custom_listener
        +
        slack_bolt.listener.internals
        +
        +
        +
        slack_bolt.listener.listener
        +
        slack_bolt.listener.listener_completion_handler
        +
        +
        +
        slack_bolt.listener.listener_error_handler
        @@ -99,11 +123,17 @@

        Index

      • Sub-modules

        diff --git a/docs/api-docs/slack_bolt/listener/internals.html b/docs/api-docs/slack_bolt/listener/internals.html new file mode 100644 index 000000000..c2104f104 --- /dev/null +++ b/docs/api-docs/slack_bolt/listener/internals.html @@ -0,0 +1,133 @@ + + + + + + +slack_bolt.listener.internals API documentation + + + + + + + + + + + +
        +
        +
        +

        Module slack_bolt.listener.internals

        +
        +
        +
        + +Expand source code + +
        from logging import Logger
        +from typing import Optional, Dict, Any, List
        +
        +from slack_bolt.request.request import BoltRequest
        +from slack_bolt.response.response import BoltResponse
        +
        +from slack_bolt.request.payload_utils import (
        +    to_options,
        +    to_shortcut,
        +    to_action,
        +    to_view,
        +    to_command,
        +    to_event,
        +    to_message,
        +    to_step,
        +)
        +
        +
        +def _build_all_available_args(
        +    logger: Logger,
        +    request: BoltRequest,
        +    response: Optional[BoltResponse],
        +    error: Optional[Exception] = None,
        +) -> Dict[str, Any]:
        +    all_available_args = {
        +        "logger": logger,
        +        "error": error,
        +        "client": request.context.client,
        +        "req": request,
        +        "request": request,
        +        "resp": response,
        +        "response": response,
        +        "context": request.context,
        +        # payload
        +        "body": request.body,
        +        "options": to_options(request.body),
        +        "shortcut": to_shortcut(request.body),
        +        "action": to_action(request.body),
        +        "view": to_view(request.body),
        +        "command": to_command(request.body),
        +        "event": to_event(request.body),
        +        "message": to_message(request.body),
        +        "step": to_step(request.body),
        +        # utilities
        +        "say": request.context.say,
        +        "respond": request.context.respond,
        +    }
        +    all_available_args["payload"] = (
        +        all_available_args["options"]
        +        or all_available_args["shortcut"]
        +        or all_available_args["action"]
        +        or all_available_args["view"]
        +        or all_available_args["command"]
        +        or all_available_args["event"]
        +        or all_available_args["message"]
        +        or all_available_args["step"]
        +        or request.body
        +    )
        +    return all_available_args
        +
        +
        +def _convert_all_available_args_to_kwargs(
        +    all_available_args: Dict[str, Any],
        +    arg_names: List[str],
        +    logger: Logger,
        +) -> Dict[str, Any]:
        +    kwargs: Dict[str, Any] = {  # type: ignore
        +        k: v for k, v in all_available_args.items() if k in arg_names  # type: ignore
        +    }
        +    found_arg_names = kwargs.keys()
        +    for name in arg_names:
        +        if name not in found_arg_names:
        +            logger.warning(f"{name} is not a valid argument")
        +            kwargs[name] = None
        +    return kwargs
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        + +
        + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/listener_completion_handler.html b/docs/api-docs/slack_bolt/listener/listener_completion_handler.html new file mode 100644 index 000000000..c4a976548 --- /dev/null +++ b/docs/api-docs/slack_bolt/listener/listener_completion_handler.html @@ -0,0 +1,285 @@ + + + + + + +slack_bolt.listener.listener_completion_handler API documentation + + + + + + + + + + + +
        +
        +
        +

        Module slack_bolt.listener.listener_completion_handler

        +
        +
        +
        + +Expand source code + +
        import inspect
        +from abc import ABCMeta, abstractmethod
        +from logging import Logger
        +from typing import Callable, Dict, Any, Optional
        +
        +from slack_bolt.listener.internals import (
        +    _build_all_available_args,
        +    _convert_all_available_args_to_kwargs,
        +)
        +from slack_bolt.request.request import BoltRequest
        +from slack_bolt.response.response import BoltResponse
        +
        +
        +class ListenerCompletionHandler(metaclass=ABCMeta):
        +    @abstractmethod
        +    def handle(
        +        self,
        +        request: BoltRequest,
        +        response: Optional[BoltResponse],
        +    ) -> None:
        +        """Handles an unhandled exception.
        +
        +        Args:
        +            request: The request.
        +            response: The response.
        +        """
        +        raise NotImplementedError()
        +
        +
        +class CustomListenerCompletionHandler(ListenerCompletionHandler):
        +    def __init__(self, logger: Logger, func: Callable[..., None]):
        +        self.func = func
        +        self.logger = logger
        +        self.arg_names = inspect.getfullargspec(func).args
        +
        +    def handle(
        +        self,
        +        request: BoltRequest,
        +        response: Optional[BoltResponse],
        +    ):
        +        all_available_args = _build_all_available_args(
        +            logger=self.logger,
        +            request=request,
        +            response=response,
        +        )
        +        kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs(
        +            all_available_args=all_available_args,
        +            arg_names=self.arg_names,
        +            logger=self.logger,
        +        )
        +        self.func(**kwargs)
        +
        +
        +class DefaultListenerCompletionHandler(ListenerCompletionHandler):
        +    def __init__(self, logger: Logger):
        +        self.logger = logger
        +
        +    def handle(
        +        self,
        +        request: BoltRequest,
        +        response: Optional[BoltResponse],
        +    ):
        +        pass
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +

        Classes

        +
        +
        +class CustomListenerCompletionHandler +(logger: logging.Logger, func: Callable[..., NoneType]) +
        +
        +
        +
        + +Expand source code + +
        class CustomListenerCompletionHandler(ListenerCompletionHandler):
        +    def __init__(self, logger: Logger, func: Callable[..., None]):
        +        self.func = func
        +        self.logger = logger
        +        self.arg_names = inspect.getfullargspec(func).args
        +
        +    def handle(
        +        self,
        +        request: BoltRequest,
        +        response: Optional[BoltResponse],
        +    ):
        +        all_available_args = _build_all_available_args(
        +            logger=self.logger,
        +            request=request,
        +            response=response,
        +        )
        +        kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs(
        +            all_available_args=all_available_args,
        +            arg_names=self.arg_names,
        +            logger=self.logger,
        +        )
        +        self.func(**kwargs)
        +
        +

        Ancestors

        + +

        Inherited members

        + +
        +
        +class DefaultListenerCompletionHandler +(logger: logging.Logger) +
        +
        +
        +
        + +Expand source code + +
        class DefaultListenerCompletionHandler(ListenerCompletionHandler):
        +    def __init__(self, logger: Logger):
        +        self.logger = logger
        +
        +    def handle(
        +        self,
        +        request: BoltRequest,
        +        response: Optional[BoltResponse],
        +    ):
        +        pass
        +
        +

        Ancestors

        + +

        Inherited members

        + +
        +
        +class ListenerCompletionHandler +
        +
        +
        +
        + +Expand source code + +
        class ListenerCompletionHandler(metaclass=ABCMeta):
        +    @abstractmethod
        +    def handle(
        +        self,
        +        request: BoltRequest,
        +        response: Optional[BoltResponse],
        +    ) -> None:
        +        """Handles an unhandled exception.
        +
        +        Args:
        +            request: The request.
        +            response: The response.
        +        """
        +        raise NotImplementedError()
        +
        +

        Subclasses

        + +

        Methods

        +
        +
        +def handle(self, request: BoltRequest, response: Optional[BoltResponse]) ‑> NoneType +
        +
        +

        Handles an unhandled exception.

        +

        Args

        +
        +
        request
        +
        The request.
        +
        response
        +
        The response.
        +
        +
        + +Expand source code + +
        @abstractmethod
        +def handle(
        +    self,
        +    request: BoltRequest,
        +    response: Optional[BoltResponse],
        +) -> None:
        +    """Handles an unhandled exception.
        +
        +    Args:
        +        request: The request.
        +        response: The response.
        +    """
        +    raise NotImplementedError()
        +
        +
        +
        +
        +
        +
        +
        + +
        + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/listener_error_handler.html b/docs/api-docs/slack_bolt/listener/listener_error_handler.html index a92ce6e33..675de1360 100644 --- a/docs/api-docs/slack_bolt/listener/listener_error_handler.html +++ b/docs/api-docs/slack_bolt/listener/listener_error_handler.html @@ -31,20 +31,14 @@

        Module slack_bolt.listener.listener_error_handler from logging import Logger from typing import Callable, Dict, Any, Optional +from slack_bolt.listener.internals import ( + _build_all_available_args, + _convert_all_available_args_to_kwargs, +) + from slack_bolt.request.request import BoltRequest from slack_bolt.response.response import BoltResponse -from slack_bolt.request.payload_utils import ( - to_options, - to_shortcut, - to_action, - to_view, - to_command, - to_event, - to_message, - to_step, -) - class ListenerErrorHandler(metaclass=ABCMeta): @abstractmethod @@ -65,7 +59,7 @@

        Module slack_bolt.listener.listener_error_handler class CustomListenerErrorHandler(ListenerErrorHandler): - def __init__(self, logger: Logger, func: Callable[..., None]): + def __init__(self, logger: Logger, func: Callable[..., Optional[BoltResponse]]): self.func = func self.logger = logger self.arg_names = inspect.getfullargspec(func).args @@ -76,52 +70,24 @@

        Module slack_bolt.listener.listener_error_handler request: BoltRequest, response: Optional[BoltResponse], ): - all_available_args = { - "logger": self.logger, - "error": error, - "client": request.context.client, - "req": request, - "request": request, - "resp": response, - "response": response, - "context": request.context, - "body": request.body, - # payload - "body": request.body, - "options": to_options(request.body), - "shortcut": to_shortcut(request.body), - "action": to_action(request.body), - "view": to_view(request.body), - "command": to_command(request.body), - "event": to_event(request.body), - "message": to_message(request.body), - "step": to_step(request.body), - # utilities - "say": request.context.say, - "respond": request.context.respond, - } - all_available_args["payload"] = ( - all_available_args["options"] - or all_available_args["shortcut"] - or all_available_args["action"] - or all_available_args["view"] - or all_available_args["command"] - or all_available_args["event"] - or all_available_args["message"] - or all_available_args["step"] - or request.body + all_available_args = _build_all_available_args( + logger=self.logger, + error=error, + request=request, + response=response, ) - - kwargs: Dict[str, Any] = { # type: ignore - k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore - } - found_arg_names = kwargs.keys() - for name in self.arg_names: - if name not in found_arg_names: - self.logger.warning(f"{name} is not a valid argument") - kwargs[name] = None - - self.func(**kwargs) + kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( + all_available_args=all_available_args, + arg_names=self.arg_names, + logger=self.logger, + ) + returned_response = self.func(**kwargs) + if returned_response is not None and isinstance( + returned_response, BoltResponse + ): + response.status = returned_response.status + response.headers = returned_response.headers + response.body = returned_response.body class DefaultListenerErrorHandler(ListenerErrorHandler): @@ -149,7 +115,7 @@

        Classes

        class CustomListenerErrorHandler -(logger: logging.Logger, func: Callable[..., NoneType]) +(logger: logging.Logger, func: Callable[..., Optional[BoltResponse]])
        @@ -158,7 +124,7 @@

        Classes

        Expand source code
        class CustomListenerErrorHandler(ListenerErrorHandler):
        -    def __init__(self, logger: Logger, func: Callable[..., None]):
        +    def __init__(self, logger: Logger, func: Callable[..., Optional[BoltResponse]]):
                 self.func = func
                 self.logger = logger
                 self.arg_names = inspect.getfullargspec(func).args
        @@ -169,52 +135,24 @@ 

        Classes

        request: BoltRequest, response: Optional[BoltResponse], ): - all_available_args = { - "logger": self.logger, - "error": error, - "client": request.context.client, - "req": request, - "request": request, - "resp": response, - "response": response, - "context": request.context, - "body": request.body, - # payload - "body": request.body, - "options": to_options(request.body), - "shortcut": to_shortcut(request.body), - "action": to_action(request.body), - "view": to_view(request.body), - "command": to_command(request.body), - "event": to_event(request.body), - "message": to_message(request.body), - "step": to_step(request.body), - # utilities - "say": request.context.say, - "respond": request.context.respond, - } - all_available_args["payload"] = ( - all_available_args["options"] - or all_available_args["shortcut"] - or all_available_args["action"] - or all_available_args["view"] - or all_available_args["command"] - or all_available_args["event"] - or all_available_args["message"] - or all_available_args["step"] - or request.body + all_available_args = _build_all_available_args( + logger=self.logger, + error=error, + request=request, + response=response, ) - - kwargs: Dict[str, Any] = { # type: ignore - k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore - } - found_arg_names = kwargs.keys() - for name in self.arg_names: - if name not in found_arg_names: - self.logger.warning(f"{name} is not a valid argument") - kwargs[name] = None - - self.func(**kwargs)
        + kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( + all_available_args=all_available_args, + arg_names=self.arg_names, + logger=self.logger, + ) + returned_response = self.func(**kwargs) + if returned_response is not None and isinstance( + returned_response, BoltResponse + ): + response.status = returned_response.status + response.headers = returned_response.headers + response.body = returned_response.body

        Ancestors

          diff --git a/docs/api-docs/slack_bolt/listener/thread_runner.html b/docs/api-docs/slack_bolt/listener/thread_runner.html index 94eeaa73a..d3dee70a9 100644 --- a/docs/api-docs/slack_bolt/listener/thread_runner.html +++ b/docs/api-docs/slack_bolt/listener/thread_runner.html @@ -33,6 +33,7 @@

          Module slack_bolt.listener.thread_runner

          from slack_bolt.lazy_listener import LazyListenerRunner from slack_bolt.listener import Listener +from slack_bolt.listener.listener_completion_handler import ListenerCompletionHandler from slack_bolt.listener.listener_error_handler import ListenerErrorHandler from slack_bolt.logger.messages import ( debug_responding, @@ -48,6 +49,7 @@

          Module slack_bolt.listener.thread_runner

          logger: Logger process_before_response: bool listener_error_handler: ListenerErrorHandler + listener_completion_handler: ListenerCompletionHandler listener_executor: ThreadPoolExecutor lazy_listener_runner: LazyListenerRunner @@ -56,12 +58,14 @@

          Module slack_bolt.listener.thread_runner

          logger: Logger, process_before_response: bool, listener_error_handler: ListenerErrorHandler, + listener_completion_handler: ListenerCompletionHandler, listener_executor: ThreadPoolExecutor, lazy_listener_runner: LazyListenerRunner, ): self.logger = logger self.process_before_response = process_before_response self.listener_error_handler = listener_error_handler + self.listener_completion_handler = listener_completion_handler self.listener_executor = listener_executor self.lazy_listener_runner = lazy_listener_runner @@ -97,6 +101,11 @@

          Module slack_bolt.listener.thread_runner

          response=response, ) ack.response = response + finally: + self.listener_completion_handler.handle( + request=request, + response=response, + ) for lazy_func in listener.lazy_functions: if request.lazy_function_name: @@ -150,6 +159,11 @@

          Module slack_bolt.listener.thread_runner

          response=response, ) ack.response = response + finally: + self.listener_completion_handler.handle( + request=request, + response=response, + ) self.listener_executor.submit(run_ack_function_asynchronously) @@ -221,7 +235,7 @@

          Classes

          class ThreadListenerRunner -(logger: logging.Logger, process_before_response: bool, listener_error_handler: ListenerErrorHandler, listener_executor: concurrent.futures.thread.ThreadPoolExecutor, lazy_listener_runner: LazyListenerRunner) +(logger: logging.Logger, process_before_response: bool, listener_error_handler: ListenerErrorHandler, listener_completion_handler: ListenerCompletionHandler, listener_executor: concurrent.futures.thread.ThreadPoolExecutor, lazy_listener_runner: LazyListenerRunner)
          @@ -233,6 +247,7 @@

          Classes

          logger: Logger process_before_response: bool listener_error_handler: ListenerErrorHandler + listener_completion_handler: ListenerCompletionHandler listener_executor: ThreadPoolExecutor lazy_listener_runner: LazyListenerRunner @@ -241,12 +256,14 @@

          Classes

          logger: Logger, process_before_response: bool, listener_error_handler: ListenerErrorHandler, + listener_completion_handler: ListenerCompletionHandler, listener_executor: ThreadPoolExecutor, lazy_listener_runner: LazyListenerRunner, ): self.logger = logger self.process_before_response = process_before_response self.listener_error_handler = listener_error_handler + self.listener_completion_handler = listener_completion_handler self.listener_executor = listener_executor self.lazy_listener_runner = lazy_listener_runner @@ -282,6 +299,11 @@

          Classes

          response=response, ) ack.response = response + finally: + self.listener_completion_handler.handle( + request=request, + response=response, + ) for lazy_func in listener.lazy_functions: if request.lazy_function_name: @@ -335,6 +357,11 @@

          Classes

          response=response, ) ack.response = response + finally: + self.listener_completion_handler.handle( + request=request, + response=response, + ) self.listener_executor.submit(run_ack_function_asynchronously) @@ -400,6 +427,10 @@

          Class variables

          +
          var listener_completion_handlerListenerCompletionHandler
          +
          +
          +
          var listener_error_handlerListenerErrorHandler
          @@ -460,6 +491,11 @@

          Methods

          response=response, ) ack.response = response + finally: + self.listener_completion_handler.handle( + request=request, + response=response, + ) for lazy_func in listener.lazy_functions: if request.lazy_function_name: @@ -513,6 +549,11 @@

          Methods

          response=response, ) ack.response = response + finally: + self.listener_completion_handler.handle( + request=request, + response=response, + ) self.listener_executor.submit(run_ack_function_asynchronously) @@ -572,6 +613,7 @@

          Index

          ThreadListenerRunner

          • lazy_listener_runner
          • +
          • listener_completion_handler
          • listener_error_handler
          • listener_executor
          • logger
          • diff --git a/docs/api-docs/slack_bolt/logger/messages.html b/docs/api-docs/slack_bolt/logger/messages.html index 4031c77a1..34dbd5365 100644 --- a/docs/api-docs/slack_bolt/logger/messages.html +++ b/docs/api-docs/slack_bolt/logger/messages.html @@ -86,6 +86,13 @@

            Module slack_bolt.logger.messages

            ) +def error_installation_store_required_for_builtin_listeners() -> str: + return ( + "To use the event listeners for token revocation handling, " + "setting a valid `installation_store` to `App`/`AsyncApp` is required." + ) + + # ------------------------------- # Warning # ------------------------------- @@ -106,7 +113,18 @@

            Module slack_bolt.logger.messages

            return "As you gave both `installation_store` and `oauth_settings`/`auth_flow`, the top level one is unused." -def warning_unhandled_request(req: Union[BoltRequest, "AsyncBoltRequest"]) -> str: # type: ignore +def warning_unhandled_by_global_middleware( # type: ignore + name: str, req: Union[BoltRequest, "AsyncBoltRequest"] # type: ignore +) -> str: # type: ignore + return ( + f"A global middleware ({name}) skipped calling `next()` " + f"without providing a response for the request ({req.body})" + ) + + +def warning_unhandled_request( # type: ignore + req: Union[BoltRequest, "AsyncBoltRequest"], # type: ignore +) -> str: # type: ignore return f"Unhandled request ({req.body})" @@ -315,6 +333,22 @@

            Functions

            return "`client` must be a slack_sdk.web.async_client.AsyncWebClient"
          +
          +def error_installation_store_required_for_builtin_listeners() ‑> str +
          +
          +
          +
          + +Expand source code + +
          def error_installation_store_required_for_builtin_listeners() -> str:
          +    return (
          +        "To use the event listeners for token revocation handling, "
          +        "setting a valid `installation_store` to `App`/`AsyncApp` is required."
          +    )
          +
          +
          def error_listener_function_must_be_coro_func(func_name: str) ‑> str
          @@ -506,6 +540,24 @@

          Functions

          )
        +
        +def warning_unhandled_by_global_middleware(name: str, req: Union[BoltRequest, ForwardRef('AsyncBoltRequest')]) ‑> str +
        +
        +
        +
        + +Expand source code + +
        def warning_unhandled_by_global_middleware(  # type: ignore
        +    name: str, req: Union[BoltRequest, "AsyncBoltRequest"]  # type: ignore
        +) -> str:  # type: ignore
        +    return (
        +        f"A global middleware ({name}) skipped calling `next()` "
        +        f"without providing a response for the request ({req.body})"
        +    )
        +
        +
        def warning_unhandled_request(req: Union[BoltRequest, ForwardRef('AsyncBoltRequest')]) ‑> str
        @@ -515,7 +567,9 @@

        Functions

        Expand source code -
        def warning_unhandled_request(req: Union[BoltRequest, "AsyncBoltRequest"]) -> str:  # type: ignore
        +
        def warning_unhandled_request(  # type: ignore
        +    req: Union[BoltRequest, "AsyncBoltRequest"],  # type: ignore
        +) -> str:  # type: ignore
             return f"Unhandled request ({req.body})"
      • @@ -547,6 +601,7 @@

        Index

      • error_authorize_conflicts
      • error_client_invalid_type
      • error_client_invalid_type_async
      • +
      • error_installation_store_required_for_builtin_listeners
      • error_listener_function_must_be_coro_func
      • error_message_event_type
      • error_oauth_flow_invalid_type_async
      • @@ -560,6 +615,7 @@

        Index

      • warning_installation_store_conflicts
      • warning_skip_uncommon_arg_name
      • warning_token_skipped
      • +
      • warning_unhandled_by_global_middleware
      • warning_unhandled_request
      diff --git a/docs/api-docs/slack_bolt/request/async_internals.html b/docs/api-docs/slack_bolt/request/async_internals.html index 78654c2a5..d8dec5586 100644 --- a/docs/api-docs/slack_bolt/request/async_internals.html +++ b/docs/api-docs/slack_bolt/request/async_internals.html @@ -34,27 +34,36 @@

      Module slack_bolt.request.async_internals

      extract_team_id, extract_user_id, extract_channel_id, + debug_multiple_response_urls_detected, ) def build_async_context( context: AsyncBoltContext, - payload: Dict[str, Any], + body: Dict[str, Any], ) -> AsyncBoltContext: - enterprise_id = extract_enterprise_id(payload) + enterprise_id = extract_enterprise_id(body) if enterprise_id: context["enterprise_id"] = enterprise_id - team_id = extract_team_id(payload) + team_id = extract_team_id(body) if team_id: context["team_id"] = team_id - user_id = extract_user_id(payload) + user_id = extract_user_id(body) if user_id: context["user_id"] = user_id - channel_id = extract_channel_id(payload) + channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id - if "response_url" in payload: - context["response_url"] = payload["response_url"] + if "response_url" in body: + context["response_url"] = body["response_url"] + elif "response_urls" in body: + # In the case where response_url_enabled: true in a modal exists + response_urls = body["response_urls"] + if len(response_urls) >= 1: + if len(response_urls) > 1: + context.logger.debug(debug_multiple_response_urls_detected()) + response_url = response_urls[0].get("response_url") + context["response_url"] = response_url return context
      @@ -66,7 +75,7 @@

      Module slack_bolt.request.async_internals

      Functions

      -def build_async_context(context: AsyncBoltContext, payload: Dict[str, Any]) ‑> AsyncBoltContext +def build_async_context(context: AsyncBoltContext, body: Dict[str, Any]) ‑> AsyncBoltContext
      @@ -76,22 +85,30 @@

      Functions

      def build_async_context(
           context: AsyncBoltContext,
      -    payload: Dict[str, Any],
      +    body: Dict[str, Any],
       ) -> AsyncBoltContext:
      -    enterprise_id = extract_enterprise_id(payload)
      +    enterprise_id = extract_enterprise_id(body)
           if enterprise_id:
               context["enterprise_id"] = enterprise_id
      -    team_id = extract_team_id(payload)
      +    team_id = extract_team_id(body)
           if team_id:
               context["team_id"] = team_id
      -    user_id = extract_user_id(payload)
      +    user_id = extract_user_id(body)
           if user_id:
               context["user_id"] = user_id
      -    channel_id = extract_channel_id(payload)
      +    channel_id = extract_channel_id(body)
           if channel_id:
               context["channel_id"] = channel_id
      -    if "response_url" in payload:
      -        context["response_url"] = payload["response_url"]
      +    if "response_url" in body:
      +        context["response_url"] = body["response_url"]
      +    elif "response_urls" in body:
      +        # In the case where response_url_enabled: true in a modal exists
      +        response_urls = body["response_urls"]
      +        if len(response_urls) >= 1:
      +            if len(response_urls) > 1:
      +                context.logger.debug(debug_multiple_response_urls_detected())
      +            response_url = response_urls[0].get("response_url")
      +            context["response_url"] = response_url
           return context
      diff --git a/docs/api-docs/slack_bolt/request/internals.html b/docs/api-docs/slack_bolt/request/internals.html index ddde2c268..c65edf9d3 100644 --- a/docs/api-docs/slack_bolt/request/internals.html +++ b/docs/api-docs/slack_bolt/request/internals.html @@ -171,6 +171,14 @@

      Module slack_bolt.request.internals

      context["channel_id"] = channel_id if "response_url" in body: context["response_url"] = body["response_url"] + elif "response_urls" in body: + # In the case where response_url_enabled: true in a modal exists + response_urls = body["response_urls"] + if len(response_urls) >= 1: + if len(response_urls) > 1: + context.logger.debug(debug_multiple_response_urls_detected()) + response_url = response_urls[0].get("response_url") + context["response_url"] = response_url return context @@ -204,7 +212,15 @@

      Module slack_bolt.request.internals

      def error_message_unknown_request_body_type() -> str: - return "`body` must be either str or dict" + return "`body` must be either str or dict" + + +def debug_multiple_response_urls_detected() -> str: + return ( + "`response_urls` in the body has multiple URLs in it. " + "If you would like to use non-primary one, " + "please manually extract the one from body['response_urls']." + )
      @@ -239,6 +255,14 @@

      Functions

      context["channel_id"] = channel_id if "response_url" in body: context["response_url"] = body["response_url"] + elif "response_urls" in body: + # In the case where response_url_enabled: true in a modal exists + response_urls = body["response_urls"] + if len(response_urls) >= 1: + if len(response_urls) > 1: + context.logger.debug(debug_multiple_response_urls_detected()) + response_url = response_urls[0].get("response_url") + context["response_url"] = response_url return context
      @@ -269,6 +293,23 @@

      Functions

      return normalized_headers # type: ignore
    +
    +def debug_multiple_response_urls_detected() ‑> str +
    +
    +
    +
    + +Expand source code + +
    def debug_multiple_response_urls_detected() -> str:
    +    return (
    +        "`response_urls` in the body has multiple URLs in it. "
    +        "If you would like to use non-primary one, "
    +        "please manually extract the one from body['response_urls']."
    +    )
    +
    +
    def error_message_raw_body_required_in_http_mode() ‑> str
    @@ -516,6 +557,7 @@

    Index

    • build_context
    • build_normalized_headers
    • +
    • debug_multiple_response_urls_detected
    • error_message_raw_body_required_in_http_mode
    • error_message_unknown_request_body_type
    • extract_channel_id
    • diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index 25a0e96c5..ef1267173 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

      Module slack_bolt.version

      Expand source code
      """Check the latest version at https://pypi.org/project/slack-bolt/"""
      -__version__ = "1.4.4"
      +__version__ = "1.5.0"
      diff --git a/docs/api-docs/slack_bolt/workflows/step/async_step.html b/docs/api-docs/slack_bolt/workflows/step/async_step.html index e0a894ba5..ce9a05f5d 100644 --- a/docs/api-docs/slack_bolt/workflows/step/async_step.html +++ b/docs/api-docs/slack_bolt/workflows/step/async_step.html @@ -57,6 +57,7 @@

      Module slack_bolt.workflows.step.async_step

      """Steps from Apps Refer to https://api.slack.com/workflows/steps for details. """ + callback_id: Union[str, Pattern] _edit: Optional[AsyncListener] _save: Optional[AsyncListener] @@ -727,6 +728,7 @@

      Args

      """Steps from Apps Refer to https://api.slack.com/workflows/steps for details. """ + callback_id: Union[str, Pattern] _edit: Optional[AsyncListener] _save: Optional[AsyncListener] diff --git a/docs/api-docs/slack_bolt/workflows/step/async_step_middleware.html b/docs/api-docs/slack_bolt/workflows/step/async_step_middleware.html index 70729d8f4..4b6583b47 100644 --- a/docs/api-docs/slack_bolt/workflows/step/async_step_middleware.html +++ b/docs/api-docs/slack_bolt/workflows/step/async_step_middleware.html @@ -39,6 +39,7 @@

      Module slack_bolt.workflows.step.async_step_middlewareClasses

      class AsyncWorkflowStepMiddleware(AsyncMiddleware):  # type:ignore
           """Base middleware for workflow step specific ones"""
      +
           def __init__(self, step: AsyncWorkflowStep, listener_runner: AsyncioListenerRunner):
               self.step = step
               self.listener_runner = listener_runner
      diff --git a/docs/api-docs/slack_bolt/workflows/step/step.html b/docs/api-docs/slack_bolt/workflows/step/step.html
      index 79d76c480..f913db2b1 100644
      --- a/docs/api-docs/slack_bolt/workflows/step/step.html
      +++ b/docs/api-docs/slack_bolt/workflows/step/step.html
      @@ -52,6 +52,7 @@ 

      Module slack_bolt.workflows.step.step

      """Steps from Apps Refer to https://api.slack.com/workflows/steps for details. """ + callback_id: Union[str, Pattern] _edit: Optional[Listener] _save: Optional[Listener] @@ -707,6 +708,7 @@

      Args

      """Steps from Apps Refer to https://api.slack.com/workflows/steps for details. """ + callback_id: Union[str, Pattern] _edit: Optional[Listener] _save: Optional[Listener] diff --git a/docs/api-docs/slack_bolt/workflows/step/step_middleware.html b/docs/api-docs/slack_bolt/workflows/step/step_middleware.html index 4ef61250a..8be25951a 100644 --- a/docs/api-docs/slack_bolt/workflows/step/step_middleware.html +++ b/docs/api-docs/slack_bolt/workflows/step/step_middleware.html @@ -39,6 +39,7 @@

      Module slack_bolt.workflows.step.step_middleware< class WorkflowStepMiddleware(Middleware): # type:ignore """Base middleware for workflow step specific ones""" + def __init__(self, step: WorkflowStep, listener_runner: ThreadListenerRunner): self.step = step self.listener_runner = listener_runner @@ -105,6 +106,7 @@

      Classes

      class WorkflowStepMiddleware(Middleware):  # type:ignore
           """Base middleware for workflow step specific ones"""
      +
           def __init__(self, step: WorkflowStep, listener_runner: ThreadListenerRunner):
               self.step = step
               self.listener_runner = listener_runner
      diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/async_complete.html b/docs/api-docs/slack_bolt/workflows/step/utilities/async_complete.html
      index 007a22172..f75f2d8aa 100644
      --- a/docs/api-docs/slack_bolt/workflows/step/utilities/async_complete.html
      +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/async_complete.html
      @@ -52,6 +52,7 @@ 

      Module slack_bolt.workflows.step.utilities.async_complet This utility is a thin wrapper of workflows.stepCompleted API method. Refer to https://api.slack.com/methods/workflows.stepCompleted for details. """ + def __init__(self, *, client: AsyncWebClient, body: dict): self.client = client self.body = body @@ -126,6 +127,7 @@

      Classes

      This utility is a thin wrapper of workflows.stepCompleted API method. Refer to https://api.slack.com/methods/workflows.stepCompleted for details. """ + def __init__(self, *, client: AsyncWebClient, body: dict): self.client = client self.body = body diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/async_configure.html b/docs/api-docs/slack_bolt/workflows/step/utilities/async_configure.html index 63fd0d2b7..e6436faef 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/async_configure.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/async_configure.html @@ -62,6 +62,7 @@

      Module slack_bolt.workflows.step.utilities.async_configu Refer to https://api.slack.com/workflows/steps for details. """ + def __init__(self, *, callback_id: str, client: AsyncWebClient, body: dict): self.callback_id = callback_id self.client = client @@ -157,6 +158,7 @@

      Classes

      Refer to https://api.slack.com/workflows/steps for details. """ + def __init__(self, *, callback_id: str, client: AsyncWebClient, body: dict): self.callback_id = callback_id self.client = client diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/async_fail.html b/docs/api-docs/slack_bolt/workflows/step/utilities/async_fail.html index dccd6e8ef..56488dcd6 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/async_fail.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/async_fail.html @@ -49,6 +49,7 @@

      Module slack_bolt.workflows.step.utilities.async_failClasses

      This utility is a thin wrapper of workflows.stepFailed API method. Refer to https://api.slack.com/methods/workflows.stepFailed for details. """ + def __init__(self, *, client: AsyncWebClient, body: dict): self.client = client self.body = body diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/async_update.html b/docs/api-docs/slack_bolt/workflows/step/utilities/async_update.html index 1f5309a69..ca8d703f9 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/async_update.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/async_update.html @@ -68,6 +68,7 @@

      Module slack_bolt.workflows.step.utilities.async_update< This utility is a thin wrapper of workflows.stepFailed API method. Refer to https://api.slack.com/methods/workflows.updateStep for details. """ + def __init__(self, *, client: AsyncWebClient, body: dict): self.client = client self.body = body @@ -172,6 +173,7 @@

      Classes

      This utility is a thin wrapper of workflows.stepFailed API method. Refer to https://api.slack.com/methods/workflows.updateStep for details. """ + def __init__(self, *, client: AsyncWebClient, body: dict): self.client = client self.body = body diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/complete.html b/docs/api-docs/slack_bolt/workflows/step/utilities/complete.html index ff7734357..23023ee67 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/complete.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/complete.html @@ -52,6 +52,7 @@

      Module slack_bolt.workflows.step.utilities.completeClasses

      This utility is a thin wrapper of workflows.stepCompleted API method. Refer to https://api.slack.com/methods/workflows.stepCompleted for details. """ + def __init__(self, *, client: WebClient, body: dict): self.client = client self.body = body diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/configure.html b/docs/api-docs/slack_bolt/workflows/step/utilities/configure.html index a3c55bd6e..92601779b 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/configure.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/configure.html @@ -62,6 +62,7 @@

      Module slack_bolt.workflows.step.utilities.configureClasses

      Refer to https://api.slack.com/workflows/steps for details. """ + def __init__(self, *, callback_id: str, client: WebClient, body: dict): self.callback_id = callback_id self.client = client diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/fail.html b/docs/api-docs/slack_bolt/workflows/step/utilities/fail.html index e3fc5340b..a71efa254 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/fail.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/fail.html @@ -49,6 +49,7 @@

      Module slack_bolt.workflows.step.utilities.failClasses

      This utility is a thin wrapper of workflows.stepFailed API method. Refer to https://api.slack.com/methods/workflows.stepFailed for details. """ + def __init__(self, *, client: WebClient, body: dict): self.client = client self.body = body diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/update.html b/docs/api-docs/slack_bolt/workflows/step/utilities/update.html index 3ba9f9af1..c14e8cc22 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/update.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/update.html @@ -68,6 +68,7 @@

      Module slack_bolt.workflows.step.utilities.update This utility is a thin wrapper of workflows.stepFailed API method. Refer to https://api.slack.com/methods/workflows.updateStep for details. """ + def __init__(self, *, client: WebClient, body: dict): self.client = client self.body = body @@ -172,6 +173,7 @@

      Classes

      This utility is a thin wrapper of workflows.stepFailed API method. Refer to https://api.slack.com/methods/workflows.updateStep for details. """ + def __init__(self, *, client: WebClient, body: dict): self.client = client self.body = body From ee7befd1173cab368594fd8e919e264ab7d77de2 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 28 Apr 2021 10:35:58 +0900 Subject: [PATCH 312/865] Set pytype version to 2021.4.15 --- .github/workflows/ci-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index dae01f300..7dde6f5aa 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -74,7 +74,7 @@ jobs: if [ ${python_version:7:3} == "3.8" ]; then pip install -e ".[async]" pip install -e ".[adapter]" - pip install "pytype" && pytype slack_bolt/ + pip install "pytype==2021.4.15" && pytype slack_bolt/ fi - name: Run all tests for codecov (3.9 only) run: | From 9f7f77b7cd488b9402a77c654840def8f823b4ad Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 28 Apr 2021 12:39:45 +0900 Subject: [PATCH 313/865] Add Japanese translation of Steps from Apps documents --- docs/_config.yml | 2 +- docs/_steps/adding_editing_workflow_step.md | 2 +- .../_steps/ja_adding_editing_workflow_step.md | 55 ++++++++++++++++++ docs/_steps/ja_creating_workflow_step.md | 49 ++++++++++++++++ docs/_steps/ja_executing_workflow_steps.md | 39 +++++++++++++ docs/_steps/ja_saving_workflow_step.md | 56 +++++++++++++++++++ docs/_steps/ja_workflow_steps_overview.md | 21 +++++++ 7 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 docs/_steps/ja_adding_editing_workflow_step.md create mode 100644 docs/_steps/ja_creating_workflow_step.md create mode 100644 docs/_steps/ja_executing_workflow_steps.md create mode 100644 docs/_steps/ja_saving_workflow_step.md create mode 100644 docs/_steps/ja_workflow_steps_overview.md diff --git a/docs/_config.yml b/docs/_config.yml index c6437f1fa..083fa7c1d 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -37,7 +37,7 @@ t: contribute: Contributing ja-jp: basic: 基本的な概念 -# steps: ワークフローステップ + steps: ワークフローステップ # advanced: 応用コンセプト start: Bolt 入門ガイド # contribute: 貢献 diff --git a/docs/_steps/adding_editing_workflow_step.md b/docs/_steps/adding_editing_workflow_step.md index 55e040d62..bf7b5d960 100644 --- a/docs/_steps/adding_editing_workflow_step.md +++ b/docs/_steps/adding_editing_workflow_step.md @@ -9,7 +9,7 @@ order: 3 When a builder adds (or later edits) your step in their workflow, your app will receive a [`workflow_step_edit` event](https://api.slack.com/reference/workflows/workflow_step_edit). The `edit` callback in your `WorkflowStep` configuration will be run when this event is received. -Whether a builder is adding or editing a step, you need to send them a [workflow step configuration modal](https://api.slack.com/reference/workflows/configuration-view). This modal is where step-specific settings are chosen, and it has more restrictions than typical modals—most notably, it cannot include `title​`, `submit​`, or `close`​ properties. By default, the configuration modal's `callback_id` will be the same as the workflow step. +Whether a builder is adding or editing a step, you need to send them a [workflow step configuration modal](https://api.slack.com/reference/workflows/configuration-view). This modal is where step-specific settings are chosen, and it has more restrictions than typical modals—most notably, it cannot include `title`, `submit`, or `close` properties. By default, the configuration modal's `callback_id` will be the same as the workflow step. Within the `edit` callback, the `configure()` utility can be used to easily open your step's configuration modal by passing in the view's blocks with the corresponding `blocks` argument. To disable saving the configuration before certain conditions are met, you can also pass in `submit_disabled` with a value of `True`. diff --git a/docs/_steps/ja_adding_editing_workflow_step.md b/docs/_steps/ja_adding_editing_workflow_step.md new file mode 100644 index 000000000..e231825e1 --- /dev/null +++ b/docs/_steps/ja_adding_editing_workflow_step.md @@ -0,0 +1,55 @@ +--- +title: ステップの追加・編集 +lang: ja-jp +slug: adding-editing-steps +order: 3 +--- + +
      + +作成したワークフローステップがワークフローに追加またはその設定を変更されるタイミングで、[`workflow_step_edit` イベントがアプリに送信されます](https://api.slack.com/reference/workflows/workflow_step_edit)。このイベントがアプリに届くと、`WorkflowStep` で設定した `edit` コールバックが実行されます。 + +ステップの追加と編集のどちらが行われるときも、[ワークフローステップの設定モーダル](https://api.slack.com/reference/workflows/configuration-view)をビルダーに送信する必要があります。このモーダルは、そのステップ独自の設定を選択するための場所です。通常のモーダルより制限が強く、例えば `title`、`submit`、`close` のプロパティを含めることができません。設定モーダルの `callback_id` は、デフォルトではワークフローステップと同じものになります。 + +`edit` コールバック内で `configure()` ユーティリティを使用すると、対応する `blocks` 引数にビューのblocks 部分だけを渡して、ステップの設定モーダルを簡単に表示させることができます。必要な入力内容が揃うまで設定の保存を無効にするには、`True` の値をセットした `submit_disabled` を渡します。 + +設定モーダルの開き方について詳しくは、[ドキュメントを参照してください](https://api.slack.com/workflows/steps#handle_config_view)。 + +
      + +```python +def edit(ack, step, configure): + ack() + + blocks = [ + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "name", + "placeholder": {"type": "plain_text", "text":"Add a task name"}, + }, + "label": {"type": "plain_text", "text":"Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "description", + "placeholder": {"type": "plain_text", "text":"Add a task description"}, + }, + "label": {"type": "plain_text", "text":"Task description"}, + }, + ] + configure(blocks=blocks) + +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) +app.step(ws) +``` diff --git a/docs/_steps/ja_creating_workflow_step.md b/docs/_steps/ja_creating_workflow_step.md new file mode 100644 index 000000000..d9438e0e4 --- /dev/null +++ b/docs/_steps/ja_creating_workflow_step.md @@ -0,0 +1,49 @@ +--- +title: ステップの定義 +lang: ja-jp +slug: creating-steps +order: 2 +--- + +
      + +ワークフローステップの作成には、Bolt が提供する `WorkflowStep` クラスを利用します。 + +ステップの `callback_id` と設定オブジェクトを指定して、`WorkflowStep` の新しいインスタンスを作成します。 + +設定オブジェクトは、`edit`、`save`、`execute` という 3 つのキーを持ちます。それぞれのキーは、単一のコールバック、またはコールバックのリストである必要があります。すべてのコールバックは、ワークフローステップのイベントに関する情報を保持する `step` オブジェクトにアクセスできます。 + +`WorkflowStep` のインスタンスを作成したら、それを`app.step()` メソッドに渡します。これによって、アプリがワークフローステップのイベントをリッスンし、設定オブジェクトで指定されたコールバックを使ってそれに応答できるようになります。 + +
      + +```python +import os +from slack_bolt import App +from slack_bolt.workflows.step import WorkflowStep + +# いつも通りBolt アプリを起動する +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +def edit(ack, step, configure): + pass + +def save(ack, view, update): + pass + +def execute(step, complete, fail): + pass + +# WorkflowStep の新しいインスタンスを作成する +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) +# ワークフローステップを渡してリスナーを設定する +app.step(ws) +``` diff --git a/docs/_steps/ja_executing_workflow_steps.md b/docs/_steps/ja_executing_workflow_steps.md new file mode 100644 index 000000000..c9540175d --- /dev/null +++ b/docs/_steps/ja_executing_workflow_steps.md @@ -0,0 +1,39 @@ +--- +title: ステップの実行 +lang: ja-jp +slug: executing-steps +order: 5 +--- + +
      + +エンドユーザーがワークフローステップを実行すると、アプリに [`workflow_step_execute` イベントが送信されます](https://api.slack.com/events/workflow_step_execute)。このイベントがアプリに届くと、`WorkflowStep` で設定した `execute` コールバックが実行されます。 + +`save` コールバックで取り出した `inputs` を使って、サードパーティの API を呼び出す、情報をデータベースに保存する、ユーザーのホームタブを更新するといった処理を実行することができます。また、ワークフローの後続のステップで利用する出力値を `outputs` オブジェクトに設定します。 + +`execute` コールバック内では、`complete()` を呼び出してステップの実行が成功したことを示すか、`fail()` を呼び出してステップの実行が失敗したことを示す必要があります。 + +
      + +```python +def execute(step, complete, fail): + inputs = step["inputs"] + # すべての処理が成功した場合 + outputs = { + "task_name": inputs["task_name"]["value"], + "task_description": inputs["task_description"]["value"], + } + complete(outputs=outputs) + + # 失敗した処理がある場合 + error = {"message":"Just testing step failure!"} + fail(error=error) + +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) +app.step(ws) +``` \ No newline at end of file diff --git a/docs/_steps/ja_saving_workflow_step.md b/docs/_steps/ja_saving_workflow_step.md new file mode 100644 index 000000000..80689bb41 --- /dev/null +++ b/docs/_steps/ja_saving_workflow_step.md @@ -0,0 +1,56 @@ +--- +title: ステップの設定の保存 +lang: ja-jp +slug: saving-steps +order: 4 +--- + +
      + +設定モーダルを開いた後、アプリは `view_submission` イベントをリッスンします。このイベントがアプリに届くと、`WorkflowStep` で設定した `save` コールバックが実行されます。 + +`save` コールバック内では、`update()` メソッドを使って、ワークフローに追加されたステップの設定を保存することができます。このメソッドには次の引数を指定します。 + +- `inputs` : ユーザーがワークフローステップを実行したときにアプリが受け取る予定のデータを表す辞書型の値です。 +- `outputs` : ワークフローステップの完了時にアプリが出力するデータが設定されたオブジェクトのリストです。この outputs は、ワークフローの後続のステップで利用することができます。 +- `step_name` : ステップのデフォルトの名前をオーバーライドします。 +- `step_image_url` : ステップのデフォルトの画像をオーバーライドします。 + +これらのパラメータの構成方法について詳しくは、[ドキュメントを参照してください](https://api.slack.com/reference/workflows/workflow_step)。 + +
      + +```python +def save(ack, view, update): + ack() + + values = view["state"]["values"] + task_name = values["task_name_input"]["name"] + task_description = values["task_description_input"]["description"] + + inputs = { + "task_name": {"value": task_name["value"]}, + "task_description": {"value": task_description["value"]} + } + outputs = [ + { + "type": "text", + "name": "task_name", + "label":"Task name", + }, + { + "type": "text", + "name": "task_description", + "label":"Task description", + } + ] + update(inputs=inputs, outputs=outputs) + +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) +app.step(ws) +``` \ No newline at end of file diff --git a/docs/_steps/ja_workflow_steps_overview.md b/docs/_steps/ja_workflow_steps_overview.md new file mode 100644 index 000000000..6905d82fa --- /dev/null +++ b/docs/_steps/ja_workflow_steps_overview.md @@ -0,0 +1,21 @@ +--- +title: ワークフローステップの概要 +lang: ja-jp +slug: steps-overview +order: 1 +--- + +
      +(アプリによる)ワークフローステップでは、処理をアプリ側で行うカスタムのワークフローステップを提供することができます。ユーザーは[ワークフロービルダー](https://api.slack.com/workflows)を使ってこれらのステップをワークフローに追加できます。 + +ワークフローステップは、次の 3 つのユーザーイベントで構成されます。 + +- ワークフローステップをワークフローに追加・変更する +- ワークフロー内のステップの設定内容を更新する +- エンドユーザーがそのステップを実行する + +ワークフローステップを機能させるためには、これら 3 つのイベントすべてに対応する必要があります。 + +アプリを使ったワークフローステップについて詳しくは、[API ドキュメント](https://api.slack.com/workflows/steps)を参照してください。 + +
      From 39194aee4dd0905a5f19de124f39d627a783700c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 28 Apr 2021 12:59:34 +0900 Subject: [PATCH 314/865] Fix an error in docs --- docs/_advanced/adapters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_advanced/adapters.md b/docs/_advanced/adapters.md index 8ff882898..b6d356bd8 100644 --- a/docs/_advanced/adapters.md +++ b/docs/_advanced/adapters.md @@ -8,7 +8,7 @@ order: 0
      Adapters are responsible for handling and parsing incoming events from Slack to conform to `BoltRequest`, then dispatching those events to your Bolt app. -By default, Bolt will use the built-in `HTTPSever` adapter. While this is okay for local development, it is not recommended for production. Bolt for Python includes a collection of built-in adapters that can be imported and used with your app. The built-in adapters support a variety of popular Python frameworks including Flask, Django, and Starlette among others. Adapters support the use of any production-ready web server of your choice. +By default, Bolt will use the built-in `HTTPServer` adapter. While this is okay for local development, it is not recommended for production. Bolt for Python includes a collection of built-in adapters that can be imported and used with your app. The built-in adapters support a variety of popular Python frameworks including Flask, Django, and Starlette among others. Adapters support the use of any production-ready web server of your choice. To use an adapter, you'll create an app with the framework of your choosing and import its corresponding adapter. Then you'll initialize the adapter instance and call its function that handles and parses incoming events. From ad622e4d925b57fbd8595ecd7b89cf42de7613e1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 29 Apr 2021 09:01:55 +0900 Subject: [PATCH 315/865] Fix #312 Typehint errors with pytype 2021.4.26 (#313) --- .github/workflows/ci-build.yml | 2 +- slack_bolt/oauth/async_callback_options.py | 6 ++++-- slack_bolt/oauth/async_oauth_settings.py | 10 ++++++---- slack_bolt/oauth/oauth_settings.py | 10 ++++++---- slack_bolt/request/async_request.py | 20 ++++++++++++++++---- slack_bolt/request/request.py | 19 +++++++++++++++---- 6 files changed, 48 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 7dde6f5aa..dae01f300 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -74,7 +74,7 @@ jobs: if [ ${python_version:7:3} == "3.8" ]; then pip install -e ".[async]" pip install -e ".[adapter]" - pip install "pytype==2021.4.15" && pytype slack_bolt/ + pip install "pytype" && pytype slack_bolt/ fi - name: Run all tests for codecov (3.9 only) run: | diff --git a/slack_bolt/oauth/async_callback_options.py b/slack_bolt/oauth/async_callback_options.py index 6adb28cca..b24417239 100644 --- a/slack_bolt/oauth/async_callback_options.py +++ b/slack_bolt/oauth/async_callback_options.py @@ -91,8 +91,10 @@ def __init__( state_utils=state_utils, redirect_uri_page_renderer=redirect_uri_page_renderer, ) - self.success = self._success_handler - self.failure = self._failure_handler + # Note that pytype 2021.4.26 misunderstands these assignments. + # Thus, we put "type: ignore" for the following two lines + self.success = self._success_handler # type: ignore + self.failure = self._failure_handler # type: ignore # -------------------------- # Internal methods diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index 318842689..9580957e3 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -103,12 +103,14 @@ def __init__( logger: The logger that will be used internally """ # OAuth flow parameters/credentials - self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID") - self.client_secret = client_secret or os.environ.get( - "SLACK_CLIENT_SECRET", None + client_id: Optional[str] = client_id or os.environ.get("SLACK_CLIENT_ID") + client_secret: Optional[str] = client_secret or os.environ.get( + "SLACK_CLIENT_SECRET" ) - if self.client_id is None or self.client_secret is None: + if client_id is None or client_secret is None: raise BoltError("Both client_id and client_secret are required") + self.client_id = client_id + self.client_secret = client_secret self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") if isinstance(self.scopes, str): diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index 669aacac5..9ab100fd1 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -97,12 +97,14 @@ def __init__( state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) logger: The logger that will be used internally """ - self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID") - self.client_secret = client_secret or os.environ.get( - "SLACK_CLIENT_SECRET", None + client_id: Optional[str] = client_id or os.environ.get("SLACK_CLIENT_ID") + client_secret: Optional[str] = client_secret or os.environ.get( + "SLACK_CLIENT_SECRET" ) - if self.client_id is None or self.client_secret is None: + if client_id is None or client_secret is None: raise BoltError("Both client_id and client_secret are required") + self.client_id = client_id + self.client_secret = client_secret self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") if isinstance(self.scopes, str): diff --git a/slack_bolt/request/async_request.py b/slack_bolt/request/async_request.py index 77ba23f6c..97ceab159 100644 --- a/slack_bolt/request/async_request.py +++ b/slack_bolt/request/async_request.py @@ -42,9 +42,21 @@ def __init__( context: The context in this request. mode: The mode used for this request. (either "http" or "socket_mode") """ - if mode == "http" and not isinstance(body, str): - raise BoltError(error_message_raw_body_required_in_http_mode()) - self.raw_body = body if mode == "http" else "" + + if mode == "http": + # HTTP Mode + if not isinstance(body, str): + raise BoltError(error_message_raw_body_required_in_http_mode()) + self.raw_body = body if body is not None else "" + else: + # Socket Mode + if body is not None and isinstance(body, str): + self.raw_body = body + else: + # We don't convert the dict value to str + # as doing so does not guarantee to keep the original structure/format. + self.raw_body = "" + self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) @@ -57,7 +69,7 @@ def __init__( self.context = build_async_context( AsyncBoltContext(context if context else {}), self.body ) - self.lazy_only = self.headers.get("x-slack-bolt-lazy-only", [False])[0] + self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0]) self.lazy_function_name = self.headers.get( "x-slack-bolt-lazy-function-name", [None] )[0] diff --git a/slack_bolt/request/request.py b/slack_bolt/request/request.py index 06b27142d..5d48d7a35 100644 --- a/slack_bolt/request/request.py +++ b/slack_bolt/request/request.py @@ -42,9 +42,20 @@ def __init__( context: The context in this request. mode: The mode used for this request. (either "http" or "socket_mode") """ - if mode == "http" and not isinstance(body, str): - raise BoltError(error_message_raw_body_required_in_http_mode()) - self.raw_body = body if mode == "http" else "" + if mode == "http": + # HTTP Mode + if not isinstance(body, str): + raise BoltError(error_message_raw_body_required_in_http_mode()) + self.raw_body = body if body is not None else "" + else: + # Socket Mode + if body is not None and isinstance(body, str): + self.raw_body = body + else: + # We don't convert the dict value to str + # as doing so does not guarantee to keep the original structure/format. + self.raw_body = "" + self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) @@ -56,7 +67,7 @@ def __init__( raise BoltError(error_message_unknown_request_body_type()) self.context = build_context(BoltContext(context if context else {}), self.body) - self.lazy_only = self.headers.get("x-slack-bolt-lazy-only", [False])[0] + self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0]) self.lazy_function_name = self.headers.get( "x-slack-bolt-lazy-function-name", [None] )[0] From 76158a8eb0d74ece8f155b1d2040da4883a9df5a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 30 Apr 2021 06:50:36 +0900 Subject: [PATCH 316/865] Fix #307 Add options to disable the built-in middleware (#310) --- slack_bolt/app/app.py | 53 ++++++-- slack_bolt/app/async_app.py | 58 ++++++-- tests/scenario_tests/test_events.py | 41 ------ .../scenario_tests/test_events_ignore_self.py | 85 ++++++++++++ .../test_events_request_verification.py | 110 ++++++++++++++++ .../test_events_url_verification.py | 84 ++++++++++++ tests/scenario_tests/test_ssl_check.py | 21 +++ tests/scenario_tests_async/test_events.py | 41 ------ .../test_events_ignore_self.py | 88 +++++++++++++ .../test_events_request_verification.py | 124 ++++++++++++++++++ .../test_events_url_verification.py | 88 +++++++++++++ 11 files changed, 692 insertions(+), 101 deletions(-) create mode 100644 tests/scenario_tests/test_events_ignore_self.py create mode 100644 tests/scenario_tests/test_events_request_verification.py create mode 100644 tests/scenario_tests/test_events_url_verification.py create mode 100644 tests/scenario_tests_async/test_events_ignore_self.py create mode 100644 tests/scenario_tests_async/test_events_request_verification.py create mode 100644 tests/scenario_tests_async/test_events_url_verification.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 86f59b296..2bf8213cb 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -99,6 +99,11 @@ def __init__( installation_store: Optional[InstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, + # for customizing the built-in middleware + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, # for the OAuth flow oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, @@ -146,6 +151,21 @@ def message_hello(message, say): by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) + request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `RequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests. + Make sure if it's safe enough when you turn a built-in middleware off. + We strongly recommend using RequestVerification for better security. + If you have a proxy that verifies request signature in front of the Bolt app, + it's totally fine to disable RequestVerification to avoid duplication of work. + Don't turn it off just for easiness of development. + ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True). + `IgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events + generated by this app's bot user (this is useful for avoiding code error causing an infinite loop). + url_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `UrlVerification` is a built-in middleware that handles url_verification requests + that verify the endpoint for Events API in HTTP Mode requests. + ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True). + `SslCheck` is a built-in middleware that handles ssl_check requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. @@ -292,22 +312,37 @@ def message_hello(message, say): self._init_middleware_list_done = False self._init_middleware_list( - token_verification_enabled=token_verification_enabled + token_verification_enabled=token_verification_enabled, + request_verification_enabled=request_verification_enabled, + ignoring_self_events_enabled=ignoring_self_events_enabled, + ssl_check_enabled=ssl_check_enabled, + url_verification_enabled=url_verification_enabled, ) - def _init_middleware_list(self, token_verification_enabled: bool): + def _init_middleware_list( + self, + token_verification_enabled: bool = True, + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, + ): if self._init_middleware_list_done: return - self._middleware_list.append( - SslCheck(verification_token=self._verification_token) - ) - self._middleware_list.append(RequestVerification(self._signing_secret)) + if ssl_check_enabled is True: + self._middleware_list.append( + SslCheck(verification_token=self._verification_token) + ) + if request_verification_enabled is True: + self._middleware_list.append(RequestVerification(self._signing_secret)) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._oauth_flow is None: if self._token is not None: try: auth_test_result = None if token_verification_enabled: + # This API call is for eagerly validating the token auth_test_result = self._client.auth_test(token=self._token) self._middleware_list.append( SingleTeamAuthorization(auth_test_result=auth_test_result) @@ -324,8 +359,10 @@ def _init_middleware_list(self, token_verification_enabled: bool): self._middleware_list.append( MultiTeamsAuthorization(authorize=self._authorize) ) - self._middleware_list.append(IgnoringSelfEvents()) - self._middleware_list.append(UrlVerification()) + if ignoring_self_events_enabled is True: + self._middleware_list.append(IgnoringSelfEvents()) + if url_verification_enabled is True: + self._middleware_list.append(UrlVerification()) self._init_middleware_list_done = True # ------------------------- diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index ce1b968bf..131650262 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -105,10 +105,15 @@ def __init__( token: Optional[str] = None, client: Optional[AsyncWebClient] = None, # for multi-workspace apps + authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[AsyncInstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, - authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, + # for customizing the built-in middleware + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, # for the OAuth flow oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, @@ -155,6 +160,21 @@ async def message_hello(message, say): # async function by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `AsyncInstallationStore#async_find_bot()` if True (Default: False) + request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncRequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests. + Make sure if it's safe enough when you turn a built-in middleware off. + We strongly recommend using RequestVerification for better security. + If you have a proxy that verifies request signature in front of the Bolt app, + it's totally fine to disable RequestVerification to avoid duplication of work. + Don't turn it off just for easiness of development. + ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncIgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events + generated by this app's bot user (this is useful for avoiding code error causing an infinite loop). + url_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncUrlVerification` is a built-in middleware that handles url_verification requests + that verify the endpoint for Events API in HTTP Mode requests. + ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True). + `AsyncSslCheck` is a built-in middleware that handles ssl_check requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.AsyncOAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. @@ -316,19 +336,33 @@ async def message_hello(message, say): # async function ) self._init_middleware_list_done = False - self._init_async_middleware_list() + self._init_async_middleware_list( + request_verification_enabled=request_verification_enabled, + ignoring_self_events_enabled=ignoring_self_events_enabled, + ssl_check_enabled=ssl_check_enabled, + url_verification_enabled=url_verification_enabled, + ) self._server: Optional[AsyncSlackAppServer] = None - def _init_async_middleware_list(self): + def _init_async_middleware_list( + self, + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, + ): if self._init_middleware_list_done: return - self._async_middleware_list.append( - AsyncSslCheck(verification_token=self._verification_token) - ) - self._async_middleware_list.append( - AsyncRequestVerification(self._signing_secret) - ) + if ssl_check_enabled is True: + self._async_middleware_list.append( + AsyncSslCheck(verification_token=self._verification_token) + ) + if request_verification_enabled is True: + self._async_middleware_list.append( + AsyncRequestVerification(self._signing_secret) + ) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: self._async_middleware_list.append(AsyncSingleTeamAuthorization()) @@ -343,8 +377,10 @@ def _init_async_middleware_list(self): AsyncMultiTeamsAuthorization(authorize=self._async_authorize) ) - self._async_middleware_list.append(AsyncIgnoringSelfEvents()) - self._async_middleware_list.append(AsyncUrlVerification()) + if ignoring_self_events_enabled is True: + self._async_middleware_list.append(AsyncIgnoringSelfEvents()) + if url_verification_enabled is True: + self._async_middleware_list.append(AsyncUrlVerification()) self._init_middleware_list_done = True # ------------------------- diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index e5a1e9f49..49c1f409c 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -169,47 +169,6 @@ def handle_app_mention(): response = app.dispatch(request) assert response.status == 200 - def test_self_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret) - - event_body = { - "token": "verification_token", - "team_id": "T111", - "enterprise_id": "E111", - "api_app_id": "A111", - "event": { - "type": "reaction_added", - "user": "W23456789", # bot_user_id - "item": { - "type": "message", - "channel": "C111", - "ts": "1599529504.000400", - }, - "reaction": "heart_eyes", - "item_user": "W111", - "event_ts": "1599616881.000800", - }, - "type": "event_callback", - "event_id": "Ev111", - "event_time": 1599616881, - "authed_users": ["W111"], - } - - @app.event("reaction_added") - def handle_app_mention(say): - say("What's up?") - - timestamp, body = str(int(time())), json.dumps(event_body) - request: BoltRequest = BoltRequest( - body=body, headers=self.build_headers(timestamp, body) - ) - response = app.dispatch(request) - assert response.status == 200 - assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - # The listener should not be executed - assert self.mock_received_requests.get("/chat.postMessage") is None - def test_self_member_join_left_events(self): app = App(client=self.web_client, signing_secret=self.signing_secret) diff --git a/tests/scenario_tests/test_events_ignore_self.py b/tests/scenario_tests/test_events_ignore_self.py new file mode 100644 index 000000000..7fb3f2e7d --- /dev/null +++ b/tests/scenario_tests/test_events_ignore_self.py @@ -0,0 +1,85 @@ +from time import sleep + +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestEventsIgnoreSelf: + 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_self_events(self): + app = App(client=self.web_client) + + @app.event("reaction_added") + def handle_app_mention(say): + say("What's up?") + + request: BoltRequest = BoltRequest(body=event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(1) # wait a bit after auto ack() + # The listener should not be executed + assert self.mock_received_requests.get("/chat.postMessage") is None + + def test_self_events_disabled(self): + app = App( + client=self.web_client, + ignoring_self_events_enabled=False, + ) + + @app.event("reaction_added") + def handle_app_mention(say): + say("What's up?") + + request: BoltRequest = BoltRequest(body=event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(1) # wait a bit after auto ack() + # The listener should be executed as the ignoring logic is disabled + assert self.mock_received_requests.get("/chat.postMessage") == 1 + + +event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} diff --git a/tests/scenario_tests/test_events_request_verification.py b/tests/scenario_tests/test_events_request_verification.py new file mode 100644 index 000000000..c8a292a3b --- /dev/null +++ b/tests/scenario_tests/test_events_request_verification.py @@ -0,0 +1,110 @@ +import json +from time import sleep, time + +from slack_sdk.web import WebClient +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import App, BoltRequest +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestEventsRequestVerification: + valid_token = "xoxb-valid" + 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_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_default(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + @app.event("reaction_added") + def handle_app_mention(say): + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests.get("/chat.postMessage") == 1 + + def test_disabled(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + request_verification_enabled=False, + ) + + @app.event("reaction_added") + def handle_app_mention(say): + say("What's up?") + + # request including invalid headers + expired = int(time()) - 3600 + timestamp, body = str(expired), json.dumps(event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests.get("/chat.postMessage") == 1 + + +event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} diff --git a/tests/scenario_tests/test_events_url_verification.py b/tests/scenario_tests/test_events_url_verification.py new file mode 100644 index 000000000..4f92bca13 --- /dev/null +++ b/tests/scenario_tests/test_events_url_verification.py @@ -0,0 +1,84 @@ +import json +from time import time + +from slack_sdk.web import WebClient +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import App, BoltRequest +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestEventsUrlVerification: + valid_token = "xoxb-valid" + 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_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_default(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + timestamp, body = str(int(time())), json.dumps(event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 200 + assert ( + response.body + == """{"challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P"}""" + ) + assert_auth_test_count(self, 0) + + def test_disabled(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + url_verification_enabled=False, + ) + + timestamp, body = str(int(time())), json.dumps(event_body) + request: BoltRequest = BoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = app.dispatch(request) + assert response.status == 404 + assert response.body == """{"error": "unhandled request"}""" + assert_auth_test_count(self, 0) + + +event_body = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", +} diff --git a/tests/scenario_tests/test_ssl_check.py b/tests/scenario_tests/test_ssl_check.py index 3470321de..937f9d7e4 100644 --- a/tests/scenario_tests/test_ssl_check.py +++ b/tests/scenario_tests/test_ssl_check.py @@ -55,3 +55,24 @@ def test_ssl_check(self): response = app.dispatch(request) assert response.status == 200 assert response.body == "" + + def test_ssl_check_disabled(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ssl_check_enabled=False, + ) + + timestamp, body = str(int(time())), "token=random&ssl_check=1" + request: BoltRequest = BoltRequest( + body=body, + query={}, + headers={ + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + }, + ) + response = app.dispatch(request) + assert response.status == 404 + assert response.body == """{"error": "unhandled request"}""" diff --git a/tests/scenario_tests_async/test_events.py b/tests/scenario_tests_async/test_events.py index 7e6f25a9c..5e5af42e2 100644 --- a/tests/scenario_tests_async/test_events.py +++ b/tests/scenario_tests_async/test_events.py @@ -158,47 +158,6 @@ async def test_stable_auto_ack(self): response = await app.async_dispatch(request) assert response.status == 200 - @pytest.mark.asyncio - async def test_self_events(self): - app = AsyncApp( - client=self.web_client, - signing_secret=self.signing_secret, - ) - app.event("reaction_added")(whats_up) - - self_event = { - "token": "verification_token", - "team_id": "T111", - "enterprise_id": "E111", - "api_app_id": "A111", - "event": { - "type": "reaction_added", - "user": "W23456789", # bot_user_id - "item": { - "type": "message", - "channel": "C111", - "ts": "1599529504.000400", - }, - "reaction": "heart_eyes", - "item_user": "W111", - "event_ts": "1599616881.000800", - }, - "type": "event_callback", - "event_id": "Ev111", - "event_time": 1599616881, - "authed_users": ["W111"], - } - timestamp, body = str(int(time())), json.dumps(self_event) - request = AsyncBoltRequest( - body=body, headers=self.build_headers(timestamp, body) - ) - response = await app.async_dispatch(request) - assert response.status == 200 - await assert_auth_test_count_async(self, 1) - await asyncio.sleep(1) # wait a bit after auto ack() - # The listener should not be executed - assert self.mock_received_requests.get("/chat.postMessage") is None - @pytest.mark.asyncio async def test_self_joined_left_events(self): app = AsyncApp( diff --git a/tests/scenario_tests_async/test_events_ignore_self.py b/tests/scenario_tests_async/test_events_ignore_self.py new file mode 100644 index 000000000..5749b980b --- /dev/null +++ b/tests/scenario_tests_async/test_events_ignore_self.py @@ -0,0 +1,88 @@ +import asyncio + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +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, + assert_auth_test_count_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEventsIgnoreSelf: + 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_self_events(self): + app = AsyncApp(client=self.web_client) + app.event("reaction_added")(whats_up) + request = AsyncBoltRequest(body=self_event, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(1) # wait a bit after auto ack() + # The listener should not be executed + assert self.mock_received_requests.get("/chat.postMessage") is None + + @pytest.mark.asyncio + async def test_self_events_disabled(self): + app = AsyncApp(client=self.web_client, ignoring_self_events_enabled=False) + app.event("reaction_added")(whats_up) + request = AsyncBoltRequest(body=self_event, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 0) + await asyncio.sleep(1) # wait a bit after auto ack() + # The listener should be executed + assert self.mock_received_requests.get("/chat.postMessage") == 1 + + +self_event = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} + + +async def whats_up(body, say, payload, event): + assert body["event"] == payload + assert payload == event + await say("What's up?") diff --git a/tests/scenario_tests_async/test_events_request_verification.py b/tests/scenario_tests_async/test_events_request_verification.py new file mode 100644 index 000000000..3c59c2a00 --- /dev/null +++ b/tests/scenario_tests_async/test_events_request_verification.py @@ -0,0 +1,124 @@ +import asyncio +import json +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +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, + assert_auth_test_count_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEventsRequestVerification: + 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) + + @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) + + 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + @pytest.mark.asyncio + async def test_default(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.event("app_mention")(whats_up) + + timestamp, body = str(int(time())), json.dumps(app_mention_body) + request = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_disabled(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + request_verification_enabled=False, + ) + app.event("app_mention")(whats_up) + + # request including invalid headers + expired = int(time()) - 3600 + timestamp, body = str(expired), json.dumps(app_mention_body) + request = AsyncBoltRequest( + body=body, headers=self.build_headers(timestamp, body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + +app_mention_body = { + "token": "verification_token", + "team_id": "T_INSTALLED", + "enterprise_id": "E_SOURCE", + "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": "T_INSTALLED", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], +} + + +async def whats_up(body, say, payload, event): + assert body["event"] == payload + assert payload == event + await say("What's up?") diff --git a/tests/scenario_tests_async/test_events_url_verification.py b/tests/scenario_tests_async/test_events_url_verification.py new file mode 100644 index 000000000..e47efcb65 --- /dev/null +++ b/tests/scenario_tests_async/test_events_url_verification.py @@ -0,0 +1,88 @@ +import asyncio +import json +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +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, + assert_auth_test_count_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEventsUrlVerification: + 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) + + @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) + + 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(event_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_default(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert ( + response.body + == """{"challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P"}""" + ) + await assert_auth_test_count_async(self, 0) + + @pytest.mark.asyncio + async def test_disabled(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + url_verification_enabled=False, + ) + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 404 + assert response.body == """{"error": "unhandled request"}""" + await assert_auth_test_count_async(self, 0) + + +event_body = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", +} From c649e2b6d432ce877e3d344ad8505d520194c8c4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 30 Apr 2021 06:50:59 +0900 Subject: [PATCH 317/865] Fix #309 Fallback to no-emoji boot message when failing to load for some reason (#311) --- slack_bolt/util/utils.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/slack_bolt/util/utils.py b/slack_bolt/util/utils.py index ef08e3337..e5e1bb46b 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -57,10 +57,19 @@ def get_boot_message(development_server: bool = False) -> str: else: return "Bolt app is running!" - if development_server: - return "⚡️ Bolt app is running! (development server)" - else: - return "⚡️ Bolt app is running!" + try: + if development_server: + return "⚡️ Bolt app is running! (development server)" + else: + return "⚡️ Bolt app is running!" + except ValueError: + # ValueError is a runtime exception for a given value + # It's a super class of UnicodeEncodeError, which may be raised in the scenario + # see also: https://github.com/slackapi/bolt-python/issues/170 + if development_server: + return "Bolt app is running! (development server)" + else: + return "Bolt app is running!" def get_name_for_callable(func: Callable) -> str: From e1b7a6f23b2e9c9bf8ad937f43053e52be075293 Mon Sep 17 00:00:00 2001 From: Jeremy Lujan <77011427+jlujan-invitae@users.noreply.github.com> Date: Fri, 30 Apr 2021 18:41:52 -0500 Subject: [PATCH 318/865] Fix chalice deployment bug introduced in #270 (#316) * Fix chalice deployment bug introduced in #270 * Refactor LocalLambdaClient into seperate file * try/catch import when running from a deployed lambda * Updates from PR feedback, improve lazy lambda tests * Only import LocalLambdaClient if CLI and client not passed in * Add unittest for default lazy listener * Fix name of mocked lambda client in test_lazy_listeners_non_cli --- .../adapter/aws_lambda/chalice_handler.py | 44 +++++-------- .../chalice_lazy_listener_runner.py | 6 +- .../adapter/aws_lambda/local_lambda_client.py | 28 +++++++++ tests/adapter_tests/aws/test_aws_chalice.py | 63 ++++++++++++++++++- 4 files changed, 106 insertions(+), 35 deletions(-) create mode 100644 slack_bolt/adapter/aws_lambda/local_lambda_client.py diff --git a/slack_bolt/adapter/aws_lambda/chalice_handler.py b/slack_bolt/adapter/aws_lambda/chalice_handler.py index f4c7220ba..a1edd158c 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_handler.py +++ b/slack_bolt/adapter/aws_lambda/chalice_handler.py @@ -1,10 +1,10 @@ import logging -import json from os import getenv +from typing import Optional + +from botocore.client import BaseClient from chalice.app import Request, Response, Chalice -from chalice.config import Config -from chalice.test import BaseClient, LambdaContext, InvokeResponse from slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner import ( ChaliceLazyListenerRunner, @@ -17,38 +17,22 @@ from slack_bolt.response import BoltResponse -class LocalLambdaClient(BaseClient): - """Lambda client implementing `invoke` for use when running with Chalice CLI""" - - def __init__(self, app: Chalice, config: Config) -> None: - self._app = app - self._config = config - - def invoke( - self, - FunctionName: str = None, - InvocationType: str = "Event", - Payload: str = "{}", - ) -> InvokeResponse: - scoped = self._config.scope(self._config.chalice_stage, FunctionName) - lambda_context = LambdaContext( - FunctionName, memory_size=scoped.lambda_memory_size - ) - - with self._patched_env_vars(scoped.environment_variables): - response = self._app(json.loads(Payload), lambda_context) - return InvokeResponse(payload=response) - - class ChaliceSlackRequestHandler: - def __init__(self, app: App, chalice: Chalice): # type: ignore + def __init__(self, app: App, chalice: Chalice, lambda_client: Optional[BaseClient] = None): # type: ignore self.app = app self.chalice = chalice self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler) - lambda_client = None - if getenv("AWS_CHALICE_CLI_MODE") == "true": - lambda_client = LocalLambdaClient(self.chalice, Config()) + if getenv("AWS_CHALICE_CLI_MODE") == "true" and lambda_client is None: + try: + from slack_bolt.adapter.aws_lambda.local_lambda_client import ( + LocalLambdaClient, + ) + + lambda_client = LocalLambdaClient(self.chalice, None) + except ImportError: + logging.info("Failed to load LocalLambdaClient for CLI mode.") + pass self.app.listener_runner.lazy_listener_runner = ChaliceLazyListenerRunner( logger=self.logger, lambda_client=lambda_client diff --git a/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py b/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py index ce339b043..8b8cdea12 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py +++ b/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py @@ -1,15 +1,16 @@ import json from logging import Logger -from typing import Callable, Optional, Any +from typing import Callable, Optional import boto3 +from botocore.client import BaseClient from slack_bolt import BoltRequest from slack_bolt.lazy_listener import LazyListenerRunner class ChaliceLazyListenerRunner(LazyListenerRunner): - def __init__(self, logger: Logger, lambda_client: Optional[Any] = None): + def __init__(self, logger: Logger, lambda_client: Optional[BaseClient] = None): self.lambda_client = lambda_client self.logger = logger @@ -38,4 +39,3 @@ def start(self, function: Callable[..., None], request: BoltRequest) -> None: InvocationType="Event", Payload=json.dumps(payload), ) - self.logger.info(invocation) diff --git a/slack_bolt/adapter/aws_lambda/local_lambda_client.py b/slack_bolt/adapter/aws_lambda/local_lambda_client.py new file mode 100644 index 000000000..82181062e --- /dev/null +++ b/slack_bolt/adapter/aws_lambda/local_lambda_client.py @@ -0,0 +1,28 @@ +import json + +from chalice.app import Chalice +from chalice.config import Config +from chalice.test import BaseClient, LambdaContext, InvokeResponse + + +class LocalLambdaClient(BaseClient): + """Lambda client implementing `invoke` for use when running with Chalice CLI.""" + + def __init__(self, app: Chalice, config: Config) -> None: + self._app = app + self._config = config if config else Config() + + def invoke( + self, + FunctionName: str = None, + InvocationType: str = "Event", + Payload: str = "{}", + ) -> InvokeResponse: + scoped = self._config.scope(self._config.chalice_stage, FunctionName) + lambda_context = LambdaContext( + FunctionName, memory_size=scoped.lambda_memory_size + ) + + with self._patched_env_vars(scoped.environment_variables): + response = self._app(json.loads(Payload), lambda_context) + return InvokeResponse(payload=response) diff --git a/tests/adapter_tests/aws/test_aws_chalice.py b/tests/adapter_tests/aws/test_aws_chalice.py index 223f961ee..e716583c2 100644 --- a/tests/adapter_tests/aws/test_aws_chalice.py +++ b/tests/adapter_tests/aws/test_aws_chalice.py @@ -4,7 +4,6 @@ from typing import Dict, Any from urllib.parse import quote from unittest import mock -import logging from chalice import Chalice, Response from chalice.app import Request @@ -18,6 +17,7 @@ ChaliceSlackRequestHandler, not_found, ) + from slack_bolt.app import App from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( @@ -315,13 +315,72 @@ def events() -> Response: return slack_handler.handle(chalice_app.current_request) headers = self.build_headers(timestamp, body) - client = Client(chalice_app, Config()) + client = Client(chalice_app) response = client.http.post("/slack/events", headers=headers, body=body) assert response.status_code == 200, f"Failed request: {response.body}" assert_auth_test_count(self, 1) assert self.mock_received_requests["/chat.postMessage"] == 1 + @mock.patch( + "slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner.boto3", + autospec=True, + ) + def test_lazy_listeners_non_cli(self, mock_boto3): + with mock.patch.dict(os.environ, {"AWS_CHALICE_CLI_MODE": "false"}): + assert os.environ.get("AWS_CHALICE_CLI_MODE") == "false" + + mock_lambda = mock.MagicMock() # mock of boto3.client('lambda') + mock_boto3.client.return_value = mock_lambda + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + + def command_handler(ack): + ack() + + def say_it(say): + say("Done!") + + app.command("/hello-world")(ack=command_handler, lazy=[say_it]) + + 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 + + chalice_app = Chalice(app_name="bolt-python-chalice") + + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + @chalice_app.route( + "/slack/events", + methods=["POST"], + content_types=["application/x-www-form-urlencoded", "application/json"], + ) + def events() -> Response: + return slack_handler.handle(chalice_app.current_request) + + headers = self.build_headers(timestamp, body) + client = Client(chalice_app) + response = client.http.post("/slack/events", headers=headers, body=body) + assert response + assert mock_lambda.invoke.called + def test_oauth(self): app = App( client=self.web_client, From ed7ef1f1bc5fe20f63da41eef8d8a58584e44e0c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 6 May 2021 18:20:29 +0900 Subject: [PATCH 319/865] Update Django example app - ref #324 --- examples/django/README.md | 8 ++++++-- examples/django/slackapp/models.py | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/examples/django/README.md b/examples/django/README.md index 538bec95a..30e0e2adf 100644 --- a/examples/django/README.md +++ b/examples/django/README.md @@ -1,7 +1,11 @@ +Follow the instructions [here](https://slack.dev/bolt-python/concepts#authenticating-oauth) for configuring OAuth flow supported Slack apps. This example works with the default env variables such as `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_SCOPES`, `SLACK_SIGNING_SECRET`, and so forth. + ``` pip install -r requirements.txt -export SLACK_SIGNING_SECRET=*** -export SLACK_BOT_TOKEN=xoxb-*** +export SLACK_CLIENT_ID= +export SLACK_CLIENT_SECRET= +export SLACK_SCOPES=commands.chat:write +export SLACK_SIGNING_SECRET= python manage.py migrate python manage.py runserver 0.0.0.0:3000 diff --git a/examples/django/slackapp/models.py b/examples/django/slackapp/models.py index cfbbd8ade..249de2000 100644 --- a/examples/django/slackapp/models.py +++ b/examples/django/slackapp/models.py @@ -231,10 +231,11 @@ def consume(self, state: str) -> bool: from slack_bolt.oauth.oauth_settings import OAuthSettings logger = logging.getLogger(__name__) -client_id, client_secret, signing_secret = ( +client_id, client_secret, signing_secret, scopes = ( os.environ["SLACK_CLIENT_ID"], os.environ["SLACK_CLIENT_SECRET"], os.environ["SLACK_SIGNING_SECRET"], + os.environ.get("SLACK_SCOPES", "commands").split(","), ) app = App( @@ -246,6 +247,7 @@ def consume(self, state: str) -> bool: oauth_settings=OAuthSettings( client_id=client_id, client_secret=client_secret, + scopes=scopes, state_store=DjangoOAuthStateStore( expiration_seconds=120, logger=logger, From 33bb0252a84094151d0ef079e850ebeb90a02516 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 6 May 2021 18:39:47 +0900 Subject: [PATCH 320/865] Fix #325 Django example app error by importing datetime utilities --- examples/django/slackapp/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/django/slackapp/models.py b/examples/django/slackapp/models.py index 249de2000..0db1e9b6d 100644 --- a/examples/django/slackapp/models.py +++ b/examples/django/slackapp/models.py @@ -79,6 +79,7 @@ class SlackOAuthState(models.Model): from uuid import uuid4 from django.db.models import F from django.utils import timezone +from django.utils.timezone import is_naive, make_aware from slack_sdk.oauth import InstallationStore, OAuthStateStore from slack_sdk.oauth.installation_store import Bot, Installation from slack_sdk.webhook import WebhookClient From 1e18c0dc956ef948378b93b709db50bd7e0d53a9 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 May 2021 09:57:18 +0900 Subject: [PATCH 321/865] Fix an error in Django example app README --- examples/django/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/django/README.md b/examples/django/README.md index 30e0e2adf..0b1ac6742 100644 --- a/examples/django/README.md +++ b/examples/django/README.md @@ -4,9 +4,9 @@ Follow the instructions [here](https://slack.dev/bolt-python/concepts#authentica pip install -r requirements.txt export SLACK_CLIENT_ID= export SLACK_CLIENT_SECRET= -export SLACK_SCOPES=commands.chat:write +export SLACK_SCOPES=commands,chat:write export SLACK_SIGNING_SECRET= python manage.py migrate python manage.py runserver 0.0.0.0:3000 -``` \ No newline at end of file +``` From c6ec722da8bebb9feb0aef735b2877b5594f99b8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 May 2021 17:10:03 +0900 Subject: [PATCH 322/865] Add missing listener suggestion to the default unhandled error message (#323) * Add missing listener suggestion to the default unhandled error message * Adjust the warning log message --- scripts/run_tests.sh | 4 +- slack_bolt/app/app.py | 2 - slack_bolt/app/async_app.py | 2 - slack_bolt/logger/messages.py | 191 +++- tests/slack_bolt/logger/__init__.py | 0 .../logger/test_unmatched_suggestions.py | 858 ++++++++++++++++++ 6 files changed, 1048 insertions(+), 9 deletions(-) create mode 100644 tests/slack_bolt/logger/__init__.py create mode 100644 tests/slack_bolt/logger/test_unmatched_suggestions.py diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index c9096048a..9931f8236 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -12,13 +12,13 @@ python_version=`python --version | awk '{print $2}'` if [[ $test_target != "" ]] then black slack_bolt/ tests/ && \ - pytest $1 + pytest -vv $1 else if [ ${python_version:0:3} == "3.8" ] then # pytype's behavior can be different in older Python versions black slack_bolt/ tests/ \ - && pytest \ + && pytest -vv \ && pip install -e ".[adapter]" \ && pip install -U pip setuptools wheel \ && pip install -U pytype \ diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 2bf8213cb..e4c6a920d 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -530,8 +530,6 @@ def middleware_next(): def _handle_unmatched_requests( self, req: BoltRequest, resp: BoltResponse ) -> BoltResponse: - # TODO: provide more info like suggestion of listeners - # e.g., You can handle this type of message with @app.event("app_mention") self._framework_logger.warning(warning_unhandled_request(req)) return resp diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 131650262..8deac5f76 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -586,8 +586,6 @@ async def async_middleware_next(): def _handle_unmatched_requests( self, req: AsyncBoltRequest, resp: BoltResponse ) -> BoltResponse: - # TODO: provide more info like suggestion of listeners - # e.g., You can handle this type of message with @app.event("app_mention") self._framework_logger.warning(warning_unhandled_request(req)) return resp diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 294997a65..12302631e 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -1,10 +1,20 @@ import time -from typing import Union +from typing import Union, Dict, Any, Optional from slack_sdk.web import SlackResponse from slack_bolt.request import BoltRequest - +from slack_bolt.request.payload_utils import ( + is_action, + is_event, + is_options, + is_shortcut, + is_slash_command, + is_view, + is_workflow_step_edit, + is_workflow_step_save, + is_workflow_step_execute, +) # ------------------------------- # Error @@ -94,10 +104,185 @@ def warning_unhandled_by_global_middleware( # type: ignore ) +_unhandled_request_suggestion_prefix = """ +--- +[Suggestion] You can handle this type of event with the following listener function: +""" + + +def _build_filtered_body(body: Optional[Dict[str, Any]]) -> dict: + if body is None: + return {} + + payload_type = body.get("type") + filtered_body = {"type": payload_type} + + if "view" in body: + view = body["view"] + # view_submission, view_closed, workflow_step_save + filtered_body["view"] = { + "type": view.get("type"), + "callback_id": view.get("callback_id"), + } + + if payload_type == "block_actions": + # Block Kit Interactivity + actions = body.get("actions", []) + if len(actions) > 0 and actions[0] is not None: + filtered_body["block_id"] = actions[0].get("block_id") + filtered_body["action_id"] = actions[0].get("action_id") + if payload_type == "block_suggestion": + # Block Kit - external data source + filtered_body["block_id"] = body.get("block_id") + filtered_body["action_id"] = body.get("action_id") + filtered_body["value"] = body.get("value") + + if payload_type == "event_callback" and "event" in body: + # Events API, workflow_step_execute + event_payload = body.get("event", {}) + filtered_event = {"type": event_payload.get("type")} + if "subtype" in body["event"]: + filtered_event["subtype"] = event_payload.get("subtype") + filtered_body["event"] = filtered_event + + if "command" in body: + # Slash Commands + filtered_body["command"] = body.get("command") + + if payload_type in ["workflow_step_edit", "shortcut", "message_action"]: + # Workflow Steps, Global Shortcuts, Message Shortcuts + filtered_body["callback_id"] = body.get("callback_id") + + if payload_type == "interactive_message": + # Actions in Attachments + filtered_body["callback_id"] = body.get("callback_id") + filtered_body["actions"] = body.get("actions") + + if payload_type == "dialog_suggestion": + # Dialogs - external data source + filtered_body["callback_id"] = body.get("callback_id") + filtered_body["value"] = body.get("value") + if payload_type == "dialog_submission": + # Dialogs - clicking submit button + filtered_body["callback_id"] = body.get("callback_id") + filtered_body["submission"] = body.get("submission") + if payload_type == "dialog_cancellation": + # Dialogs - clicking cancel button + filtered_body["callback_id"] = body.get("callback_id") + + return filtered_body + + +def _build_unhandled_request_suggestion(default_message: str, code_snippet: str): + return f"""{default_message}{_unhandled_request_suggestion_prefix}{code_snippet}""" + + def warning_unhandled_request( # type: ignore req: Union[BoltRequest, "AsyncBoltRequest"], # type: ignore ) -> str: # type: ignore - return f"Unhandled request ({req.body})" + filtered_body = _build_filtered_body(req.body) + default_message = f"Unhandled request ({filtered_body})" + if ( + is_workflow_step_edit(req.body) + or is_workflow_step_save(req.body) + or is_workflow_step_execute(req.body) + ): + # @app.step + callback_id = ( + filtered_body.get("callback_id") + or filtered_body.get("view", {}).get("callback_id") # type: ignore + or "your-callback-id" + ) + return _build_unhandled_request_suggestion( + default_message, + f""" +from slack_bolt.workflows.step import WorkflowStep +ws = WorkflowStep( + callback_id="{callback_id}", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners +app.step(ws) +""", + ) + if is_action(req.body): + # @app.action + action_id_or_callback_id = req.body.get("callback_id") + if req.body.get("type") == "block_actions": + action_id_or_callback_id = req.body.get("actions")[0].get("action_id") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.action("{action_id_or_callback_id}") +def handle_some_action(ack, body, logger): + ack() + logger.info(body) +""", + ) + if is_options(req.body): + # @app.options + constraints = '"action-id"' + if req.body.get("action_id") is not None: + constraints = '"' + req.body.get("action_id") + '"' + elif req.body.get("type") == "dialog_suggestion": + constraints = f"""{{"type": "dialog_suggestion", "callback_id": "{req.body.get('callback_id')}"}}""" + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.options({constraints}) +def handle_some_options(ack): + ack(options=[ ... ]) +""", + ) + if is_shortcut(req.body): + # @app.shortcut + id = req.body.get("action_id") or req.body.get("callback_id") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.shortcut("{id}") +def handle_shortcuts(ack, body, logger): + ack() + logger.info(body) +""", + ) + if is_view(req.body): + # @app.view + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.view("{req.body.get('view', {}).get('callback_id', 'modal-view-id')}") +def handle_view_events(ack, body, logger): + ack() + logger.info(body) +""", + ) + if is_event(req.body): + # @app.event + event_type = req.body.get("event", {}).get("type") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.event("{event_type}") +def handle_{event_type}_events(body, logger): + logger.info(body) +""", + ) + if is_slash_command(req.body): + # @app.command + command = req.body.get("command", "/your-command") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.command("{command}") +def handle_some_command(ack, body, logger): + ack() + logger.info(body) +""", + ) + return default_message def warning_did_not_call_ack(listener_name: str) -> str: diff --git a/tests/slack_bolt/logger/__init__.py b/tests/slack_bolt/logger/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/logger/test_unmatched_suggestions.py b/tests/slack_bolt/logger/test_unmatched_suggestions.py new file mode 100644 index 000000000..eb2f0a519 --- /dev/null +++ b/tests/slack_bolt/logger/test_unmatched_suggestions.py @@ -0,0 +1,858 @@ +from slack_bolt.request import BoltRequest +from slack_bolt.logger.messages import warning_unhandled_request + + +class TestUnmatchedPatternSuggestions: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_unknown_patterns(self): + req: BoltRequest = BoltRequest(body={"type": "foo"}, mode="socket_mode") + message = warning_unhandled_request(req) + assert f"Unhandled request ({req.body})" == message + + def test_block_actions(self): + req: BoltRequest = BoltRequest(body=block_actions, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "block_actions", + "block_id": "b", + "action_id": "action-id-value", + } + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.action("action-id-value") +def handle_some_action(ack, body, logger): + ack() + logger.info(body) +""" + == message + ) + + def test_attachment_actions(self): + req: BoltRequest = BoltRequest(body=attachment_actions, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "interactive_message", + "callback_id": "pick_channel_for_fun", + "actions": [ + { + "name": "channel_list", + "type": "select", + "selected_options": [{"value": "C111"}], + } + ], + } + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.action("pick_channel_for_fun") +def handle_some_action(ack, body, logger): + ack() + logger.info(body) +""" + == message + ) + + def test_app_mention_event(self): + req: BoltRequest = BoltRequest(body=app_mention_event, mode="socket_mode") + filtered_body = { + "type": "event_callback", + "event": {"type": "app_mention"}, + } + message = warning_unhandled_request(req) + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.event("app_mention") +def handle_app_mention_events(body, logger): + logger.info(body) +""" + == message + ) + + def test_commands(self): + req: BoltRequest = BoltRequest(body=slash_command, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": None, + "command": "/start-conv", + } + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.command("/start-conv") +def handle_some_command(ack, body, logger): + ack() + logger.info(body) +""" + == message + ) + + def test_shortcut(self): + req: BoltRequest = BoltRequest(body=global_shortcut, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "shortcut", + "callback_id": "test-shortcut", + } + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.shortcut("test-shortcut") +def handle_shortcuts(ack, body, logger): + ack() + logger.info(body) +""" + == message + ) + + req: BoltRequest = BoltRequest(body=message_shortcut, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "message_action", + "callback_id": "test-shortcut", + } + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.shortcut("test-shortcut") +def handle_shortcuts(ack, body, logger): + ack() + logger.info(body) +""" + == message + ) + + def test_view(self): + req: BoltRequest = BoltRequest(body=view_submission, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "view_submission", + "view": {"type": "modal", "callback_id": "view-id"}, + } + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.view("view-id") +def handle_view_events(ack, body, logger): + ack() + logger.info(body) +""" + == message + ) + + req: BoltRequest = BoltRequest(body=view_closed, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "view_closed", + "view": {"type": "modal", "callback_id": "view-id"}, + } + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.view("view-id") +def handle_view_events(ack, body, logger): + ack() + logger.info(body) +""" + == message + ) + + def test_block_suggestion(self): + req: BoltRequest = BoltRequest(body=block_suggestion, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "block_suggestion", + "view": {"type": "modal", "callback_id": "view-id"}, + "block_id": "block-id", + "action_id": "the-id", + "value": "search word", + } + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.options("the-id") +def handle_some_options(ack): + ack(options=[ ... ]) +""" + == message + ) + + def test_dialog_suggestion(self): + req: BoltRequest = BoltRequest(body=dialog_suggestion, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "dialog_suggestion", + "callback_id": "the-id", + "value": "search keyword", + } + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.options({{"type": "dialog_suggestion", "callback_id": "the-id"}}) +def handle_some_options(ack): + ack(options=[ ... ]) +""" + == message + ) + + def test_step(self): + req: BoltRequest = BoltRequest(body=step_edit_payload, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "workflow_step_edit", + "callback_id": "copy_review", + } + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +from slack_bolt.workflows.step import WorkflowStep +ws = WorkflowStep( + callback_id="copy_review", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners +app.step(ws) +""" + == message + ) + req: BoltRequest = BoltRequest(body=step_save_payload, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "view_submission", + "view": {"type": "workflow_step", "callback_id": "copy_review"}, + } + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +from slack_bolt.workflows.step import WorkflowStep +ws = WorkflowStep( + callback_id="copy_review", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners +app.step(ws) +""" + == message + ) + req: BoltRequest = BoltRequest(body=step_execute_payload, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "event_callback", + "event": {"type": "workflow_step_execute"}, + } + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +from slack_bolt.workflows.step import WorkflowStep +ws = WorkflowStep( + callback_id="your-callback-id", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners +app.step(ws) +""" + == message + ) + + +block_actions = { + "type": "block_actions", + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "111.222", + "channel_id": "C111", + "is_ephemeral": True, + }, + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "action-id-value", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button", "emoji": True}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], +} + +attachment_actions = { + "type": "interactive_message", + "actions": [ + { + "name": "channel_list", + "type": "select", + "selected_options": [{"value": "C111"}], + } + ], + "callback_id": "pick_channel_for_fun", + "team": {"id": "T111", "domain": "hooli-hq"}, + "channel": {"id": "C222", "name": "triage-random"}, + "user": {"id": "U111", "name": "gbelson"}, + "action_ts": "1520966872.245369", + "message_ts": "1520965348.000538", + "attachment_id": "1", + "token": "verification_token", + "is_app_unfurl": True, + "original_message": { + "text": "", + "username": "Belson Bot", + "bot_id": "B111", + "attachments": [ + { + "callback_id": "pick_channel_for_fun", + "text": "Choose a channel", + "id": 1, + "color": "2b72cb", + "actions": [ + { + "id": "1", + "name": "channel_list", + "text": "Public channels", + "type": "select", + "data_source": "channels", + } + ], + "fallback": "Choose a channel", + } + ], + "type": "message", + "subtype": "bot_message", + "ts": "1520965348.000538", + }, + "response_url": "https://hooks.slack.com/actions/T111/111/xxxx", + "trigger_id": "111.222.valid", +} + + +app_mention_event = { + "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, +} + +slash_command = { + "token": "fixed-verification-token", + "team_id": "T111", + "team_domain": "maria", + "channel_id": "C111", + "channel_name": "general", + "user_id": "U111", + "user_name": "rainer", + "command": "/start-conv", + "text": "title", + "response_url": "https://xxx.slack.com/commands/T111/xxx/zzz", + "trigger_id": "111.222.xxx", +} + +step_edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +step_save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +step_execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + +global_shortcut = { + "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", +} + +message_shortcut = { + "type": "message_action", + "token": "verification_token", + "action_ts": "1583637157.207593", + "team": { + "id": "T111", + "domain": "test-test", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "name": "test-test"}, + "channel": {"id": "C111", "name": "dev"}, + "callback_id": "test-shortcut", + "trigger_id": "111.222.xxx", + "message_ts": "1583636382.000300", + "message": { + "client_msg_id": "zzzz-111-222-xxx-yyy", + "type": "message", + "text": "<@W222> test", + "user": "W111", + "ts": "1583636382.000300", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "d7eJ", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U222"}, + {"type": "text", "text": " test"}, + ], + } + ], + } + ], + }, + "response_url": "https://hooks.slack.com/app/T111/111/xxx", +} + +view_submission = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "hspI", + "label": { + "type": "plain_text", + "text": "Label", + }, + "optional": False, + "element": {"type": "plain_text_input", "action_id": "maBWU"}, + } + ], + "private_metadata": "This is for you!", + "callback_id": "view-id", + "state": { + "values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}} + }, + "hash": "1596530361.3wRYuk3R", + "title": { + "type": "plain_text", + "text": "My App", + }, + "clear_on_close": False, + "notify_on_close": False, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], +} + +view_closed = { + "type": "view_closed", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "hspI", + "label": { + "type": "plain_text", + "text": "Label", + }, + "optional": False, + "element": {"type": "plain_text_input", "action_id": "maBWU"}, + } + ], + "private_metadata": "This is for you!", + "callback_id": "view-id", + "state": {"values": {}}, + "hash": "1596530361.3wRYuk3R", + "title": { + "type": "plain_text", + "text": "My App", + }, + "clear_on_close": False, + "notify_on_close": False, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], +} + +block_suggestion = { + "type": "block_suggestion", + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "container": {"type": "view", "view_id": "V111"}, + "api_app_id": "A111", + "token": "verification_token", + "action_id": "the-id", + "block_id": "block-id", + "value": "search word", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "5ar+", + "label": {"type": "plain_text", "text": "Label"}, + "optional": False, + "element": {"type": "plain_text_input", "action_id": "i5IpR"}, + }, + { + "type": "input", + "block_id": "es_b", + "label": {"type": "plain_text", "text": "Search"}, + "optional": False, + "element": { + "type": "external_select", + "action_id": "es_a", + "placeholder": {"type": "plain_text", "text": "Select an item"}, + }, + }, + { + "type": "input", + "block_id": "mes_b", + "label": {"type": "plain_text", "text": "Search (multi)"}, + "optional": False, + "element": { + "type": "multi_external_select", + "action_id": "mes_a", + "placeholder": {"type": "plain_text", "text": "Select an item"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "view-id", + "state": {"values": {}}, + "hash": "111.xxx", + "title": {"type": "plain_text", "text": "My App"}, + "clear_on_close": False, + "notify_on_close": False, + "close": {"type": "plain_text", "text": "Cancel"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, +} + +dialog_suggestion = { + "type": "dialog_suggestion", + "token": "verification_token", + "action_ts": "1596603332.676855", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "user": {"id": "W111", "name": "primary-owner", "team_id": "T111"}, + "channel": {"id": "C111", "name": "test-channel"}, + "name": "types", + "value": "search keyword", + "callback_id": "the-id", + "state": "Limo", +} From 7d0d1eaae2575ccf93a0588f05792eca0f9a8ae1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 May 2021 17:34:05 +0900 Subject: [PATCH 323/865] version 1.6.0 --- setup.py | 2 +- slack_bolt/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 6a42a72dd..bdd2db1b2 100755 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ "moto<2", # For AWS tests "bottle>=0.12,<1", "boddle>=0.2,<0.3", # For Bottle app tests - "chalice>=1.22.1,<2", + "chalice>=1.22.4,<2", "click>=7,<8", # for chalice "CherryPy>=18,<19", "Django>=3,<4", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index c73df5eee..dfa62616b 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.5.0" +__version__ = "1.6.0" From 31436f729871c38f596a1363aa1d61a5b4c15d0d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 May 2021 17:52:11 +0900 Subject: [PATCH 324/865] Update API docs --- .../adapter/aws_lambda/chalice_handler.html | 132 ++------ .../chalice_lazy_listener_runner.html | 15 +- .../slack_bolt/adapter/aws_lambda/index.html | 5 + .../aws_lambda/local_lambda_client.html | 164 ++++++++++ docs/api-docs/slack_bolt/app/app.html | 131 ++++++-- docs/api-docs/slack_bolt/app/async_app.html | 141 +++++++-- docs/api-docs/slack_bolt/logger/messages.html | 295 +++++++++++++++++- .../oauth/async_callback_options.html | 12 +- .../oauth/async_oauth_settings.html | 20 +- .../slack_bolt/oauth/oauth_settings.html | 20 +- .../slack_bolt/request/async_request.html | 40 ++- docs/api-docs/slack_bolt/request/request.html | 38 ++- docs/api-docs/slack_bolt/util/utils.html | 34 +- docs/api-docs/slack_bolt/version.html | 2 +- 14 files changed, 838 insertions(+), 211 deletions(-) create mode 100644 docs/api-docs/slack_bolt/adapter/aws_lambda/local_lambda_client.html diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html index 032b92679..c46bbd7df 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html @@ -27,12 +27,12 @@

      Module slack_bolt.adapter.aws_lambda.chalice_handlerExpand source code
      import logging
      -import json
       from os import getenv
      +from typing import Optional
      +
      +from botocore.client import BaseClient
       
       from chalice.app import Request, Response, Chalice
      -from chalice.config import Config
      -from chalice.test import BaseClient, LambdaContext, InvokeResponse
       
       from slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner import (
           ChaliceLazyListenerRunner,
      @@ -45,38 +45,22 @@ 

      Module slack_bolt.adapter.aws_lambda.chalice_handlerClasses

      class ChaliceSlackRequestHandler -(app: App, chalice: chalice.app.Chalice) +(app: App, chalice: chalice.app.Chalice, lambda_client: Optional[botocore.client.BaseClient] = None)
      @@ -234,14 +218,21 @@

      Classes

      Expand source code
      class ChaliceSlackRequestHandler:
      -    def __init__(self, app: App, chalice: Chalice):  # type: ignore
      +    def __init__(self, app: App, chalice: Chalice, lambda_client: Optional[BaseClient] = None):  # type: ignore
               self.app = app
               self.chalice = chalice
               self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler)
       
      -        lambda_client = None
      -        if getenv("AWS_CHALICE_CLI_MODE") == "true":
      -            lambda_client = LocalLambdaClient(self.chalice, Config())
      +        if getenv("AWS_CHALICE_CLI_MODE") == "true" and lambda_client is None:
      +            try:
      +                from slack_bolt.adapter.aws_lambda.local_lambda_client import (
      +                    LocalLambdaClient,
      +                )
      +
      +                lambda_client = LocalLambdaClient(self.chalice, None)
      +            except ImportError:
      +                logging.info("Failed to load LocalLambdaClient for CLI mode.")
      +                pass
       
               self.app.listener_runner.lazy_listener_runner = ChaliceLazyListenerRunner(
                   logger=self.logger, lambda_client=lambda_client
      @@ -377,71 +368,6 @@ 

      Methods

      -
      -class LocalLambdaClient -(app: chalice.app.Chalice, config: chalice.config.Config) -
      -
      -

      Lambda client implementing invoke for use when running with Chalice CLI

      -
      - -Expand source code - -
      class LocalLambdaClient(BaseClient):
      -    """Lambda client implementing `invoke` for use when running with Chalice CLI"""
      -
      -    def __init__(self, app: Chalice, config: Config) -> None:
      -        self._app = app
      -        self._config = config
      -
      -    def invoke(
      -        self,
      -        FunctionName: str = None,
      -        InvocationType: str = "Event",
      -        Payload: str = "{}",
      -    ) -> InvokeResponse:
      -        scoped = self._config.scope(self._config.chalice_stage, FunctionName)
      -        lambda_context = LambdaContext(
      -            FunctionName, memory_size=scoped.lambda_memory_size
      -        )
      -
      -        with self._patched_env_vars(scoped.environment_variables):
      -            response = self._app(json.loads(Payload), lambda_context)
      -        return InvokeResponse(payload=response)
      -
      -

      Ancestors

      -
        -
      • chalice.test.BaseClient
      • -
      -

      Methods

      -
      -
      -def invoke(self, FunctionName: str = None, InvocationType: str = 'Event', Payload: str = '{}') ‑> chalice.test.InvokeResponse -
      -
      -
      -
      - -Expand source code - -
      def invoke(
      -    self,
      -    FunctionName: str = None,
      -    InvocationType: str = "Event",
      -    Payload: str = "{}",
      -) -> InvokeResponse:
      -    scoped = self._config.scope(self._config.chalice_stage, FunctionName)
      -    lambda_context = LambdaContext(
      -        FunctionName, memory_size=scoped.lambda_memory_size
      -    )
      -
      -    with self._patched_env_vars(scoped.environment_variables):
      -        response = self._app(json.loads(Payload), lambda_context)
      -    return InvokeResponse(payload=response)
      -
      -
      -
      -

    @@ -472,12 +398,6 @@

    handle -
  • -

    LocalLambdaClient

    - -
  • diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html index cb1183a0d..f4780c6d9 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html @@ -28,16 +28,17 @@

    Module slack_bolt.adapter.aws_lambda.chalice_lazy_listen
    import json
     from logging import Logger
    -from typing import Callable, Optional, Any
    +from typing import Callable, Optional
     
     import boto3
    +from botocore.client import BaseClient
     
     from slack_bolt import BoltRequest
     from slack_bolt.lazy_listener import LazyListenerRunner
     
     
     class ChaliceLazyListenerRunner(LazyListenerRunner):
    -    def __init__(self, logger: Logger, lambda_client: Optional[Any] = None):
    +    def __init__(self, logger: Logger, lambda_client: Optional[BaseClient] = None):
             self.lambda_client = lambda_client
             self.logger = logger
     
    @@ -65,8 +66,7 @@ 

    Module slack_bolt.adapter.aws_lambda.chalice_lazy_listen FunctionName=request.context["aws_lambda_function_name"], InvocationType="Event", Payload=json.dumps(payload), - ) - self.logger.info(invocation)

    + )
    @@ -80,7 +80,7 @@

    Classes

    class ChaliceLazyListenerRunner -(logger: logging.Logger, lambda_client: Optional[Any] = None) +(logger: logging.Logger, lambda_client: Optional[botocore.client.BaseClient] = None)
    @@ -89,7 +89,7 @@

    Classes

    Expand source code
    class ChaliceLazyListenerRunner(LazyListenerRunner):
    -    def __init__(self, logger: Logger, lambda_client: Optional[Any] = None):
    +    def __init__(self, logger: Logger, lambda_client: Optional[BaseClient] = None):
             self.lambda_client = lambda_client
             self.logger = logger
     
    @@ -117,8 +117,7 @@ 

    Classes

    FunctionName=request.context["aws_lambda_function_name"], InvocationType="Event", Payload=json.dumps(payload), - ) - self.logger.info(invocation)
    + )

    Ancestors

      diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html index 573bfaba0..0aec8cf2b 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html @@ -56,6 +56,10 @@

      Sub-modules

      +
      slack_bolt.adapter.aws_lambda.local_lambda_client
      +
      +
      +
    @@ -84,6 +88,7 @@

    Index

  • slack_bolt.adapter.aws_lambda.internals
  • slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow
  • slack_bolt.adapter.aws_lambda.lazy_listener_runner
  • +
  • slack_bolt.adapter.aws_lambda.local_lambda_client
  • diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/local_lambda_client.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/local_lambda_client.html new file mode 100644 index 000000000..c0e435bed --- /dev/null +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/local_lambda_client.html @@ -0,0 +1,164 @@ + + + + + + +slack_bolt.adapter.aws_lambda.local_lambda_client API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.adapter.aws_lambda.local_lambda_client

    +
    +
    +
    + +Expand source code + +
    import json
    +
    +from chalice.app import Chalice
    +from chalice.config import Config
    +from chalice.test import BaseClient, LambdaContext, InvokeResponse
    +
    +
    +class LocalLambdaClient(BaseClient):
    +    """Lambda client implementing `invoke` for use when running with Chalice CLI."""
    +
    +    def __init__(self, app: Chalice, config: Config) -> None:
    +        self._app = app
    +        self._config = config if config else Config()
    +
    +    def invoke(
    +        self,
    +        FunctionName: str = None,
    +        InvocationType: str = "Event",
    +        Payload: str = "{}",
    +    ) -> InvokeResponse:
    +        scoped = self._config.scope(self._config.chalice_stage, FunctionName)
    +        lambda_context = LambdaContext(
    +            FunctionName, memory_size=scoped.lambda_memory_size
    +        )
    +
    +        with self._patched_env_vars(scoped.environment_variables):
    +            response = self._app(json.loads(Payload), lambda_context)
    +        return InvokeResponse(payload=response)
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class LocalLambdaClient +(app: chalice.app.Chalice, config: chalice.config.Config) +
    +
    +

    Lambda client implementing invoke for use when running with Chalice CLI.

    +
    + +Expand source code + +
    class LocalLambdaClient(BaseClient):
    +    """Lambda client implementing `invoke` for use when running with Chalice CLI."""
    +
    +    def __init__(self, app: Chalice, config: Config) -> None:
    +        self._app = app
    +        self._config = config if config else Config()
    +
    +    def invoke(
    +        self,
    +        FunctionName: str = None,
    +        InvocationType: str = "Event",
    +        Payload: str = "{}",
    +    ) -> InvokeResponse:
    +        scoped = self._config.scope(self._config.chalice_stage, FunctionName)
    +        lambda_context = LambdaContext(
    +            FunctionName, memory_size=scoped.lambda_memory_size
    +        )
    +
    +        with self._patched_env_vars(scoped.environment_variables):
    +            response = self._app(json.loads(Payload), lambda_context)
    +        return InvokeResponse(payload=response)
    +
    +

    Ancestors

    +
      +
    • chalice.test.BaseClient
    • +
    +

    Methods

    +
    +
    +def invoke(self, FunctionName: str = None, InvocationType: str = 'Event', Payload: str = '{}') ‑> chalice.test.InvokeResponse +
    +
    +
    +
    + +Expand source code + +
    def invoke(
    +    self,
    +    FunctionName: str = None,
    +    InvocationType: str = "Event",
    +    Payload: str = "{}",
    +) -> InvokeResponse:
    +    scoped = self._config.scope(self._config.chalice_stage, FunctionName)
    +    lambda_context = LambdaContext(
    +        FunctionName, memory_size=scoped.lambda_memory_size
    +    )
    +
    +    with self._patched_env_vars(scoped.environment_variables):
    +        response = self._app(json.loads(Payload), lambda_context)
    +    return InvokeResponse(payload=response)
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index 559314723..31bdc474a 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -127,6 +127,11 @@

    Module slack_bolt.app.app

    installation_store: Optional[InstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, + # for customizing the built-in middleware + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, # for the OAuth flow oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, @@ -174,6 +179,21 @@

    Module slack_bolt.app.app

    by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) + request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `RequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests. + Make sure if it's safe enough when you turn a built-in middleware off. + We strongly recommend using RequestVerification for better security. + If you have a proxy that verifies request signature in front of the Bolt app, + it's totally fine to disable RequestVerification to avoid duplication of work. + Don't turn it off just for easiness of development. + ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True). + `IgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events + generated by this app's bot user (this is useful for avoiding code error causing an infinite loop). + url_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `UrlVerification` is a built-in middleware that handles url_verification requests + that verify the endpoint for Events API in HTTP Mode requests. + ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True). + `SslCheck` is a built-in middleware that handles ssl_check requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. @@ -320,22 +340,37 @@

    Module slack_bolt.app.app

    self._init_middleware_list_done = False self._init_middleware_list( - token_verification_enabled=token_verification_enabled + token_verification_enabled=token_verification_enabled, + request_verification_enabled=request_verification_enabled, + ignoring_self_events_enabled=ignoring_self_events_enabled, + ssl_check_enabled=ssl_check_enabled, + url_verification_enabled=url_verification_enabled, ) - def _init_middleware_list(self, token_verification_enabled: bool): + def _init_middleware_list( + self, + token_verification_enabled: bool = True, + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, + ): if self._init_middleware_list_done: return - self._middleware_list.append( - SslCheck(verification_token=self._verification_token) - ) - self._middleware_list.append(RequestVerification(self._signing_secret)) + if ssl_check_enabled is True: + self._middleware_list.append( + SslCheck(verification_token=self._verification_token) + ) + if request_verification_enabled is True: + self._middleware_list.append(RequestVerification(self._signing_secret)) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._oauth_flow is None: if self._token is not None: try: auth_test_result = None if token_verification_enabled: + # This API call is for eagerly validating the token auth_test_result = self._client.auth_test(token=self._token) self._middleware_list.append( SingleTeamAuthorization(auth_test_result=auth_test_result) @@ -352,8 +387,10 @@

    Module slack_bolt.app.app

    self._middleware_list.append( MultiTeamsAuthorization(authorize=self._authorize) ) - self._middleware_list.append(IgnoringSelfEvents()) - self._middleware_list.append(UrlVerification()) + if ignoring_self_events_enabled is True: + self._middleware_list.append(IgnoringSelfEvents()) + if url_verification_enabled is True: + self._middleware_list.append(UrlVerification()) self._init_middleware_list_done = True # ------------------------- @@ -521,8 +558,6 @@

    Module slack_bolt.app.app

    def _handle_unmatched_requests( self, req: BoltRequest, resp: BoltResponse ) -> BoltResponse: - # TODO: provide more info like suggestion of listeners - # e.g., You can handle this type of message with @app.event("app_mention") self._framework_logger.warning(warning_unhandled_request(req)) return resp @@ -1386,7 +1421,7 @@

    Classes

    class App -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None)

    Bolt App that provides functionalities to register middleware/listeners.

    @@ -1439,6 +1474,25 @@

    Args

    The module offering save/find operations of installation data
    installation_store_bot_only
    Use InstallationStore#find_bot() if True (Default: False)
    +
    request_verification_enabled
    +
    False if you would like to disable the built-in middleware (Default: True). +RequestVerification is a built-in middleware that verifies the signature in HTTP Mode requests. +Make sure if it's safe enough when you turn a built-in middleware off. +We strongly recommend using RequestVerification for better security. +If you have a proxy that verifies request signature in front of the Bolt app, +it's totally fine to disable RequestVerification to avoid duplication of work. +Don't turn it off just for easiness of development.
    +
    ignoring_self_events_enabled
    +
    False if you would like to disable the built-in middleware (Default: True). +IgnoringSelfEvents is a built-in middleware that enables Bolt apps to easily skip the events +generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
    +
    url_verification_enabled
    +
    False if you would like to disable the built-in middleware (Default: True). +UrlVerification is a built-in middleware that handles url_verification requests +that verify the endpoint for Events API in HTTP Mode requests.
    +
    ssl_check_enabled
    +
    bool = False if you would like to disable the built-in middleware (Default: True). +SslCheck is a built-in middleware that handles ssl_check requests from Slack.
    oauth_settings
    The settings related to Slack app installation flow (OAuth flow)
    oauth_flow
    @@ -1472,6 +1526,11 @@

    Args

    installation_store: Optional[InstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, + # for customizing the built-in middleware + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, # for the OAuth flow oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, @@ -1519,6 +1578,21 @@

    Args

    by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) + request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `RequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests. + Make sure if it's safe enough when you turn a built-in middleware off. + We strongly recommend using RequestVerification for better security. + If you have a proxy that verifies request signature in front of the Bolt app, + it's totally fine to disable RequestVerification to avoid duplication of work. + Don't turn it off just for easiness of development. + ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True). + `IgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events + generated by this app's bot user (this is useful for avoiding code error causing an infinite loop). + url_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `UrlVerification` is a built-in middleware that handles url_verification requests + that verify the endpoint for Events API in HTTP Mode requests. + ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True). + `SslCheck` is a built-in middleware that handles ssl_check requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. @@ -1665,22 +1739,37 @@

    Args

    self._init_middleware_list_done = False self._init_middleware_list( - token_verification_enabled=token_verification_enabled + token_verification_enabled=token_verification_enabled, + request_verification_enabled=request_verification_enabled, + ignoring_self_events_enabled=ignoring_self_events_enabled, + ssl_check_enabled=ssl_check_enabled, + url_verification_enabled=url_verification_enabled, ) - def _init_middleware_list(self, token_verification_enabled: bool): + def _init_middleware_list( + self, + token_verification_enabled: bool = True, + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, + ): if self._init_middleware_list_done: return - self._middleware_list.append( - SslCheck(verification_token=self._verification_token) - ) - self._middleware_list.append(RequestVerification(self._signing_secret)) + if ssl_check_enabled is True: + self._middleware_list.append( + SslCheck(verification_token=self._verification_token) + ) + if request_verification_enabled is True: + self._middleware_list.append(RequestVerification(self._signing_secret)) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._oauth_flow is None: if self._token is not None: try: auth_test_result = None if token_verification_enabled: + # This API call is for eagerly validating the token auth_test_result = self._client.auth_test(token=self._token) self._middleware_list.append( SingleTeamAuthorization(auth_test_result=auth_test_result) @@ -1697,8 +1786,10 @@

    Args

    self._middleware_list.append( MultiTeamsAuthorization(authorize=self._authorize) ) - self._middleware_list.append(IgnoringSelfEvents()) - self._middleware_list.append(UrlVerification()) + if ignoring_self_events_enabled is True: + self._middleware_list.append(IgnoringSelfEvents()) + if url_verification_enabled is True: + self._middleware_list.append(UrlVerification()) self._init_middleware_list_done = True # ------------------------- @@ -1866,8 +1957,6 @@

    Args

    def _handle_unmatched_requests( self, req: BoltRequest, resp: BoltResponse ) -> BoltResponse: - # TODO: provide more info like suggestion of listeners - # e.g., You can handle this type of message with @app.event("app_mention") self._framework_logger.warning(warning_unhandled_request(req)) return resp diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index 90e10ae97..adbbf75b0 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -133,10 +133,15 @@

    Module slack_bolt.app.async_app

    token: Optional[str] = None, client: Optional[AsyncWebClient] = None, # for multi-workspace apps + authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[AsyncInstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, - authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, + # for customizing the built-in middleware + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, # for the OAuth flow oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, @@ -183,6 +188,21 @@

    Module slack_bolt.app.async_app

    by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `AsyncInstallationStore#async_find_bot()` if True (Default: False) + request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncRequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests. + Make sure if it's safe enough when you turn a built-in middleware off. + We strongly recommend using RequestVerification for better security. + If you have a proxy that verifies request signature in front of the Bolt app, + it's totally fine to disable RequestVerification to avoid duplication of work. + Don't turn it off just for easiness of development. + ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncIgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events + generated by this app's bot user (this is useful for avoiding code error causing an infinite loop). + url_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncUrlVerification` is a built-in middleware that handles url_verification requests + that verify the endpoint for Events API in HTTP Mode requests. + ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True). + `AsyncSslCheck` is a built-in middleware that handles ssl_check requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.AsyncOAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. @@ -344,19 +364,33 @@

    Module slack_bolt.app.async_app

    ) self._init_middleware_list_done = False - self._init_async_middleware_list() + self._init_async_middleware_list( + request_verification_enabled=request_verification_enabled, + ignoring_self_events_enabled=ignoring_self_events_enabled, + ssl_check_enabled=ssl_check_enabled, + url_verification_enabled=url_verification_enabled, + ) self._server: Optional[AsyncSlackAppServer] = None - def _init_async_middleware_list(self): + def _init_async_middleware_list( + self, + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, + ): if self._init_middleware_list_done: return - self._async_middleware_list.append( - AsyncSslCheck(verification_token=self._verification_token) - ) - self._async_middleware_list.append( - AsyncRequestVerification(self._signing_secret) - ) + if ssl_check_enabled is True: + self._async_middleware_list.append( + AsyncSslCheck(verification_token=self._verification_token) + ) + if request_verification_enabled is True: + self._async_middleware_list.append( + AsyncRequestVerification(self._signing_secret) + ) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: self._async_middleware_list.append(AsyncSingleTeamAuthorization()) @@ -371,8 +405,10 @@

    Module slack_bolt.app.async_app

    AsyncMultiTeamsAuthorization(authorize=self._async_authorize) ) - self._async_middleware_list.append(AsyncIgnoringSelfEvents()) - self._async_middleware_list.append(AsyncUrlVerification()) + if ignoring_self_events_enabled is True: + self._async_middleware_list.append(AsyncIgnoringSelfEvents()) + if url_verification_enabled is True: + self._async_middleware_list.append(AsyncUrlVerification()) self._init_middleware_list_done = True # ------------------------- @@ -578,8 +614,6 @@

    Module slack_bolt.app.async_app

    def _handle_unmatched_requests( self, req: AsyncBoltRequest, resp: BoltResponse ) -> BoltResponse: - # TODO: provide more info like suggestion of listeners - # e.g., You can handle this type of message with @app.event("app_mention") self._framework_logger.warning(warning_unhandled_request(req)) return resp @@ -1353,7 +1387,7 @@

    Classes

    class AsyncApp -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: Optional[bool] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, verification_token: Optional[str] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, verification_token: Optional[str] = None)

    Bolt App that provides functionalities to register middleware/listeners.

    @@ -1404,6 +1438,25 @@

    Args

    The module offering save/find operations of installation data
    installation_store_bot_only
    Use AsyncInstallationStore#async_find_bot() if True (Default: False)
    +
    request_verification_enabled
    +
    False if you would like to disable the built-in middleware (Default: True). +AsyncRequestVerification is a built-in middleware that verifies the signature in HTTP Mode requests. +Make sure if it's safe enough when you turn a built-in middleware off. +We strongly recommend using RequestVerification for better security. +If you have a proxy that verifies request signature in front of the Bolt app, +it's totally fine to disable RequestVerification to avoid duplication of work. +Don't turn it off just for easiness of development.
    +
    ignoring_self_events_enabled
    +
    False if you would like to disable the built-in middleware (Default: True). +AsyncIgnoringSelfEvents is a built-in middleware that enables Bolt apps to easily skip the events +generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
    +
    url_verification_enabled
    +
    False if you would like to disable the built-in middleware (Default: True). +AsyncUrlVerification is a built-in middleware that handles url_verification requests +that verify the endpoint for Events API in HTTP Mode requests.
    +
    ssl_check_enabled
    +
    bool = False if you would like to disable the built-in middleware (Default: True). +AsyncSslCheck is a built-in middleware that handles ssl_check requests from Slack.
    oauth_settings
    The settings related to Slack app installation flow (OAuth flow)
    oauth_flow
    @@ -1432,10 +1485,15 @@

    Args

    token: Optional[str] = None, client: Optional[AsyncWebClient] = None, # for multi-workspace apps + authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[AsyncInstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, - authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, + # for customizing the built-in middleware + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, # for the OAuth flow oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, @@ -1482,6 +1540,21 @@

    Args

    by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `AsyncInstallationStore#async_find_bot()` if True (Default: False) + request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncRequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests. + Make sure if it's safe enough when you turn a built-in middleware off. + We strongly recommend using RequestVerification for better security. + If you have a proxy that verifies request signature in front of the Bolt app, + it's totally fine to disable RequestVerification to avoid duplication of work. + Don't turn it off just for easiness of development. + ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncIgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events + generated by this app's bot user (this is useful for avoiding code error causing an infinite loop). + url_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncUrlVerification` is a built-in middleware that handles url_verification requests + that verify the endpoint for Events API in HTTP Mode requests. + ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True). + `AsyncSslCheck` is a built-in middleware that handles ssl_check requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.AsyncOAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. @@ -1643,19 +1716,33 @@

    Args

    ) self._init_middleware_list_done = False - self._init_async_middleware_list() + self._init_async_middleware_list( + request_verification_enabled=request_verification_enabled, + ignoring_self_events_enabled=ignoring_self_events_enabled, + ssl_check_enabled=ssl_check_enabled, + url_verification_enabled=url_verification_enabled, + ) self._server: Optional[AsyncSlackAppServer] = None - def _init_async_middleware_list(self): + def _init_async_middleware_list( + self, + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, + ): if self._init_middleware_list_done: return - self._async_middleware_list.append( - AsyncSslCheck(verification_token=self._verification_token) - ) - self._async_middleware_list.append( - AsyncRequestVerification(self._signing_secret) - ) + if ssl_check_enabled is True: + self._async_middleware_list.append( + AsyncSslCheck(verification_token=self._verification_token) + ) + if request_verification_enabled is True: + self._async_middleware_list.append( + AsyncRequestVerification(self._signing_secret) + ) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: self._async_middleware_list.append(AsyncSingleTeamAuthorization()) @@ -1670,8 +1757,10 @@

    Args

    AsyncMultiTeamsAuthorization(authorize=self._async_authorize) ) - self._async_middleware_list.append(AsyncIgnoringSelfEvents()) - self._async_middleware_list.append(AsyncUrlVerification()) + if ignoring_self_events_enabled is True: + self._async_middleware_list.append(AsyncIgnoringSelfEvents()) + if url_verification_enabled is True: + self._async_middleware_list.append(AsyncUrlVerification()) self._init_middleware_list_done = True # ------------------------- @@ -1877,8 +1966,6 @@

    Args

    def _handle_unmatched_requests( self, req: AsyncBoltRequest, resp: BoltResponse ) -> BoltResponse: - # TODO: provide more info like suggestion of listeners - # e.g., You can handle this type of message with @app.event("app_mention") self._framework_logger.warning(warning_unhandled_request(req)) return resp diff --git a/docs/api-docs/slack_bolt/logger/messages.html b/docs/api-docs/slack_bolt/logger/messages.html index 34dbd5365..02976d2a1 100644 --- a/docs/api-docs/slack_bolt/logger/messages.html +++ b/docs/api-docs/slack_bolt/logger/messages.html @@ -27,12 +27,22 @@

    Module slack_bolt.logger.messages

    Expand source code
    import time
    -from typing import Union
    +from typing import Union, Dict, Any, Optional
     
     from slack_sdk.web import SlackResponse
     
     from slack_bolt.request import BoltRequest
    -
    +from slack_bolt.request.payload_utils import (
    +    is_action,
    +    is_event,
    +    is_options,
    +    is_shortcut,
    +    is_slash_command,
    +    is_view,
    +    is_workflow_step_edit,
    +    is_workflow_step_save,
    +    is_workflow_step_execute,
    +)
     
     # -------------------------------
     # Error
    @@ -122,10 +132,185 @@ 

    Module slack_bolt.logger.messages

    ) +_unhandled_request_suggestion_prefix = """ +--- +[Suggestion] You can handle this type of event with the following listener function: +""" + + +def _build_filtered_body(body: Optional[Dict[str, Any]]) -> dict: + if body is None: + return {} + + payload_type = body.get("type") + filtered_body = {"type": payload_type} + + if "view" in body: + view = body["view"] + # view_submission, view_closed, workflow_step_save + filtered_body["view"] = { + "type": view.get("type"), + "callback_id": view.get("callback_id"), + } + + if payload_type == "block_actions": + # Block Kit Interactivity + actions = body.get("actions", []) + if len(actions) > 0 and actions[0] is not None: + filtered_body["block_id"] = actions[0].get("block_id") + filtered_body["action_id"] = actions[0].get("action_id") + if payload_type == "block_suggestion": + # Block Kit - external data source + filtered_body["block_id"] = body.get("block_id") + filtered_body["action_id"] = body.get("action_id") + filtered_body["value"] = body.get("value") + + if payload_type == "event_callback" and "event" in body: + # Events API, workflow_step_execute + event_payload = body.get("event", {}) + filtered_event = {"type": event_payload.get("type")} + if "subtype" in body["event"]: + filtered_event["subtype"] = event_payload.get("subtype") + filtered_body["event"] = filtered_event + + if "command" in body: + # Slash Commands + filtered_body["command"] = body.get("command") + + if payload_type in ["workflow_step_edit", "shortcut", "message_action"]: + # Workflow Steps, Global Shortcuts, Message Shortcuts + filtered_body["callback_id"] = body.get("callback_id") + + if payload_type == "interactive_message": + # Actions in Attachments + filtered_body["callback_id"] = body.get("callback_id") + filtered_body["actions"] = body.get("actions") + + if payload_type == "dialog_suggestion": + # Dialogs - external data source + filtered_body["callback_id"] = body.get("callback_id") + filtered_body["value"] = body.get("value") + if payload_type == "dialog_submission": + # Dialogs - clicking submit button + filtered_body["callback_id"] = body.get("callback_id") + filtered_body["submission"] = body.get("submission") + if payload_type == "dialog_cancellation": + # Dialogs - clicking cancel button + filtered_body["callback_id"] = body.get("callback_id") + + return filtered_body + + +def _build_unhandled_request_suggestion(default_message: str, code_snippet: str): + return f"""{default_message}{_unhandled_request_suggestion_prefix}{code_snippet}""" + + def warning_unhandled_request( # type: ignore req: Union[BoltRequest, "AsyncBoltRequest"], # type: ignore ) -> str: # type: ignore - return f"Unhandled request ({req.body})" + filtered_body = _build_filtered_body(req.body) + default_message = f"Unhandled request ({filtered_body})" + if ( + is_workflow_step_edit(req.body) + or is_workflow_step_save(req.body) + or is_workflow_step_execute(req.body) + ): + # @app.step + callback_id = ( + filtered_body.get("callback_id") + or filtered_body.get("view", {}).get("callback_id") # type: ignore + or "your-callback-id" + ) + return _build_unhandled_request_suggestion( + default_message, + f""" +from slack_bolt.workflows.step import WorkflowStep +ws = WorkflowStep( + callback_id="{callback_id}", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners +app.step(ws) +""", + ) + if is_action(req.body): + # @app.action + action_id_or_callback_id = req.body.get("callback_id") + if req.body.get("type") == "block_actions": + action_id_or_callback_id = req.body.get("actions")[0].get("action_id") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.action("{action_id_or_callback_id}") +def handle_some_action(ack, body, logger): + ack() + logger.info(body) +""", + ) + if is_options(req.body): + # @app.options + constraints = '"action-id"' + if req.body.get("action_id") is not None: + constraints = '"' + req.body.get("action_id") + '"' + elif req.body.get("type") == "dialog_suggestion": + constraints = f"""{{"type": "dialog_suggestion", "callback_id": "{req.body.get('callback_id')}"}}""" + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.options({constraints}) +def handle_some_options(ack): + ack(options=[ ... ]) +""", + ) + if is_shortcut(req.body): + # @app.shortcut + id = req.body.get("action_id") or req.body.get("callback_id") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.shortcut("{id}") +def handle_shortcuts(ack, body, logger): + ack() + logger.info(body) +""", + ) + if is_view(req.body): + # @app.view + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.view("{req.body.get('view', {}).get('callback_id', 'modal-view-id')}") +def handle_view_events(ack, body, logger): + ack() + logger.info(body) +""", + ) + if is_event(req.body): + # @app.event + event_type = req.body.get("event", {}).get("type") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.event("{event_type}") +def handle_{event_type}_events(body, logger): + logger.info(body) +""", + ) + if is_slash_command(req.body): + # @app.command + command = req.body.get("command", "/your-command") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.command("{command}") +def handle_some_command(ack, body, logger): + ack() + logger.info(body) +""", + ) + return default_message def warning_did_not_call_ack(listener_name: str) -> str: @@ -570,7 +755,109 @@

    Functions

    def warning_unhandled_request(  # type: ignore
         req: Union[BoltRequest, "AsyncBoltRequest"],  # type: ignore
     ) -> str:  # type: ignore
    -    return f"Unhandled request ({req.body})"
    + filtered_body = _build_filtered_body(req.body) + default_message = f"Unhandled request ({filtered_body})" + if ( + is_workflow_step_edit(req.body) + or is_workflow_step_save(req.body) + or is_workflow_step_execute(req.body) + ): + # @app.step + callback_id = ( + filtered_body.get("callback_id") + or filtered_body.get("view", {}).get("callback_id") # type: ignore + or "your-callback-id" + ) + return _build_unhandled_request_suggestion( + default_message, + f""" +from slack_bolt.workflows.step import WorkflowStep +ws = WorkflowStep( + callback_id="{callback_id}", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners +app.step(ws) +""", + ) + if is_action(req.body): + # @app.action + action_id_or_callback_id = req.body.get("callback_id") + if req.body.get("type") == "block_actions": + action_id_or_callback_id = req.body.get("actions")[0].get("action_id") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.action("{action_id_or_callback_id}") +def handle_some_action(ack, body, logger): + ack() + logger.info(body) +""", + ) + if is_options(req.body): + # @app.options + constraints = '"action-id"' + if req.body.get("action_id") is not None: + constraints = '"' + req.body.get("action_id") + '"' + elif req.body.get("type") == "dialog_suggestion": + constraints = f"""{{"type": "dialog_suggestion", "callback_id": "{req.body.get('callback_id')}"}}""" + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.options({constraints}) +def handle_some_options(ack): + ack(options=[ ... ]) +""", + ) + if is_shortcut(req.body): + # @app.shortcut + id = req.body.get("action_id") or req.body.get("callback_id") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.shortcut("{id}") +def handle_shortcuts(ack, body, logger): + ack() + logger.info(body) +""", + ) + if is_view(req.body): + # @app.view + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.view("{req.body.get('view', {}).get('callback_id', 'modal-view-id')}") +def handle_view_events(ack, body, logger): + ack() + logger.info(body) +""", + ) + if is_event(req.body): + # @app.event + event_type = req.body.get("event", {}).get("type") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.event("{event_type}") +def handle_{event_type}_events(body, logger): + logger.info(body) +""", + ) + if is_slash_command(req.body): + # @app.command + command = req.body.get("command", "/your-command") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.command("{command}") +def handle_some_command(ack, body, logger): + ack() + logger.info(body) +""", + ) + return default_message
    diff --git a/docs/api-docs/slack_bolt/oauth/async_callback_options.html b/docs/api-docs/slack_bolt/oauth/async_callback_options.html index 668bf63ae..c09e724ae 100644 --- a/docs/api-docs/slack_bolt/oauth/async_callback_options.html +++ b/docs/api-docs/slack_bolt/oauth/async_callback_options.html @@ -119,8 +119,10 @@

    Module slack_bolt.oauth.async_callback_optionsArgs

    state_utils=state_utils, redirect_uri_page_renderer=redirect_uri_page_renderer, ) - self.success = self._success_handler - self.failure = self._failure_handler + # Note that pytype 2021.4.26 misunderstands these assignments. + # Thus, we put "type: ignore" for the following two lines + self.success = self._success_handler # type: ignore + self.failure = self._failure_handler # type: ignore # -------------------------- # Internal methods diff --git a/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html b/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html index d479a3f38..5c3a4a0f7 100644 --- a/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html +++ b/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html @@ -131,12 +131,14 @@

    Module slack_bolt.oauth.async_oauth_settings

    logger: The logger that will be used internally """ # OAuth flow parameters/credentials - self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID") - self.client_secret = client_secret or os.environ.get( - "SLACK_CLIENT_SECRET", None + client_id: Optional[str] = client_id or os.environ.get("SLACK_CLIENT_ID") + client_secret: Optional[str] = client_secret or os.environ.get( + "SLACK_CLIENT_SECRET" ) - if self.client_id is None or self.client_secret is None: + if client_id is None or client_secret is None: raise BoltError("Both client_id and client_secret are required") + self.client_id = client_id + self.client_secret = client_secret self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") if isinstance(self.scopes, str): @@ -336,12 +338,14 @@

    Args

    logger: The logger that will be used internally """ # OAuth flow parameters/credentials - self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID") - self.client_secret = client_secret or os.environ.get( - "SLACK_CLIENT_SECRET", None + client_id: Optional[str] = client_id or os.environ.get("SLACK_CLIENT_ID") + client_secret: Optional[str] = client_secret or os.environ.get( + "SLACK_CLIENT_SECRET" ) - if self.client_id is None or self.client_secret is None: + if client_id is None or client_secret is None: raise BoltError("Both client_id and client_secret are required") + self.client_id = client_id + self.client_secret = client_secret self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") if isinstance(self.scopes, str): diff --git a/docs/api-docs/slack_bolt/oauth/oauth_settings.html b/docs/api-docs/slack_bolt/oauth/oauth_settings.html index b9459f0c7..4624e9f85 100644 --- a/docs/api-docs/slack_bolt/oauth/oauth_settings.html +++ b/docs/api-docs/slack_bolt/oauth/oauth_settings.html @@ -125,12 +125,14 @@

    Module slack_bolt.oauth.oauth_settings

    state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) logger: The logger that will be used internally """ - self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID") - self.client_secret = client_secret or os.environ.get( - "SLACK_CLIENT_SECRET", None + client_id: Optional[str] = client_id or os.environ.get("SLACK_CLIENT_ID") + client_secret: Optional[str] = client_secret or os.environ.get( + "SLACK_CLIENT_SECRET" ) - if self.client_id is None or self.client_secret is None: + if client_id is None or client_secret is None: raise BoltError("Both client_id and client_secret are required") + self.client_id = client_id + self.client_secret = client_secret self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") if isinstance(self.scopes, str): @@ -329,12 +331,14 @@

    Args

    state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) logger: The logger that will be used internally """ - self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID") - self.client_secret = client_secret or os.environ.get( - "SLACK_CLIENT_SECRET", None + client_id: Optional[str] = client_id or os.environ.get("SLACK_CLIENT_ID") + client_secret: Optional[str] = client_secret or os.environ.get( + "SLACK_CLIENT_SECRET" ) - if self.client_id is None or self.client_secret is None: + if client_id is None or client_secret is None: raise BoltError("Both client_id and client_secret are required") + self.client_id = client_id + self.client_secret = client_secret self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") if isinstance(self.scopes, str): diff --git a/docs/api-docs/slack_bolt/request/async_request.html b/docs/api-docs/slack_bolt/request/async_request.html index 11d5fd887..97be298e9 100644 --- a/docs/api-docs/slack_bolt/request/async_request.html +++ b/docs/api-docs/slack_bolt/request/async_request.html @@ -70,9 +70,21 @@

    Module slack_bolt.request.async_request

    context: The context in this request. mode: The mode used for this request. (either "http" or "socket_mode") """ - if mode == "http" and not isinstance(body, str): - raise BoltError(error_message_raw_body_required_in_http_mode()) - self.raw_body = body if mode == "http" else "" + + if mode == "http": + # HTTP Mode + if not isinstance(body, str): + raise BoltError(error_message_raw_body_required_in_http_mode()) + self.raw_body = body if body is not None else "" + else: + # Socket Mode + if body is not None and isinstance(body, str): + self.raw_body = body + else: + # We don't convert the dict value to str + # as doing so does not guarantee to keep the original structure/format. + self.raw_body = "" + self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) @@ -85,7 +97,7 @@

    Module slack_bolt.request.async_request

    self.context = build_async_context( AsyncBoltContext(context if context else {}), self.body ) - self.lazy_only = self.headers.get("x-slack-bolt-lazy-only", [False])[0] + self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0]) self.lazy_function_name = self.headers.get( "x-slack-bolt-lazy-function-name", [None] )[0] @@ -153,9 +165,21 @@

    Args

    context: The context in this request. mode: The mode used for this request. (either "http" or "socket_mode") """ - if mode == "http" and not isinstance(body, str): - raise BoltError(error_message_raw_body_required_in_http_mode()) - self.raw_body = body if mode == "http" else "" + + if mode == "http": + # HTTP Mode + if not isinstance(body, str): + raise BoltError(error_message_raw_body_required_in_http_mode()) + self.raw_body = body if body is not None else "" + else: + # Socket Mode + if body is not None and isinstance(body, str): + self.raw_body = body + else: + # We don't convert the dict value to str + # as doing so does not guarantee to keep the original structure/format. + self.raw_body = "" + self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) @@ -168,7 +192,7 @@

    Args

    self.context = build_async_context( AsyncBoltContext(context if context else {}), self.body ) - self.lazy_only = self.headers.get("x-slack-bolt-lazy-only", [False])[0] + self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0]) self.lazy_function_name = self.headers.get( "x-slack-bolt-lazy-function-name", [None] )[0] diff --git a/docs/api-docs/slack_bolt/request/request.html b/docs/api-docs/slack_bolt/request/request.html index acb5c3176..ce8fd5b42 100644 --- a/docs/api-docs/slack_bolt/request/request.html +++ b/docs/api-docs/slack_bolt/request/request.html @@ -70,9 +70,20 @@

    Module slack_bolt.request.request

    context: The context in this request. mode: The mode used for this request. (either "http" or "socket_mode") """ - if mode == "http" and not isinstance(body, str): - raise BoltError(error_message_raw_body_required_in_http_mode()) - self.raw_body = body if mode == "http" else "" + if mode == "http": + # HTTP Mode + if not isinstance(body, str): + raise BoltError(error_message_raw_body_required_in_http_mode()) + self.raw_body = body if body is not None else "" + else: + # Socket Mode + if body is not None and isinstance(body, str): + self.raw_body = body + else: + # We don't convert the dict value to str + # as doing so does not guarantee to keep the original structure/format. + self.raw_body = "" + self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) @@ -84,7 +95,7 @@

    Module slack_bolt.request.request

    raise BoltError(error_message_unknown_request_body_type()) self.context = build_context(BoltContext(context if context else {}), self.body) - self.lazy_only = self.headers.get("x-slack-bolt-lazy-only", [False])[0] + self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0]) self.lazy_function_name = self.headers.get( "x-slack-bolt-lazy-function-name", [None] )[0] @@ -152,9 +163,20 @@

    Args

    context: The context in this request. mode: The mode used for this request. (either "http" or "socket_mode") """ - if mode == "http" and not isinstance(body, str): - raise BoltError(error_message_raw_body_required_in_http_mode()) - self.raw_body = body if mode == "http" else "" + if mode == "http": + # HTTP Mode + if not isinstance(body, str): + raise BoltError(error_message_raw_body_required_in_http_mode()) + self.raw_body = body if body is not None else "" + else: + # Socket Mode + if body is not None and isinstance(body, str): + self.raw_body = body + else: + # We don't convert the dict value to str + # as doing so does not guarantee to keep the original structure/format. + self.raw_body = "" + self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) @@ -166,7 +188,7 @@

    Args

    raise BoltError(error_message_unknown_request_body_type()) self.context = build_context(BoltContext(context if context else {}), self.body) - self.lazy_only = self.headers.get("x-slack-bolt-lazy-only", [False])[0] + self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0]) self.lazy_function_name = self.headers.get( "x-slack-bolt-lazy-function-name", [None] )[0] diff --git a/docs/api-docs/slack_bolt/util/utils.html b/docs/api-docs/slack_bolt/util/utils.html index 5b9d46594..2a18a57c3 100644 --- a/docs/api-docs/slack_bolt/util/utils.html +++ b/docs/api-docs/slack_bolt/util/utils.html @@ -85,10 +85,19 @@

    Module slack_bolt.util.utils

    else: return "Bolt app is running!" - if development_server: - return "⚡️ Bolt app is running! (development server)" - else: - return "⚡️ Bolt app is running!" + try: + if development_server: + return "⚡️ Bolt app is running! (development server)" + else: + return "⚡️ Bolt app is running!" + except ValueError: + # ValueError is a runtime exception for a given value + # It's a super class of UnicodeEncodeError, which may be raised in the scenario + # see also: https://github.com/slackapi/bolt-python/issues/170 + if development_server: + return "Bolt app is running! (development server)" + else: + return "Bolt app is running!" def get_name_for_callable(func: Callable) -> str: @@ -205,10 +214,19 @@

    Functions

    else: return "Bolt app is running!" - if development_server: - return "⚡️ Bolt app is running! (development server)" - else: - return "⚡️ Bolt app is running!"
    + try: + if development_server: + return "⚡️ Bolt app is running! (development server)" + else: + return "⚡️ Bolt app is running!" + except ValueError: + # ValueError is a runtime exception for a given value + # It's a super class of UnicodeEncodeError, which may be raised in the scenario + # see also: https://github.com/slackapi/bolt-python/issues/170 + if development_server: + return "Bolt app is running! (development server)" + else: + return "Bolt app is running!"
    diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index ef1267173..274497ee7 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.5.0"
    +__version__ = "1.6.0"
    From ace3cb842257bcdb3cd0d3894c4db6bf46af952a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 11 May 2021 05:44:34 +0900 Subject: [PATCH 325/865] Fix #330 Potentially request.body can be None when using a custom adapter (#331) * Fix #330 Potentially request.body can be None when using a custom adapter * Add scenario tests --- slack_bolt/request/async_request.py | 7 +- slack_bolt/request/internals.py | 4 -- slack_bolt/request/request.py | 6 +- tests/scenario_tests/test_app.py | 37 +++++++++- .../scenario_tests_async/test_app_dispatch.py | 72 +++++++++++++++++++ tests/slack_bolt/request/test_request.py | 23 ++++++ tests/slack_bolt_async/request/__init__.py | 0 .../request/test_async_request.py | 21 ++++++ 8 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 tests/scenario_tests_async/test_app_dispatch.py create mode 100644 tests/slack_bolt/request/test_request.py create mode 100644 tests/slack_bolt_async/request/__init__.py create mode 100644 tests/slack_bolt_async/request/test_async_request.py diff --git a/slack_bolt/request/async_request.py b/slack_bolt/request/async_request.py index 97ceab159..ad671ccc2 100644 --- a/slack_bolt/request/async_request.py +++ b/slack_bolt/request/async_request.py @@ -9,7 +9,6 @@ build_normalized_headers, extract_content_type, error_message_raw_body_required_in_http_mode, - error_message_unknown_request_body_type, ) @@ -45,7 +44,7 @@ def __init__( if mode == "http": # HTTP Mode - if not isinstance(body, str): + if body is not None and not isinstance(body, str): raise BoltError(error_message_raw_body_required_in_http_mode()) self.raw_body = body if body is not None else "" else: @@ -60,12 +59,14 @@ def __init__( self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) + if isinstance(body, str): self.body = parse_body(self.raw_body, self.content_type) elif isinstance(body, dict): self.body = body else: - raise BoltError(error_message_unknown_request_body_type()) + self.body = {} + self.context = build_async_context( AsyncBoltContext(context if context else {}), self.body ) diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 4b70df1b3..2fd0c430e 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -183,10 +183,6 @@ def error_message_raw_body_required_in_http_mode() -> str: return "`body` must be a raw string data when running in the HTTP server mode" -def error_message_unknown_request_body_type() -> str: - return "`body` must be either str or dict" - - def debug_multiple_response_urls_detected() -> str: return ( "`response_urls` in the body has multiple URLs in it. " diff --git a/slack_bolt/request/request.py b/slack_bolt/request/request.py index 5d48d7a35..d91e78ba6 100644 --- a/slack_bolt/request/request.py +++ b/slack_bolt/request/request.py @@ -9,7 +9,6 @@ build_context, extract_content_type, error_message_raw_body_required_in_http_mode, - error_message_unknown_request_body_type, ) @@ -44,7 +43,7 @@ def __init__( """ if mode == "http": # HTTP Mode - if not isinstance(body, str): + if body is not None and not isinstance(body, str): raise BoltError(error_message_raw_body_required_in_http_mode()) self.raw_body = body if body is not None else "" else: @@ -59,12 +58,13 @@ def __init__( self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) + if isinstance(body, str): self.body = parse_body(self.raw_body, self.content_type) elif isinstance(body, dict): self.body = body else: - raise BoltError(error_message_unknown_request_body_type()) + self.body = {} self.context = build_context(BoltContext(context if context else {}), self.body) self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0]) diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index 5d91dd577..8d3a4a3db 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -3,7 +3,7 @@ from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore -from slack_bolt import App, Say +from slack_bolt import App, Say, BoltRequest from slack_bolt.authorization import AuthorizeResult from slack_bolt.error import BoltError from slack_bolt.oauth import OAuthFlow @@ -187,3 +187,38 @@ def test_installation_store_conflicts(self): installation_store=store1, ) assert app.installation_store is store1 + + def test_none_body(self): + app = App(signing_secret="valid", client=self.web_client) + + req = BoltRequest(body=None, headers={}, mode="http") + response = app.dispatch(req) + # request verification failure + assert response.status == 401 + assert response.body == '{"error": "invalid request"}' + + req = BoltRequest(body=None, headers={}, mode="socket_mode") + response = app.dispatch(req) + # request verification is skipped for Socket Mode + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + def test_none_body_no_middleware(self): + app = App( + signing_secret="valid", + client=self.web_client, + ssl_check_enabled=False, + ignoring_self_events_enabled=False, + request_verification_enabled=False, + token_verification_enabled=False, + url_verification_enabled=False, + ) + req = BoltRequest(body=None, headers={}, mode="http") + response = app.dispatch(req) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + req = BoltRequest(body=None, headers={}, mode="socket_mode") + response = app.dispatch(req) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' diff --git a/tests/scenario_tests_async/test_app_dispatch.py b/tests/scenario_tests_async/test_app_dispatch.py new file mode 100644 index 000000000..34396a2d1 --- /dev/null +++ b/tests/scenario_tests_async/test_app_dispatch.py @@ -0,0 +1,72 @@ +import asyncio + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +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 TestAsyncAppDispatch: + 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_none_body(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + req = AsyncBoltRequest(body=None, headers={}, mode="http") + response = await app.async_dispatch(req) + # request verification failure + assert response.status == 401 + assert response.body == '{"error": "invalid request"}' + + req = AsyncBoltRequest(body=None, headers={}, mode="socket_mode") + response = await app.async_dispatch(req) + # request verification is skipped for Socket Mode + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @pytest.mark.asyncio + async def test_none_body_no_middleware(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ssl_check_enabled=False, + ignoring_self_events_enabled=False, + request_verification_enabled=False, + # token_verification_enabled=False, + url_verification_enabled=False, + ) + + req = AsyncBoltRequest(body=None, headers={}, mode="http") + response = await app.async_dispatch(req) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + req = AsyncBoltRequest(body=None, headers={}, mode="socket_mode") + response = await app.async_dispatch(req) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' diff --git a/tests/slack_bolt/request/test_request.py b/tests/slack_bolt/request/test_request.py new file mode 100644 index 000000000..b6a81ecca --- /dev/null +++ b/tests/slack_bolt/request/test_request.py @@ -0,0 +1,23 @@ +from slack_bolt.request.request import BoltRequest + + +class TestRequest: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_all_none_inputs_http(self): + req = BoltRequest(body=None, headers=None, query=None, context=None) + assert req is not None + assert req.raw_body == "" + assert req.body == {} + + def test_all_none_inputs_socket_mode(self): + req = BoltRequest( + body=None, headers=None, query=None, context=None, mode="socket_mode" + ) + assert req is not None + assert req.raw_body == "" + assert req.body == {} diff --git a/tests/slack_bolt_async/request/__init__.py b/tests/slack_bolt_async/request/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/request/test_async_request.py b/tests/slack_bolt_async/request/test_async_request.py new file mode 100644 index 000000000..e69a242cf --- /dev/null +++ b/tests/slack_bolt_async/request/test_async_request.py @@ -0,0 +1,21 @@ +import pytest + +from slack_bolt.request.async_request import AsyncBoltRequest + + +class TestAsyncRequest: + @pytest.mark.asyncio + async def test_all_none_values_http(self): + req = AsyncBoltRequest(body=None, headers=None, query=None, context=None) + assert req is not None + assert req.raw_body == "" + assert req.body == {} + + @pytest.mark.asyncio + async def test_all_none_values_socket_mode(self): + req = AsyncBoltRequest( + body=None, headers=None, query=None, context=None, mode="socket_mode" + ) + assert req is not None + assert req.raw_body == "" + assert req.body == {} From 1400f646426464f88967444951332f752234c0fb Mon Sep 17 00:00:00 2001 From: siegerts Date: Tue, 11 May 2021 23:05:55 -0400 Subject: [PATCH 326/865] chore(lazy_listener): fix typo (#338) --- docs/_advanced/lazy_listener.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_advanced/lazy_listener.md b/docs/_advanced/lazy_listener.md index 9ae5f9f81..d556b08f6 100644 --- a/docs/_advanced/lazy_listener.md +++ b/docs/_advanced/lazy_listener.md @@ -92,7 +92,7 @@ def handler(event, context): return slack_handler.handle(event, context) ``` -Please note that the followig IAM permissions would be required for running this example app. +Please note that the following IAM permissions would be required for running this example app. ```json { From c6571179253c6c288ac58468d074d5829e611962 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 12 May 2021 13:55:10 +0900 Subject: [PATCH 327/865] Set Werkzeug major version to 1 for Flask compatibility (#340) * Set Werkzeug major version to 1 for Flask-Sockets compatibility --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index bdd2db1b2..7dec70019 100755 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ "pytest-asyncio<1", # for async "aiohttp>=3,<4", # for async "Flask-Sockets>=0.2,<1", + "Werkzeug<2", # Flask-Sockets is not yet compatible with the major version "black==20.8b1", ] @@ -62,6 +63,7 @@ "falcon>=2,<3", "fastapi<1", "Flask>=1,<2", + "Werkzeug<2", # Flask is not yet compatible with the major version "pyramid>=1,<2", "sanic>=20,<21", "starlette>=0.13,<1", From 6bc0fca14884682032ddc62f555ffe67568b2c69 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 12 May 2021 17:40:56 +0900 Subject: [PATCH 328/865] Add comment about the API Gateway payload format --- slack_bolt/adapter/aws_lambda/handler.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/slack_bolt/adapter/aws_lambda/handler.py b/slack_bolt/adapter/aws_lambda/handler.py index fef64094c..349123783 100644 --- a/slack_bolt/adapter/aws_lambda/handler.py +++ b/slack_bolt/adapter/aws_lambda/handler.py @@ -75,6 +75,12 @@ def handle(self, event, context): def to_bolt_request(event) -> BoltRequest: + """Note that this handler supports only the payload format 2.0. + This means you can use this with HTTP API while REST API is not supported. + + Read https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html + for more details. + """ body = event.get("body", "") if event["isBase64Encoded"]: body = base64.b64decode(body).decode("utf-8") From 51daa120da1d10d0fff3b937099eaa0fca563b96 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 12 May 2021 19:08:59 +0900 Subject: [PATCH 329/865] Update comments about Flask versions in setup.py --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 7dec70019..4a57c98ba 100755 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ "pytest-asyncio<1", # for async "aiohttp>=3,<4", # for async "Flask-Sockets>=0.2,<1", - "Werkzeug<2", # Flask-Sockets is not yet compatible with the major version + "Werkzeug<2", # TODO: support Flask 2.x "black==20.8b1", ] @@ -63,7 +63,7 @@ "falcon>=2,<3", "fastapi<1", "Flask>=1,<2", - "Werkzeug<2", # Flask is not yet compatible with the major version + "Werkzeug<2", # TODO: support Flask 2.x "pyramid>=1,<2", "sanic>=20,<21", "starlette>=0.13,<1", From 51edf84b3fc2efce88782e894c63f080a511d898 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 24 May 2021 15:24:46 +0900 Subject: [PATCH 330/865] Update the Django example (#348) --- examples/django/README.md | 83 +++++++++- examples/django/manage.py | 3 +- .../{slackapp => myslackapp}/__init__.py | 0 examples/django/myslackapp/asgi.py | 16 ++ .../{slackapp => myslackapp}/settings.py | 146 ++++++++++-------- examples/django/myslackapp/urls.py | 42 +++++ .../django/{slackapp => myslackapp}/wsgi.py | 6 +- .../migrations => oauth_app}/__init__.py | 0 examples/django/oauth_app/apps.py | 6 + .../migrations/0001_initial.py | 12 +- .../django/oauth_app/migrations/__init__.py | 0 examples/django/oauth_app/models.py | 69 +++++++++ .../slack_datastores.py} | 143 +---------------- examples/django/oauth_app/slack_listeners.py | 71 +++++++++ examples/django/oauth_app/urls.py | 25 +++ examples/django/requirements.txt | 3 +- examples/django/simple_app/__init__.py | 0 examples/django/simple_app/apps.py | 6 + .../django/simple_app/migrations/__init__.py | 0 examples/django/simple_app/models.py | 3 + examples/django/simple_app/slack_listeners.py | 19 +++ .../{slackapp/views.py => simple_app/urls.py} | 15 +- examples/django/slackapp/apps.py | 5 - examples/django/slackapp/urls.py | 9 -- 24 files changed, 441 insertions(+), 241 deletions(-) rename examples/django/{slackapp => myslackapp}/__init__.py (100%) create mode 100644 examples/django/myslackapp/asgi.py rename examples/django/{slackapp => myslackapp}/settings.py (60%) create mode 100644 examples/django/myslackapp/urls.py rename examples/django/{slackapp => myslackapp}/wsgi.py (57%) rename examples/django/{slackapp/migrations => oauth_app}/__init__.py (100%) create mode 100644 examples/django/oauth_app/apps.py rename examples/django/{slackapp => oauth_app}/migrations/0001_initial.py (93%) create mode 100644 examples/django/oauth_app/migrations/__init__.py create mode 100644 examples/django/oauth_app/models.py rename examples/django/{slackapp/models.py => oauth_app/slack_datastores.py} (51%) create mode 100644 examples/django/oauth_app/slack_listeners.py create mode 100644 examples/django/oauth_app/urls.py create mode 100644 examples/django/simple_app/__init__.py create mode 100644 examples/django/simple_app/apps.py create mode 100644 examples/django/simple_app/migrations/__init__.py create mode 100644 examples/django/simple_app/models.py create mode 100644 examples/django/simple_app/slack_listeners.py rename examples/django/{slackapp/views.py => simple_app/urls.py} (54%) delete mode 100644 examples/django/slackapp/apps.py delete mode 100644 examples/django/slackapp/urls.py diff --git a/examples/django/README.md b/examples/django/README.md index 0b1ac6742..a28039d57 100644 --- a/examples/django/README.md +++ b/examples/django/README.md @@ -1,12 +1,85 @@ -Follow the instructions [here](https://slack.dev/bolt-python/concepts#authenticating-oauth) for configuring OAuth flow supported Slack apps. This example works with the default env variables such as `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_SCOPES`, `SLACK_SIGNING_SECRET`, and so forth. +## Bolt for Python - Django integration example +This example demonstrates how you can use Bolt for Python in your Django application. The project consists of two apps. + +### `simple_app` - Single-workspace App Example + +If you want to run a simple app like the one you've tried in the [Getting Started Guide](https://slack.dev/bolt-python/tutorial/getting-started), this is the right one for you. By default, this Django project runs this application. If you want to switch to OAuth flow supported one, modify `myslackapp/urls.py`. + +To run this app, all you need to do are: + +* Create a new Slack app configuration at https://api.slack.com/apps?new_app=1 +* Go to "OAuth & Permissions" + * Add `app_mentions:read`, `chat:write` in Scopes > Bot Token Scopes +* Go to "Install App" + * Click "Install to Workspace" + * Complete the installation flow + * Copy the "Bot User OAuth Token" value, which starts with `xoxb-` + +You can start your Django application this way: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt + +export SLACK_SIGNING_SECRET=(You can find this value at Settings > Basic Information > App Credentials > Signing Secret) +export SLACK_BOT_TOKEN=(You can find this value at Settings > Install App > Bot User OAuth Token) + +python manage.py migrate +python manage.py runserver 0.0.0.0:3000 ``` + +As you did at [Getting Started Guide](https://slack.dev/bolt-python/tutorial/getting-started), configure ngrok or something similar to serve a public endpoint. Lastly, + +* Go back to the Slack app configuration page +* Go to "Event Subscriptions" + * Turn the feature on + * Set the "Request URL" to `https://{your public domain}/slack/events` +* Go to the Slack workspace you've installed this app +* Invite the app's bot user to a channel +* Mention the bot user in the channel +* You'll see a reply from your app's bot user! + +### `oauth_app` - Multiple-workspace App Example (OAuth flow supported) + +By default, this Django project runs this application. If you want to switch to OAuth flow supported one, modify `myslackapp/urls.py`. + +This example uses SQLite. If you are looking for an example using MySQL, check the `mysql-docker-compose.yml` and the comment in `myslackapp/settings.py`. + + +To run this app, all you need to do are: + +* Create a new Slack app configuration at https://api.slack.com/apps?new_app=1 +* Go to "OAuth & Permissions" + * Add `app_mentions:read`, `chat:write` in Scopes > Bot Token Scopes +* Follow the instructions [here](https://slack.dev/bolt-python/concepts#authenticating-oauth) for configuring OAuth flow supported Slack apps + +You can start your Django application this way: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -U pip pip install -r requirements.txt -export SLACK_CLIENT_ID= -export SLACK_CLIENT_SECRET= -export SLACK_SCOPES=commands,chat:write -export SLACK_SIGNING_SECRET= + +export SLACK_SIGNING_SECRET=(You can find this value at Settings > Basic Information > App Credentials > Signing Secret) +export SLACK_CLIENT_ID=(You can find this value at Settings > Basic Information > App Credentials > Client ID) +export SLACK_CLIENT_SECRET=(You can find this value at Settings > Basic Information > App Credentials > Client Secret) +export SLACK_SCOPES=app_mentions:read,chat:write python manage.py migrate python manage.py runserver 0.0.0.0:3000 ``` + +As you did at [Getting Started Guide](https://slack.dev/bolt-python/tutorial/getting-started), configure ngrok or something similar to serve a public endpoint. Lastly, + +* Go back to the Slack app configuration page +* Go to "Event Subscriptions" + * Turn the feature on + * Set the "Request URL" to `https://{your public domain}/slack/events` +* Visit `https://{your public domain}/slack/install` and complete the installation flow +* Invite the app's bot user to a channel +* Mention the bot user in the channel +* You'll see a reply from your app's bot user! diff --git a/examples/django/manage.py b/examples/django/manage.py index eb1a2c09c..cec2fe4e4 100755 --- a/examples/django/manage.py +++ b/examples/django/manage.py @@ -5,7 +5,8 @@ def main(): - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "slackapp.settings") + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myslackapp.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/examples/django/slackapp/__init__.py b/examples/django/myslackapp/__init__.py similarity index 100% rename from examples/django/slackapp/__init__.py rename to examples/django/myslackapp/__init__.py diff --git a/examples/django/myslackapp/asgi.py b/examples/django/myslackapp/asgi.py new file mode 100644 index 000000000..814c38df4 --- /dev/null +++ b/examples/django/myslackapp/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for myslackapp project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myslackapp.settings") + +application = get_asgi_application() diff --git a/examples/django/slackapp/settings.py b/examples/django/myslackapp/settings.py similarity index 60% rename from examples/django/slackapp/settings.py rename to examples/django/myslackapp/settings.py index 56bdc50e9..a99c91188 100644 --- a/examples/django/slackapp/settings.py +++ b/examples/django/myslackapp/settings.py @@ -1,64 +1,35 @@ """ -Django settings for slackapp project. +Django settings for myslackapp project. -Generated by 'django-admin startproject' using Django 3.0.8. +Generated by 'django-admin startproject' using Django 3.2.3. For more information on this file, see -https://docs.djangoproject.com/en/3.0/topics/settings/ +https://docs.djangoproject.com/en/3.2/topics/settings/ For the full list of settings and their values, see -https://docs.djangoproject.com/en/3.0/ref/settings/ +https://docs.djangoproject.com/en/3.2/ref/settings/ """ - import os +from pathlib import Path -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "handlers": { - "console": { - "class": "logging.StreamHandler", - }, - }, - "root": { - "handlers": ["console"], - "level": "DEBUG", - }, - "loggers": { - "django": { - "handlers": ["console"], - "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), - "propagate": False, - }, - "django.db": { - "level": "DEBUG", - }, - "slack_bolt": { - "handlers": ["console"], - "level": "DEBUG", - "propagate": False, - }, - }, -} +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ +# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ -# SECURITY WARNING: keep the secret key used in production secret! # TODO: CHANGE THIS IF YOU REUSE THIS APP -SECRET_KEY = ( - "This is just a example. You should not expose your secret key in real apps" -) +SECRET_KEY = "This is just a example. You should not expose your secret key in real apps" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True + # ALLOWED_HOSTS = [] ALLOWED_HOSTS = ["*"] + # Application definition INSTALLED_APPS = [ @@ -68,7 +39,8 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "slackapp.apps.SlackAppConfig", + "simple_app.apps.SimpleAppConfig", + "oauth_app.apps.OauthAppConfig", ] MIDDLEWARE = [ @@ -81,7 +53,7 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = "slackapp.urls" +ROOT_URLCONF = "myslackapp.urls" TEMPLATES = [ { @@ -99,35 +71,44 @@ }, ] -WSGI_APPLICATION = "slackapp.wsgi.application" +WSGI_APPLICATION = "myslackapp.wsgi.application" + # Database -# https://docs.djangoproject.com/en/3.0/ref/settings/#databases +# https://docs.djangoproject.com/en/3.2/ref/settings/#databases DATABASES = { - # python manage.py migrate - # python manage.py runserver 0.0.0.0:3000 + # You can initialize your local database by the following steps: + # + # python manage.py migrate + # python manage.py runserver 0.0.0.0:3000 + # + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } + # If you want to use MySQL quickly, the following steps work for you + # + # docker-compose -f mysql-docker-compose.yml up --build + # pip install mysqlclient + # python manage.py migrate + # python manage.py runserver 0.0.0.0:3000 + # + # And then, enable the following setting instead: + # # "default": { - # "ENGINE": "django.db.backends.sqlite3", - # "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + # "ENGINE": "django.db.backends.mysql", + # "NAME": "slackapp", + # "USER": "app", + # "PASSWORD": "password", + # "HOST": "127.0.0.1", + # "PORT": 33306, # }, - - # docker-compose -f mysql-docker-compose.yml up --build - # pip install mysqlclient - # python manage.py migrate - # python manage.py runserver 0.0.0.0:3000 - "default": { - "ENGINE": "django.db.backends.mysql", - "NAME": "slackapp", - "USER": "app", - "PASSWORD": "password", - "HOST": "127.0.0.1", - "PORT": 33306, - }, } + # Password validation -# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators +# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { @@ -144,8 +125,9 @@ }, ] + # Internationalization -# https://docs.djangoproject.com/en/3.0/topics/i18n/ +# https://docs.djangoproject.com/en/3.2/topics/i18n/ LANGUAGE_CODE = "en-us" @@ -157,7 +139,43 @@ USE_TZ = True + # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/3.0/howto/static-files/ +# https://docs.djangoproject.com/en/3.2/howto/static-files/ STATIC_URL = "/static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), + "propagate": False, + }, + "django.db": { + "level": "DEBUG", + }, + "slack_bolt": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + }, +} diff --git a/examples/django/myslackapp/urls.py b/examples/django/myslackapp/urls.py new file mode 100644 index 000000000..45e6cab60 --- /dev/null +++ b/examples/django/myslackapp/urls.py @@ -0,0 +1,42 @@ +"""myslackapp URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +is_simple_app = True + +if is_simple_app: + # A simple app that works only for a single Slack workspace + # (prerequisites) + # export SLACK_BOT_TOKEN= + # export SLACK_SIGNING_SECRET= + from simple_app.urls import slack_events_handler + + urlpatterns = [path("slack/events", slack_events_handler)] +else: + # OAuth flow supported app + # (prerequisites) + # export SLACK_CLIENT_ID= + # export SLACK_CLIENT_SECRET= + # export SLACK_SIGNING_SECRET= + # export SLACK_SCOPES=app_mentions:read + from oauth_app.urls import slack_events_handler, slack_oauth_handler + + urlpatterns = [ + path("slack/events", slack_events_handler, name="handle"), + path("slack/install", slack_oauth_handler, name="install"), + path("slack/oauth_redirect", slack_oauth_handler, name="oauth_redirect"), + ] diff --git a/examples/django/slackapp/wsgi.py b/examples/django/myslackapp/wsgi.py similarity index 57% rename from examples/django/slackapp/wsgi.py rename to examples/django/myslackapp/wsgi.py index efefedc30..4443e81cb 100644 --- a/examples/django/slackapp/wsgi.py +++ b/examples/django/myslackapp/wsgi.py @@ -1,16 +1,16 @@ """ -WSGI config for slackapp project. +WSGI config for myslackapp project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see -https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ +https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "slackapp.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myslackapp.settings") application = get_wsgi_application() diff --git a/examples/django/slackapp/migrations/__init__.py b/examples/django/oauth_app/__init__.py similarity index 100% rename from examples/django/slackapp/migrations/__init__.py rename to examples/django/oauth_app/__init__.py diff --git a/examples/django/oauth_app/apps.py b/examples/django/oauth_app/apps.py new file mode 100644 index 000000000..34a1577d4 --- /dev/null +++ b/examples/django/oauth_app/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OauthAppConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "oauth_app" diff --git a/examples/django/slackapp/migrations/0001_initial.py b/examples/django/oauth_app/migrations/0001_initial.py similarity index 93% rename from examples/django/slackapp/migrations/0001_initial.py rename to examples/django/oauth_app/migrations/0001_initial.py index ebc013537..d5e3113c6 100644 --- a/examples/django/slackapp/migrations/0001_initial.py +++ b/examples/django/oauth_app/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1.7 on 2021-04-02 05:53 +# Generated by Django 3.2.3 on 2021-05-24 05:50 from django.db import migrations, models @@ -15,7 +15,7 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.AutoField( + models.BigAutoField( auto_created=True, primary_key=True, serialize=False, @@ -41,7 +41,7 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.AutoField( + models.BigAutoField( auto_created=True, primary_key=True, serialize=False, @@ -76,7 +76,7 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.AutoField( + models.BigAutoField( auto_created=True, primary_key=True, serialize=False, @@ -97,14 +97,14 @@ class Migration(migrations.Migration): "user_id", "installed_at", ], - name="slackapp_sl_client__9b0d3f_idx", + name="oauth_app_s_client__f32bc0_idx", ), ), migrations.AddIndex( model_name="slackbot", index=models.Index( fields=["client_id", "enterprise_id", "team_id", "installed_at"], - name="slackapp_sl_client__d220d6_idx", + name="oauth_app_s_client__fe2514_idx", ), ), ] diff --git a/examples/django/oauth_app/migrations/__init__.py b/examples/django/oauth_app/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/django/oauth_app/models.py b/examples/django/oauth_app/models.py new file mode 100644 index 000000000..72fa342f5 --- /dev/null +++ b/examples/django/oauth_app/models.py @@ -0,0 +1,69 @@ +# ---------------------- +# Database tables +# ---------------------- + +from django.db import models + + +class SlackBot(models.Model): + client_id = models.CharField(null=False, max_length=32) + app_id = models.CharField(null=False, max_length=32) + enterprise_id = models.CharField(null=True, max_length=32) + enterprise_name = models.TextField(null=True) + team_id = models.CharField(null=True, max_length=32) + team_name = models.TextField(null=True) + bot_token = models.TextField(null=True) + bot_id = models.CharField(null=True, max_length=32) + bot_user_id = models.CharField(null=True, max_length=32) + bot_scopes = models.TextField(null=True) + is_enterprise_install = models.BooleanField(null=True) + installed_at = models.DateTimeField(null=False) + + class Meta: + indexes = [ + models.Index( + fields=["client_id", "enterprise_id", "team_id", "installed_at"] + ), + ] + + +class SlackInstallation(models.Model): + client_id = models.CharField(null=False, max_length=32) + app_id = models.CharField(null=False, max_length=32) + enterprise_id = models.CharField(null=True, max_length=32) + enterprise_name = models.TextField(null=True) + enterprise_url = models.TextField(null=True) + team_id = models.CharField(null=True, max_length=32) + team_name = models.TextField(null=True) + bot_token = models.TextField(null=True) + bot_id = models.CharField(null=True, max_length=32) + bot_user_id = models.TextField(null=True) + bot_scopes = models.TextField(null=True) + user_id = models.CharField(null=False, max_length=32) + user_token = models.TextField(null=True) + user_scopes = models.TextField(null=True) + incoming_webhook_url = models.TextField(null=True) + incoming_webhook_channel = models.TextField(null=True) + incoming_webhook_channel_id = models.TextField(null=True) + incoming_webhook_configuration_url = models.TextField(null=True) + is_enterprise_install = models.BooleanField(null=True) + token_type = models.CharField(null=True, max_length=32) + installed_at = models.DateTimeField(null=False) + + class Meta: + indexes = [ + models.Index( + fields=[ + "client_id", + "enterprise_id", + "team_id", + "user_id", + "installed_at", + ] + ), + ] + + +class SlackOAuthState(models.Model): + state = models.CharField(null=False, max_length=64) + expire_at = models.DateTimeField(null=False) diff --git a/examples/django/slackapp/models.py b/examples/django/oauth_app/slack_datastores.py similarity index 51% rename from examples/django/slackapp/models.py rename to examples/django/oauth_app/slack_datastores.py index 0db1e9b6d..5c1c3dfe9 100644 --- a/examples/django/slackapp/models.py +++ b/examples/django/oauth_app/slack_datastores.py @@ -1,79 +1,7 @@ -# ---------------------- -# Database tables -# ---------------------- - -from django.db import models - - -class SlackBot(models.Model): - client_id = models.CharField(null=False, max_length=32) - app_id = models.CharField(null=False, max_length=32) - enterprise_id = models.CharField(null=True, max_length=32) - enterprise_name = models.TextField(null=True) - team_id = models.CharField(null=True, max_length=32) - team_name = models.TextField(null=True) - bot_token = models.TextField(null=True) - bot_id = models.CharField(null=True, max_length=32) - bot_user_id = models.CharField(null=True, max_length=32) - bot_scopes = models.TextField(null=True) - is_enterprise_install = models.BooleanField(null=True) - installed_at = models.DateTimeField(null=False) - - class Meta: - indexes = [ - models.Index( - fields=["client_id", "enterprise_id", "team_id", "installed_at"] - ), - ] - - -class SlackInstallation(models.Model): - client_id = models.CharField(null=False, max_length=32) - app_id = models.CharField(null=False, max_length=32) - enterprise_id = models.CharField(null=True, max_length=32) - enterprise_name = models.TextField(null=True) - enterprise_url = models.TextField(null=True) - team_id = models.CharField(null=True, max_length=32) - team_name = models.TextField(null=True) - bot_token = models.TextField(null=True) - bot_id = models.CharField(null=True, max_length=32) - bot_user_id = models.TextField(null=True) - bot_scopes = models.TextField(null=True) - user_id = models.CharField(null=False, max_length=32) - user_token = models.TextField(null=True) - user_scopes = models.TextField(null=True) - incoming_webhook_url = models.TextField(null=True) - incoming_webhook_channel = models.TextField(null=True) - incoming_webhook_channel_id = models.TextField(null=True) - incoming_webhook_configuration_url = models.TextField(null=True) - is_enterprise_install = models.BooleanField(null=True) - token_type = models.CharField(null=True, max_length=32) - installed_at = models.DateTimeField(null=False) - - class Meta: - indexes = [ - models.Index( - fields=[ - "client_id", - "enterprise_id", - "team_id", - "user_id", - "installed_at", - ] - ), - ] - - -class SlackOAuthState(models.Model): - state = models.CharField(null=False, max_length=64) - expire_at = models.DateTimeField(null=False) - - # ---------------------- # Bolt store implementations # ---------------------- - from logging import Logger from typing import Optional from uuid import uuid4 @@ -82,7 +10,8 @@ class SlackOAuthState(models.Model): from django.utils.timezone import is_naive, make_aware from slack_sdk.oauth import InstallationStore, OAuthStateStore from slack_sdk.oauth.installation_store import Bot, Installation -from slack_sdk.webhook import WebhookClient + +from .models import SlackBot, SlackInstallation, SlackOAuthState class DjangoInstallationStore(InstallationStore): @@ -220,71 +149,3 @@ def consume(self, state: str) -> bool: row.delete() return True return False - - -# ---------------------- -# Slack App -# ---------------------- - -import logging -import os -from slack_bolt import App, BoltContext -from slack_bolt.oauth.oauth_settings import OAuthSettings - -logger = logging.getLogger(__name__) -client_id, client_secret, signing_secret, scopes = ( - os.environ["SLACK_CLIENT_ID"], - os.environ["SLACK_CLIENT_SECRET"], - os.environ["SLACK_SIGNING_SECRET"], - os.environ.get("SLACK_SCOPES", "commands").split(","), -) - -app = App( - signing_secret=signing_secret, - installation_store=DjangoInstallationStore( - client_id=client_id, - logger=logger, - ), - oauth_settings=OAuthSettings( - client_id=client_id, - client_secret=client_secret, - scopes=scopes, - state_store=DjangoOAuthStateStore( - expiration_seconds=120, - logger=logger, - ), - ), -) - - -def event_test(body, say, context: BoltContext, logger): - logger.info(body) - say(":wave: What's up?") - - found_rows = list( - SlackInstallation.objects.filter(enterprise_id=context.enterprise_id) - .filter(team_id=context.team_id) - .filter(incoming_webhook_url__isnull=False) - .order_by(F("installed_at").desc())[:1] - ) - if len(found_rows) > 0: - webhook_url = found_rows[0].incoming_webhook_url - logger.info(f"webhook_url: {webhook_url}") - client = WebhookClient(webhook_url) - client.send(text=":wave: This is a message posted using Incoming Webhook!") - - -# lazy listener example -def noop(): - pass - - -app.event("app_mention")( - ack=event_test, - lazy=[noop], -) - - -@app.command("/hello-django-app") -def command(ack): - ack(":wave: Hello from a Django app :smile:") diff --git a/examples/django/oauth_app/slack_listeners.py b/examples/django/oauth_app/slack_listeners.py new file mode 100644 index 000000000..86a05fdb2 --- /dev/null +++ b/examples/django/oauth_app/slack_listeners.py @@ -0,0 +1,71 @@ +import logging +import os + +from slack_bolt import App, BoltContext +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.webhook import WebhookClient + +# Database models +from .models import SlackInstallation +from django.db.models import F + +# Bolt datastore implementations +from .slack_datastores import DjangoInstallationStore, DjangoOAuthStateStore + +logger = logging.getLogger(__name__) +client_id, client_secret, signing_secret, scopes = ( + os.environ["SLACK_CLIENT_ID"], + os.environ["SLACK_CLIENT_SECRET"], + os.environ["SLACK_SIGNING_SECRET"], + os.environ.get("SLACK_SCOPES", "commands").split(","), +) + +app = App( + signing_secret=signing_secret, + installation_store=DjangoInstallationStore( + client_id=client_id, + logger=logger, + ), + oauth_settings=OAuthSettings( + client_id=client_id, + client_secret=client_secret, + scopes=scopes, + state_store=DjangoOAuthStateStore( + expiration_seconds=120, + logger=logger, + ), + ), +) + + +def event_test(body, say, context: BoltContext, logger): + logger.info(body) + say(":wave: What's up?") + + found_rows = list( + SlackInstallation.objects.filter(enterprise_id=context.enterprise_id) + .filter(team_id=context.team_id) + .filter(incoming_webhook_url__isnull=False) + .order_by(F("installed_at").desc())[:1] + ) + if len(found_rows) > 0: + webhook_url = found_rows[0].incoming_webhook_url + logger.info(f"webhook_url: {webhook_url}") + client = WebhookClient(webhook_url) + client.send(text=":wave: This is a message posted using Incoming Webhook!") + + +# lazy listener example +def noop(): + pass + + +app.event("app_mention")( + ack=event_test, + lazy=[noop], +) + + +@app.command("/hello-django-app") +def command(ack): + ack(":wave: Hello from a Django app :smile:") diff --git a/examples/django/oauth_app/urls.py b/examples/django/oauth_app/urls.py new file mode 100644 index 000000000..7802c7e54 --- /dev/null +++ b/examples/django/oauth_app/urls.py @@ -0,0 +1,25 @@ +from django.urls import path + +from django.http import HttpRequest +from django.views.decorators.csrf import csrf_exempt + +from slack_bolt.adapter.django import SlackRequestHandler +from .slack_listeners import app + +handler = SlackRequestHandler(app=app) + + +@csrf_exempt +def slack_events_handler(request: HttpRequest): + return handler.handle(request) + + +def slack_oauth_handler(request: HttpRequest): + return handler.handle(request) + + +urlpatterns = [ + path("slack/events", slack_events_handler, name="handle"), + path("slack/install", slack_oauth_handler, name="install"), + path("slack/oauth_redirect", slack_oauth_handler, name="oauth_redirect"), +] diff --git a/examples/django/requirements.txt b/examples/django/requirements.txt index ec9338fd2..bee4f4a98 100644 --- a/examples/django/requirements.txt +++ b/examples/django/requirements.txt @@ -1 +1,2 @@ -Django>=3,<4 \ No newline at end of file +Django>=3.2,<4 +slack-bolt>=1.6,<2 diff --git a/examples/django/simple_app/__init__.py b/examples/django/simple_app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/django/simple_app/apps.py b/examples/django/simple_app/apps.py new file mode 100644 index 000000000..d80d269e9 --- /dev/null +++ b/examples/django/simple_app/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SimpleAppConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "simple_app" diff --git a/examples/django/simple_app/migrations/__init__.py b/examples/django/simple_app/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/django/simple_app/models.py b/examples/django/simple_app/models.py new file mode 100644 index 000000000..71a836239 --- /dev/null +++ b/examples/django/simple_app/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/examples/django/simple_app/slack_listeners.py b/examples/django/simple_app/slack_listeners.py new file mode 100644 index 000000000..c066ead1c --- /dev/null +++ b/examples/django/simple_app/slack_listeners.py @@ -0,0 +1,19 @@ +import logging +import os + +from slack_bolt import App + +logger = logging.getLogger(__name__) + +app = App( + token=os.environ["SLACK_BOT_TOKEN"], + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + # disable eagerly verifying the given SLACK_BOT_TOKEN value + token_verification_enabled=False, +) + + +@app.event("app_mention") +def handle_app_mentions(logger, event, say): + logger.info(event) + say(f"Hi there, <@{event['user']}>") diff --git a/examples/django/slackapp/views.py b/examples/django/simple_app/urls.py similarity index 54% rename from examples/django/slackapp/views.py rename to examples/django/simple_app/urls.py index 5ff1c8a29..dfdd1291e 100644 --- a/examples/django/slackapp/views.py +++ b/examples/django/simple_app/urls.py @@ -1,16 +1,19 @@ -from django.http import HttpRequest -from django.views.decorators.csrf import csrf_exempt +from django.urls import path from slack_bolt.adapter.django import SlackRequestHandler -from .models import app +from .slack_listeners import app handler = SlackRequestHandler(app=app) +from django.http import HttpRequest +from django.views.decorators.csrf import csrf_exempt + @csrf_exempt -def events(request: HttpRequest): +def slack_events_handler(request: HttpRequest): return handler.handle(request) -def oauth(request: HttpRequest): - return handler.handle(request) +urlpatterns = [ + path("slack/events", slack_events_handler, name="slack_events"), +] diff --git a/examples/django/slackapp/apps.py b/examples/django/slackapp/apps.py deleted file mode 100644 index aa8cafd43..000000000 --- a/examples/django/slackapp/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class SlackAppConfig(AppConfig): - name = "slackapp" diff --git a/examples/django/slackapp/urls.py b/examples/django/slackapp/urls.py deleted file mode 100644 index f875bc822..000000000 --- a/examples/django/slackapp/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path - -from . import views - -urlpatterns = [ - path("slack/events", views.events, name="handle"), - path("slack/install", views.oauth, name="install"), - path("slack/oauth_redirect", views.oauth, name="oauth_redirect"), -] From a289c73de9e318e7e9861faa78f7e5a220d00a29 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 27 May 2021 13:33:05 +0900 Subject: [PATCH 331/865] Fix a minor error in document --- docs/_advanced/context.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_advanced/context.md b/docs/_advanced/context.md index cc29d239c..beacfbcf7 100644 --- a/docs/_advanced/context.md +++ b/docs/_advanced/context.md @@ -12,7 +12,7 @@ All listeners have access to a `context` dictionary, which can be used to enrich ```python -# Listener middleware to fetch tasks from external system using userId +# Listener middleware to fetch tasks from external system using user ID def fetch_tasks(context, event, next): user = event["user"] try: @@ -68,4 +68,4 @@ def show_tasks(event, client, context): "blocks": context["blocks"] } ) -``` \ No newline at end of file +``` From f84bc94266b0327ca96956ab6ef58e99da1abae1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 27 May 2021 13:46:00 +0900 Subject: [PATCH 332/865] Fix an error in document --- docs/_advanced/custom_adapters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_advanced/custom_adapters.md b/docs/_advanced/custom_adapters.md index f06af77c1..f89a1ee46 100644 --- a/docs/_advanced/custom_adapters.md +++ b/docs/_advanced/custom_adapters.md @@ -20,7 +20,7 @@ order: 1 | `headers: Dict[str, Union[str, List[str]]]` | Request headers | No | | `context: BoltContext` | Any context for the request | No | -`BoltRequest` will return [an instance of `BoltResponse`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/response/response.py) from the Bolt app. +Your adapter will return [an instance of `BoltResponse`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/response/response.py) from the Bolt app. For more in-depth examples of custom adapters, look at the implementations of the [built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter). From 24e64c9a7def6d19abc0cf241beab200ce3bd6b0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 28 May 2021 14:24:16 +0900 Subject: [PATCH 333/865] Add Japanese translation of Advanced concepts pages (#354) --- docs/_advanced/errors.md | 2 +- docs/_advanced/ja_adapters.md | 45 +++++++++ docs/_advanced/ja_async.md | 81 +++++++++++++++++ docs/_advanced/ja_authorization.md | 72 +++++++++++++++ docs/_advanced/ja_context.md | 72 +++++++++++++++ docs/_advanced/ja_custom_adapters.md | 72 +++++++++++++++ docs/_advanced/ja_errors.md | 19 ++++ docs/_advanced/ja_global_middleware.md | 34 +++++++ docs/_advanced/ja_lazy_listener.md | 111 +++++++++++++++++++++++ docs/_advanced/ja_listener_middleware.md | 24 +++++ docs/_advanced/ja_logging.md | 28 ++++++ docs/_advanced/logging.md | 2 +- docs/_config.yml | 2 +- 13 files changed, 561 insertions(+), 3 deletions(-) create mode 100644 docs/_advanced/ja_adapters.md create mode 100644 docs/_advanced/ja_async.md create mode 100644 docs/_advanced/ja_authorization.md create mode 100644 docs/_advanced/ja_context.md create mode 100644 docs/_advanced/ja_custom_adapters.md create mode 100644 docs/_advanced/ja_errors.md create mode 100644 docs/_advanced/ja_global_middleware.md create mode 100644 docs/_advanced/ja_lazy_listener.md create mode 100644 docs/_advanced/ja_listener_middleware.md create mode 100644 docs/_advanced/ja_logging.md diff --git a/docs/_advanced/errors.md b/docs/_advanced/errors.md index 7899485ce..d045331db 100644 --- a/docs/_advanced/errors.md +++ b/docs/_advanced/errors.md @@ -6,7 +6,7 @@ order: 3 ---
    -If an error occurs in a listener, you can handle it directly using a `try`/`except` block. Errors associated with your app will be of type `BoltError`. Errors associated with calling Slack APIs will be of type `SlackApiError`. +If an error occurs in a listener, you can handle it directly using a try/except block. Errors associated with your app will be of type `BoltError`. Errors associated with calling Slack APIs will be of type `SlackApiError`. By default, the global error handler will log all non-handled exceptions to the console. To handle global errors yourself, you can attach a global error handler to your app using the `app.error(fn)` function.
    diff --git a/docs/_advanced/ja_adapters.md b/docs/_advanced/ja_adapters.md new file mode 100644 index 000000000..025ff6116 --- /dev/null +++ b/docs/_advanced/ja_adapters.md @@ -0,0 +1,45 @@ +--- +title: アダプター +lang: ja-jp +slug: adapters +order: 0 +--- + +
    +アダプターは Slack から届く受信イベントの受付とパーズを担当し、それらのイベントを `BoltRequest` の形式に変換して Bolt アプリに引き渡します。 + +デフォルトでは、Bolt の組み込みの `HTTPServer` アダプターが使われます。このアダプターは、ローカルで開発するのには問題がありませんが、本番環境での利用は推奨されていません。Bolt for Python には複数の組み込みのアダプターが用意されており、必要に応じてインポートしてアプリで使用することができます。組み込みのアダプターは Flask、Django、Starlette をはじめとする様々な人気の Python フレームワークをサポートしています。これらのアダプターは、あなたが選択した本番環境で利用可能な Webサーバーとともに利用することができます。 + +アダプターを使用するには、任意のフレームワークを使ってアプリを開発し、そのコードに対応するアダプターをインポートします。その後、アダプターのインスタンスを初期化して、受信イベントの受付とパーズを行う関数を呼び出します。 + +すべてのアダプターの一覧と、設定や使い方のサンプルは、リポジトリの `examples` フォルダをご覧ください。 +
    + +```python +from slack_bolt import App +app = App( + signing_secret=os.environ.get("SIGNING_SECRET"), + token=os.environ.get("SLACK_BOT_TOKEN") +) + +# ここには Flask 固有の記述はありません +# App はフレームワークやランタイムに一切依存しません +@app.command("/hello-bolt") +def hello(body, ack): + ack(f"Hi <@{body['user_id']}>!") + +# Flask アプリを初期化します +from flask import Flask, request +flask_app = Flask(__name__) + +# SlackRequestHandler は WSGI のリクエストを Bolt のインターフェイスに合った形に変換します +# Bolt レスポンスからの WSGI レスポンスの作成も行います +from slack_bolt.adapter.flask import SlackRequestHandler +handler = SlackRequestHandler(app) + +# Flask アプリへのルートを登録します +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + # handler はアプリのディスパッチメソッドを実行します + return handler.handle(request) +``` diff --git a/docs/_advanced/ja_async.md b/docs/_advanced/ja_async.md new file mode 100644 index 000000000..aac6a1568 --- /dev/null +++ b/docs/_advanced/ja_async.md @@ -0,0 +1,81 @@ +--- +title: Async(asyncio)の使用 +lang: ja-jp +slug: async +order: 2 +--- + +
    +非同期バージョンの Bolt を使用する場合は、`App` の代わりに `AsyncApp` インスタンスをインポートして初期化します。`AsyncApp` では AIOHTTP を使って API リクエストを行うため、`aiohttp` をインストールする必要があります(`requirements.txt` に追記するか、`pip install aiohttp` を実行します)。 + +非同期バージョンのプロジェクトのサンプルは、リポジトリの `examples` フォルダにあります。 +
    + +```python +# aiohttp のインストールが必要です +from slack_bolt.async_app import AsyncApp +app = AsyncApp() + +@app.event("app_mention") +async def handle_mentions(event, client, say): # 非同期関数 + api_response = await client.reactions_add( + channel=event["channel"], + timestamp=event["ts"], + name="eyes", + ) + await say("What's up?") + +if __name__ == "__main__": + app.start(3000) +``` + +
    + +

    他のフレームワークを使用する

    +
    + +
    + +`AsyncApp#start()` では内部的に [`AIOHTTP`](https://docs.aiohttp.org/) のWebサーバーが実装されています。必要に応じて、受信リクエストの処理に `AIOHTTP` 以外のフレームワークを使用することができます。 + +この例では [Sanic](https://sanicframework.org/) を使用しています。すべてのアダプターのリストについては、[`adapter` フォルダ](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) を参照してください。 + +以下のコマンドを実行すると、必要なパッケージをインストールして、Sanic サーバーをポート 3000 で起動します。 + +```bash +# 必要なパッケージをインストールします +pip install slack_bolt sanic uvicorn +# ソースファイルを async_app.py として保存します +uvicorn async_app:api --reload --port 3000 --log-level debug +``` +
    + +```python +from slack_bolt.async_app import AsyncApp +app = AsyncApp() + +# ここには Sanic に固有の記述はありません +# AsyncApp はフレームワークやランタイムに依存しません +@app.event("app_mention") +async def handle_app_mentions(say): + await say("What's up?") + +import os +from sanic import Sanic +from sanic.request import Request +from slack_bolt.adapter.sanic import AsyncSlackRequestHandler + +# App のインスタンスから Sanic 用のアダプターを作成します +app_handler = AsyncSlackRequestHandler(app) +# Sanic アプリを作成します +api = Sanic(name="awesome-slack-app") + +@api.post("/slack/events") +async def endpoint(req: Request): + # app_handler では内部的にアプリのディスパッチメソッドが実行されます + return await app_handler.handle(req) + +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) +``` +
    diff --git a/docs/_advanced/ja_authorization.md b/docs/_advanced/ja_authorization.md new file mode 100644 index 000000000..c329f87c5 --- /dev/null +++ b/docs/_advanced/ja_authorization.md @@ -0,0 +1,72 @@ +--- +title: 認可(Authorization) +lang: ja-jp +slug: authorization +order: 5 +--- + +
    +認可(Authorization)は、Slack からの受信イベントを処理するにあたって、どのようなSlack +クレデンシャル (ボットトークンなど) を使用可能にするかを決定するプロセスです。 + +単一のワークスペースにインストールされるアプリでは、`token` パラメーターを使って `App` のコンストラクターにボットトークンを渡すという、シンプルな方法が使えます。それに対して、複数のワークスペースにインストールされるアプリでは、次の 2 つの方法のいずれかを使用する必要があります。簡単なのは、組み込みの OAuth サポートを使用する方法です。OAuth サポートは、OAuth フロー用のURLのセットアップとstateの検証を行います。詳細は「[OAuth を使った認証](#authenticating-oauth)」セクションを参照してください。 + +よりカスタマイズできる方法として、`App` をインスタンス化する関数に`authorize` パラメーターを指定する方法があります。`authorize` 関数から返される [`AuthorizeResult` のインスタンス](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/authorization/authorize_result.py)には、どのユーザーがどこで発生させたイベントかを示す情報が含まれます。 + +`AuthorizeResult` には、いくつか特定のプロパティを指定する必要があり、いずれも `str` 型です。 + + +- **`bot_token`**(xoxb)*または* **`user_token`**(xoxp): どちらか一方が**必須**です。ほとんどのアプリでは、デフォルトの `bot_token` を使用すればよいでしょう。トークンを渡すことで、`say()` などの組み込みの関数を機能させることができます。 +- **`bot_user_id`** および **`bot_id`** : `bot_token` を使用する場合に指定します。 +- **`enterprise_id`** および **`team_id`** : アプリに届いたイベントから見つけることができます。 +- **`user_id`** : `user_token` を使用する場合に必須です。 +
    + +```python +import os +from slack_bolt import App +# AuthorizeResult クラスをインポートします +from slack_bolt.authorization import AuthorizeResult + +# これはあくまでサンプル例です(ユーザートークンがないことを想定しています) +# 実際にはセキュアな DB に認可情報を保存してください +installations = [ + { + "enterprise_id":"E1234A12AB", + "team_id":"T12345", + "bot_token": "xoxb-123abc", + "bot_id":"B1251", + "bot_user_id":"U12385" + }, + { + "team_id":"T77712", + "bot_token": "xoxb-102anc", + "bot_id":"B5910", + "bot_user_id":"U1239", + "enterprise_id":"E1234A12AB" + } +] + +def authorize(enterprise_id, team_id, logger): + # トークンを取得するためのあなたのロジックをここに記述します + for team in installations: + # 一部のチームは enterprise_id を持たない場合があります + is_valid_enterprise = True if (("enterprise_id" not in team) or (enterprise_id == team["enterprise_id"])) else False + if ((is_valid_enterprise == True) and (team["team_id"] == team_id)): + # AuthorizeResult のインスタンスを返します + # bot_id と bot_user_id を保存していない場合、bot_token を使って `from_auth_test_response` を呼び出すと、自動的に取得できます + return AuthorizeResult( + enterprise_id=enterprise_id, + team_id=team_id, + bot_token=team["bot_token"], + bot_id=team["bot_id"], + bot_user_id=team["bot_user_id"] + ) + + logger.error("No authorization information was found") + +app = App( + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + authorize=authorize +) +``` diff --git a/docs/_advanced/ja_context.md b/docs/_advanced/ja_context.md new file mode 100644 index 000000000..1053cce6a --- /dev/null +++ b/docs/_advanced/ja_context.md @@ -0,0 +1,72 @@ +--- +title: コンテキストの追加 +lang: ja-jp +slug: context +order: 7 +--- + +
    +すべてのリスナーは `context` ディクショナリにアクセスできます。リスナーはこれを使ってイベントの付加情報を得ることができます。受信イベントに含まれる `user_id`、`team_id`、`channel_id`、`enterprise_id` などの情報は、Bolt によって自動的に設定されます。 + +`context` は単純なディクショナリで、変更を直接加えることもできます。 +
    + +```python +# ユーザーID を使って外部のシステムからタスクを取得するリスナーミドルウェア +def fetch_tasks(context, event, next): + user = event["user"] + try: + # get_tasks は、ユーザー ID に対応するタスクのリストを DB から取得します + user_tasks = db.get_tasks(user) + tasks = user_tasks + except Exception: + # タスクが見つからなかった場合 get_tasks() は例外を投げます + tasks = [] + finally: + # ユーザーのタスクを context に設定します + context["tasks"] = tasks + next() + +# section のブロックのリストを作成するリスナーミドルウェア +def create_sections(context, next): + task_blocks = [] + # 先ほどのミドルウェアを使って context に追加した各タスクについて、処理を繰り返します + for task in context["tasks"]: + task_blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{task['title']}* +{task['body']}" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text":"See task" + }, + "url": task["url"], + } + } + ) + # ブロックのリストを context に設定します + context["blocks"] = task_blocks + next() + +# ユーザーがアプリのホームを開くのをリッスンします +# fetch_tasks ミドルウェアを含めます +@app.event( + event = "app_home_opened", + middleware = [fetch_tasks, create_sections] +) +def show_tasks(event, client, context): + # ユーザーのホームタブにビューを表示します + client.views_publish( + user_id=event["user"], + view={ + "type": "home", + "blocks": context["blocks"] + } + ) +``` \ No newline at end of file diff --git a/docs/_advanced/ja_custom_adapters.md b/docs/_advanced/ja_custom_adapters.md new file mode 100644 index 000000000..88c6c54b1 --- /dev/null +++ b/docs/_advanced/ja_custom_adapters.md @@ -0,0 +1,72 @@ +--- +title: カスタムのアダプター +lang: ja-jp +slug: custom-adapters +order: 1 +--- + +
    +[アダプター](#adapters)はフレキシブルで、あなたが使用したいフレームワークに合わせた調整も可能です。アダプターでは、次の 2 つの要素が必須となっています。 + +- `__init__(app:App)` : コンストラクター。Bolt の `App` のインスタンスを受け取り、保持します。 +- `handle(req:Request)` : Slack からの受信リクエストを受け取り、解析を行う関数。通常は `handle()` という名前です。リクエストを [`BoltRequest`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/request/request.py) のインスタンスに合った形にして、保持している Bolt アプリに引き渡します。 + +`BoltRequest` のインスタンスの作成では、以下の 4 種類のパラメーターを指定できます。 + +| パラメーター | 説明 | 必須 | +|-----------|-------------|-----------| +| `body: str` | そのままのリクエストボディ | **Yes** | +| `query: any` | クエリストリング | No | +| `headers:Dict[str, Union[str, List[str]]]` | リクエストヘッダー | No | +| `context:BoltContext` | リクエストのコンテキスト情報 | No | + +アダプターは、Bolt アプリからの [`BoltResponse` のインスタンス](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/response/response.py)を返します。 + +カスタムのアダプターに関連した詳しいサンプルについては、[組み込みのアダプター](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter)の実装を参考にしてください。 +
    + +```python +# Flask で必要なパッケージをインポートします +from flask import Request, Response, make_response + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + +# この例は Flask アダプターを簡略化したものです +# もう少し詳しい完全版のサンプルは、adapter フォルダをご覧ください +# github.com/slackapi/bolt-python/blob/main/slack_bolt/adapter/flask/handler.py + +# HTTP リクエストを取り込み、標準の BoltRequest に変換します +def to_bolt_request(req:Request) -> BoltRequest: + return BoltRequest( + body=req.get_data(as_text=True), + query=req.query_string.decode("utf-8"), + headers=req.headers, + ) + +# BoltResponse を取り込み、標準の Flask レスポンスに変換します +def to_flask_response(bolt_resp:BoltResponse) -> Response: + resp:Response = make_response(bolt_resp.body, bolt_resp.status) + for k, values in bolt_resp.headers.items(): + for v in values: + resp.headers.add_header(k, v) + return resp + +# アプリからインスタンス化します +# Flask アプリを受け取ります +class SlackRequestHandler: + def __init__(self, app:App): + self.app = app + + # Slack からリクエストが届いたときに + # Flask アプリの handle() を呼び出します + def handle(self, req:Request) -> Response: + # この例では OAuth に関する部分は扱いません + if req.method == "POST": + # Bolt へのリクエストをディスパッチし、処理とルーティングを行います + bolt_resp:BoltResponse = self.app.dispatch(to_bolt_request(req)) + return to_flask_response(bolt_resp) + + return make_response("Not Found", 404) +``` diff --git a/docs/_advanced/ja_errors.md b/docs/_advanced/ja_errors.md new file mode 100644 index 000000000..e0d9c61b0 --- /dev/null +++ b/docs/_advanced/ja_errors.md @@ -0,0 +1,19 @@ +--- +title: エラーの処理 +lang: ja-jp +slug: errors +order: 3 +--- + +
    +リスナー内でエラーが発生した場合に try/except ブロックを使用して直接エラーを処理することができます。アプリに関連するエラーは、`BoltError` 型です。Slack API の呼び出しに関連するエラーは、`SlackApiError` 型となります。 + +デフォルトでは、すべての処理されなかった例外のログはグローバルのエラーハンドラーによってコンソールに出力されます。グローバルのエラーを開発者自身で処理するには、`app.error(fn)` 関数を使ってグローバルのエラーハンドラーをアプリに設定します。 +
    + +```python +@app.error +def custom_error_handler(error, body, logger): + logger.exception(f"Error: {error}") + logger.info(f"Request body: {body}") +``` \ No newline at end of file diff --git a/docs/_advanced/ja_global_middleware.md b/docs/_advanced/ja_global_middleware.md new file mode 100644 index 000000000..9d81812c9 --- /dev/null +++ b/docs/_advanced/ja_global_middleware.md @@ -0,0 +1,34 @@ +--- +title: グローバルミドルウェア +lang: ja-jp +slug: global-middleware +order: 6 +--- + +
    +グローバルミドルウェアは、すべての受信イベントに対して、リスナーミドルウェアが呼ばれる前に実行されるものです。ミドルウェア関数を `app.use()` に渡すことで、アプリにはグローバルミドルウェアをいくつでも追加できます。ミドルウェア関数で受け取れる引数はリスナー関数と同じものに加えて`next()` 関数があります。 + +グローバルミドルウェアでもリスナーミドルウェアでも、次のミドルウェアに実行チェーンの制御をリレーするために、`next()` を呼び出す必要があります。 +
    + +```python +@app.use +def auth_abc(client, context, logger, payload, next): + slack_user_id = payload["user"] + help_channel_id = "C12345" + + try: + # Slack のユーザー ID を使って外部のシステムでユーザーを検索します + user = abc.lookup_by_id(slack_user_id) + # 結果を context に保存します + context["user"] = user + except Exception: + client.chat_postEphemeral( + channel=payload["channel"], + user=slack_user_id, + text=f"Sorry <@{slack_user_id}>, you aren't registered in ABC or there was an error with authentication.Please post in <#{help_channel_id}> for assistance" + ) + + # 次のミドルウェアに実行権を渡します + next() +``` diff --git a/docs/_advanced/ja_lazy_listener.md b/docs/_advanced/ja_lazy_listener.md new file mode 100644 index 000000000..a3cbe8fec --- /dev/null +++ b/docs/_advanced/ja_lazy_listener.md @@ -0,0 +1,111 @@ +--- +title: Lazy リスナー(FaaS) +lang: ja-jp +slug: lazy-listeners +order: 9 +--- + +
    +⚠️ Lazy リスナー関数は、FaaS 環境への Bolt for Python アプリのデプロイを容易にする、ベータ版の機能です。開発中の機能であるため、Bolt for Python の API は変更される可能性があります。 + +通常、リスナー関数では最初の手順として `ack()` を呼び出します。`ack()` を呼び出すことで、アプリがイベントを受け取り、適切な時間内(3 秒間)に処理する予定であることが Slack に伝えられます。 + +しかし、FaaS 環境や類似のランタイムで実行されるアプリでは、HTTP レスポンスを返したあとにスレッドやプロセスの実行を続けることができないため、同じパターンに従うことはできません。代わりに、`process_before_response` フラグを `True` に設定します。この設定により、`ack()` の呼び出しとイベントの処理を安全に行うリスナーを作成することができます。しかし、3 秒以内にすべての処理を完了させる必要があることは変わりません。イベント APIに応答するリスナーでは`ack()` メソッドの呼び出しを必要としませんが、この設定では処理を 3 秒以内に完了させる必要があります。 + +Lazy リスナーは、この問題を解決するためのソリューションです。Lazy リスナーは、デコレーターとして動作するものではなく、次の 2 つのキーワード引数を指定することにより動作するものです。 * `ack:Callable` : `ack()` の呼び出しを行います。 * `lazy:List[Callable]` : イベントに関係する、時間のかかるプロセスの処理を担当します。Lazy 関数からは `ack()` にアクセスできません。 +
    + +```python +def respond_to_slack_within_3_seconds(body, ack): + text = body.get("text") + if text is None or len(text) == 0: + ack(f":x:Usage: /start-process (description here)") + else: + ack(f"Accepted! (task: {body['text']})") + +import time +def run_long_process(respond, body): + time.sleep(5) # 3 秒より長い時間を指定します + respond(f"Completed! (task: {body['text']})") + +app.command("/start-process")( + # この場合でも ack() は 3 秒以内に呼ばれます + ack=respond_to_slack_within_3_seconds, + # Lazy 関数がイベントの処理を担当します + lazy=[run_long_process] +) +``` + +
    + +

    AWS Lambda を使用した例

    +
    + +
    +このサンプルは、[AWS Lambda](https://aws.amazon.com/lambda/) にコードをデプロイします。[`examples` フォルダ](https://github.com/slackapi/bolt-python/tree/main/examples/aws_lambda)にはほかにもサンプルが用意されています。 + +```bash +pip install slack_bolt +# ソースコードを main.py として保存します +# config.yaml を設定してハンドラーを `handler: main.handler` で参照できるようにします + +# https://pypi.org/project/python-lambda/ +pip install python-lambda + +# config.yml を適切に設定します +# lazy リスナーの実行には lambda:InvokeFunction と lambda:GetFunction が必要です +export SLACK_SIGNING_SECRET=*** +export SLACK_BOT_TOKEN=xoxb-*** +echo 'slack_bolt' > requirements.txt +lambda deploy --config-file config.yaml --requirements requirements.txt +``` +
    + +```python +from slack_bolt import App +from slack_bolt.adapter.aws_lambda import SlackRequestHandler + +# FaaS で実行するときは process_before_response を True にする必要があります +app = App(process_before_response=True) + +def respond_to_slack_within_3_seconds(body, ack): + text = body.get("text") + if text is None or len(text) == 0: + ack(":x: Usage: /start-process (description here)") + else: + ack(f"Accepted! (task: {body['text']})") + +import time +def run_long_process(respond, body): + time.sleep(5) # 3 秒より長い時間を指定します + respond(f"Completed! (task: {body['text']})") + +app.command("/start-process")( + ack=respond_to_slack_within_3_seconds, # `ack()` の呼び出しを担当します + lazy=[run_long_process] # `ack()` の呼び出しはできません。複数の関数を持たせることができます。 +) + +def handler(event, context): + slack_handler = SlackRequestHandler(app=app) + return slack_handler.handle(event, context) +``` + +このサンプルアプリを実行するには、以下の IAM 権限が必要になります。 + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction", + "lambda:GetFunction" + ], + "Resource": "*" + } + ] +} +``` +
    diff --git a/docs/_advanced/ja_listener_middleware.md b/docs/_advanced/ja_listener_middleware.md new file mode 100644 index 000000000..0bb3f7d77 --- /dev/null +++ b/docs/_advanced/ja_listener_middleware.md @@ -0,0 +1,24 @@ +--- +title: リスナーミドルウェア +lang: ja-jp +slug: listener-middleware +order: 5 +--- + +
    +リスナーミドルウェアは、それを渡したリスナーでのみ実行されるミドルウェアです。リスナーには、`middleware` パラメーターを使ってミドルウェア関数をいくつでも渡すことができます。このパラメーターには、1 つまたは複数のミドルウェア関数からなるリストを指定します。 +
    + +```python +# "bot_message" サブタイプのメッセージを抽出するリスナーミドルウェア +def no_bot_messages(message, next): + subtype = message.get("subtype") + if subtype != "bot_message": + next() + +# このリスナーは人間によって送信されたメッセージのみを受け取ります +@app.event(event="message", middleware=[no_bot_messages]) +def log_message(logger, event): + logger.info(f"(MSG) User: {event['user']} +Message: {event['text']}") +``` diff --git a/docs/_advanced/ja_logging.md b/docs/_advanced/ja_logging.md new file mode 100644 index 000000000..91a3f4085 --- /dev/null +++ b/docs/_advanced/ja_logging.md @@ -0,0 +1,28 @@ +--- +title: ロギング +lang: ja-jp +slug: logging +order: 4 +--- + +
    +デフォルトでは、アプリからのログ情報は、既定の出力先に出力されます。`logging` モジュールをインポートすれば、`basicConfig()` の `level` パラメーターでrootのログレベルを変更することができます。指定できるログレベルは、重要度の低い方から `debug`、`info`、`warning`、`error`、および `critical` です。 + +グローバルのコンテキストとは別に、指定のログレベルに応じて単一のメッセージをログ出力することもできます。Bolt では [Python 標準の logging モジュール](https://docs.python.org/3/library/logging.html)が使われているため、このモジュールが持つすべての機能を利用できます。 +
    + +```python +import logging + +# グローバルのコンテキストの logger です +# logging をインポートする必要があります +logging.basicConfig(level=logging.DEBUG) + +@app.event("app_mention") +def handle_mention(body, say, logger): + user = body["event"]["user"] + # 単一の logger の呼び出しです + # グローバルの logger がリスナーに渡されています + logger.debug(body) + say(f"{user} mentioned your app") +``` diff --git a/docs/_advanced/logging.md b/docs/_advanced/logging.md index f36161153..9ee44f5fa 100644 --- a/docs/_advanced/logging.md +++ b/docs/_advanced/logging.md @@ -6,7 +6,7 @@ order: 4 ---
    -By default, Bolt will log information from your app to the output destination. After you've imported the logging module, you can customize the root log level by passing the `level` parameter to `basicConfig()`. The available log levels in order of least to most severe are `debug`, `info`, `warning`, `error`, and `critical`. +By default, Bolt will log information from your app to the output destination. After you've imported the `logging` module, you can customize the root log level by passing the `level` parameter to `basicConfig()`. The available log levels in order of least to most severe are `debug`, `info`, `warning`, `error`, and `critical`. Outside of a global context, you can also log a single message corresponding to a specific level. Because Bolt uses Python’s [standard logging module](https://docs.python.org/3/library/logging.html), you can use any its features.
    diff --git a/docs/_config.yml b/docs/_config.yml index 083fa7c1d..954982049 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -38,7 +38,7 @@ t: ja-jp: basic: 基本的な概念 steps: ワークフローステップ -# advanced: 応用コンセプト + advanced: 応用コンセプト start: Bolt 入門ガイド # contribute: 貢献 From 5c7399c6b24ad9d28678e9b8589e8a6126f1a1d8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 28 May 2021 20:14:00 +0900 Subject: [PATCH 334/865] Upgrade black code formatter to veresion 21.5b1 --- examples/django/myslackapp/settings.py | 4 +++- setup.py | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/examples/django/myslackapp/settings.py b/examples/django/myslackapp/settings.py index a99c91188..3cae54402 100644 --- a/examples/django/myslackapp/settings.py +++ b/examples/django/myslackapp/settings.py @@ -20,7 +20,9 @@ # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ # TODO: CHANGE THIS IF YOU REUSE THIS APP -SECRET_KEY = "This is just a example. You should not expose your secret key in real apps" +SECRET_KEY = ( + "This is just a example. You should not expose your secret key in real apps" +) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True diff --git a/setup.py b/setup.py index 4a57c98ba..fc6c4f5d5 100755 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ "aiohttp>=3,<4", # for async "Flask-Sockets>=0.2,<1", "Werkzeug<2", # TODO: support Flask 2.x - "black==20.8b1", + "black==21.5b1", ] setuptools.setup( @@ -32,10 +32,17 @@ long_description_content_type="text/markdown", url="https://github.com/slackapi/bolt-python", packages=setuptools.find_packages( - exclude=["examples", "integration_tests", "tests", "tests.*",] + exclude=[ + "examples", + "integration_tests", + "tests", + "tests.*", + ] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.5.0,<4",], + install_requires=[ + "slack_sdk>=3.5.0,<4", + ], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", @@ -53,7 +60,7 @@ # used only under src/slack_bolt/adapter "boto3<=2", # TODO: Upgrade to v2 - "moto<2", # For AWS tests + "moto<2", # For AWS tests "bottle>=0.12,<1", "boddle>=0.2,<0.3", # For Bottle app tests "chalice>=1.22.4,<2", @@ -73,7 +80,7 @@ "uvicorn<1", "gunicorn>=20,<21", # Socket Mode 3rd party implementation - "websocket_client>=0.57,<1" + "websocket_client>=0.57,<1", ], # pip install -e ".[testing]" "testing": test_dependencies, From bdba69a292f626cb1efc3c1f8fbdf48804b97665 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 2 Jun 2021 06:19:21 +0900 Subject: [PATCH 335/865] Fix #358 Add more links to listener argument guide in document pages (#360) --- docs/_advanced/global_middleware.md | 3 +++ docs/_advanced/ja_global_middleware.md | 3 +++ docs/_advanced/ja_listener_middleware.md | 3 +++ docs/_advanced/listener_middleware.md | 3 +++ docs/_basic/acknowledging_events.md | 3 +++ docs/_basic/ja_acknowledging_events.md | 3 +++ docs/_basic/ja_listening_actions.md | 3 +++ docs/_basic/ja_listening_events.md | 3 +++ docs/_basic/ja_listening_messages.md | 4 +++- docs/_basic/ja_listening_modals.md | 3 +++ docs/_basic/ja_listening_responding_commands.md | 3 +++ docs/_basic/ja_listening_responding_options.md | 3 +++ docs/_basic/ja_listening_responding_shortcuts.md | 4 +++- docs/_basic/ja_opening_modals.md | 3 +++ docs/_basic/ja_publishing_views.md | 4 +++- docs/_basic/ja_responding_actions.md | 4 +++- docs/_basic/ja_sending_messages.md | 3 +++ docs/_basic/ja_updating_pushing_modals.md | 4 ++++ docs/_basic/ja_web_api.md | 3 +++ docs/_basic/listening_actions.md | 3 +++ docs/_basic/listening_events.md | 3 +++ docs/_basic/listening_messages.md | 3 +++ docs/_basic/listening_modals.md | 3 +++ docs/_basic/listening_responding_commands.md | 3 +++ docs/_basic/listening_responding_options.md | 3 +++ docs/_basic/listening_responding_shortcuts.md | 3 +++ docs/_basic/opening_modals.md | 3 +++ docs/_basic/publishing_views.md | 4 +++- docs/_basic/responding_actions.md | 3 +++ docs/_basic/sending_messages.md | 3 +++ docs/_basic/updating_pushing_modals.md | 3 +++ docs/_basic/web_api.md | 3 +++ docs/_steps/adding_editing_workflow_step.md | 3 +++ docs/_steps/creating_workflow_step.md | 3 +++ docs/_steps/executing_workflow_steps.md | 5 ++++- docs/_steps/ja_adding_editing_workflow_step.md | 3 +++ docs/_steps/ja_creating_workflow_step.md | 3 +++ docs/_steps/ja_executing_workflow_steps.md | 5 ++++- docs/_steps/ja_saving_workflow_step.md | 5 ++++- docs/_steps/saving_workflow_step.md | 7 +++++-- docs/_tutorials/getting_started.md | 2 ++ docs/_tutorials/ja_getting_started.md | 2 ++ docs/api-docs/slack_bolt/kwargs_injection/index.html | 2 +- docs/assets/style.css | 4 ++++ examples/getting_started/app.py | 2 ++ 45 files changed, 137 insertions(+), 11 deletions(-) diff --git a/docs/_advanced/global_middleware.md b/docs/_advanced/global_middleware.md index efebea050..63703fffb 100644 --- a/docs/_advanced/global_middleware.md +++ b/docs/_advanced/global_middleware.md @@ -11,6 +11,8 @@ Global middleware is run for all incoming events, before any listener middleware Both global and listener middleware must call `next()` to pass control of the execution chain to the next middleware. +
    +Refer to the module document to learn the available listener arguments. ```python @app.use def auth_acme(client, context, logger, payload, next): @@ -32,3 +34,4 @@ def auth_acme(client, context, logger, payload, next): # Pass control to the next middleware next() ``` +
    diff --git a/docs/_advanced/ja_global_middleware.md b/docs/_advanced/ja_global_middleware.md index 9d81812c9..3588e4fcb 100644 --- a/docs/_advanced/ja_global_middleware.md +++ b/docs/_advanced/ja_global_middleware.md @@ -11,6 +11,8 @@ order: 6 グローバルミドルウェアでもリスナーミドルウェアでも、次のミドルウェアに実行チェーンの制御をリレーするために、`next()` を呼び出す必要があります。 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python @app.use def auth_abc(client, context, logger, payload, next): @@ -32,3 +34,4 @@ def auth_abc(client, context, logger, payload, next): # 次のミドルウェアに実行権を渡します next() ``` +
    \ No newline at end of file diff --git a/docs/_advanced/ja_listener_middleware.md b/docs/_advanced/ja_listener_middleware.md index 0bb3f7d77..fa4ea4dd7 100644 --- a/docs/_advanced/ja_listener_middleware.md +++ b/docs/_advanced/ja_listener_middleware.md @@ -9,6 +9,8 @@ order: 5 リスナーミドルウェアは、それを渡したリスナーでのみ実行されるミドルウェアです。リスナーには、`middleware` パラメーターを使ってミドルウェア関数をいくつでも渡すことができます。このパラメーターには、1 つまたは複数のミドルウェア関数からなるリストを指定します。 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # "bot_message" サブタイプのメッセージを抽出するリスナーミドルウェア def no_bot_messages(message, next): @@ -22,3 +24,4 @@ def log_message(logger, event): logger.info(f"(MSG) User: {event['user']} Message: {event['text']}") ``` +
    \ No newline at end of file diff --git a/docs/_advanced/listener_middleware.md b/docs/_advanced/listener_middleware.md index 73c3d3590..476be70d6 100644 --- a/docs/_advanced/listener_middleware.md +++ b/docs/_advanced/listener_middleware.md @@ -9,6 +9,8 @@ order: 5 Listener middleware is only run for the listener in which it's passed. You can pass any number of middleware functions to the listener using the `middleware` parameter, which must be a list that contains one to many middleware functions. +
    +Refer to the module document to learn the available listener arguments. ```python # Listener middleware which filters out messages with "bot_message" subtype def no_bot_messages(message, next): @@ -21,3 +23,4 @@ def no_bot_messages(message, next): def log_message(logger, event): logger.info(f"(MSG) User: {event['user']}\nMessage: {event['text']}") ``` +
    \ No newline at end of file diff --git a/docs/_basic/acknowledging_events.md b/docs/_basic/acknowledging_events.md index b341ca673..fd7b0d534 100644 --- a/docs/_basic/acknowledging_events.md +++ b/docs/_basic/acknowledging_events.md @@ -15,6 +15,8 @@ We recommend calling `ack()` right away before sending a new message or fetching +
    +Refer to the module document to learn the available listener arguments. ```python # Example of responding to an external_select options request @app.options("menu_selection") @@ -31,3 +33,4 @@ def show_menu_options(ack): ] ack(options=options) ``` +
    \ No newline at end of file diff --git a/docs/_basic/ja_acknowledging_events.md b/docs/_basic/ja_acknowledging_events.md index 996500347..51331a3ce 100644 --- a/docs/_basic/ja_acknowledging_events.md +++ b/docs/_basic/ja_acknowledging_events.md @@ -15,6 +15,8 @@ order: 7 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # 外部データを使用する選択メニューオプションに応答するサンプル @app.options("menu_selection") @@ -31,3 +33,4 @@ def show_menu_options(ack): ] ack(options=options) ``` +
    \ No newline at end of file diff --git a/docs/_basic/ja_listening_actions.md b/docs/_basic/ja_listening_actions.md index fe4407809..6456b56ab 100644 --- a/docs/_basic/ja_listening_actions.md +++ b/docs/_basic/ja_listening_actions.md @@ -14,6 +14,8 @@ Bolt アプリは `action` メソッドを用いて、ボタンのクリック +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # 'approve_button' という action_id のブロックエレメントがトリガーされるたびに、このリスナーが呼び出させれる @app.action("approve_button") @@ -21,6 +23,7 @@ def update_message(ack): ack() # アクションへの反応としてメッセージを更新 ``` +
    diff --git a/docs/_basic/ja_listening_events.md b/docs/_basic/ja_listening_events.md index b3ac00773..b491af6fe 100644 --- a/docs/_basic/ja_listening_events.md +++ b/docs/_basic/ja_listening_events.md @@ -13,6 +13,8 @@ order: 3 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # ユーザーがワークスペースに参加した際に、自己紹介を促すメッセージを指定のチャンネルに送信 @app.event("team_join") @@ -22,6 +24,7 @@ def ask_for_introduction(event, say): text = f"Welcome to the team, <@{user_id}>! 🎉 You can introduce yourself in this channel." say(text=text, channel=welcome_channel_id) ``` +
    diff --git a/docs/_basic/ja_listening_messages.md b/docs/_basic/ja_listening_messages.md index d0ca9eea8..12df49493 100644 --- a/docs/_basic/ja_listening_messages.md +++ b/docs/_basic/ja_listening_messages.md @@ -10,9 +10,10 @@ order: 1 [あなたのアプリがアクセス権限を持つ](https://api.slack.com/messaging/retrieving#permissions)メッセージの投稿イベントをリッスンするには `message()` メソッドを利用します。このメソッドは `type` が `message` ではないイベントを処理対象から除外します。 `message()` の引数には `str` 型または `re.Pattern` オブジェクトを指定できます。この条件のパターンに一致しないメッセージは除外されます。 - +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # '👋' が含まれるすべてのメッセージに一致 @app.message(":wave:") @@ -20,6 +21,7 @@ def say_hello(message, say): user = message['user'] say(f"Hi there, <@{user}>!") ``` +
    diff --git a/docs/_basic/ja_listening_modals.md b/docs/_basic/ja_listening_modals.md index c5986d395..16935948d 100644 --- a/docs/_basic/ja_listening_modals.md +++ b/docs/_basic/ja_listening_modals.md @@ -15,6 +15,8 @@ order: 12 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # view_submission イベントを処理 @app.view("view_1") @@ -46,3 +48,4 @@ def handle_submission(ack, body, client, view): # ユーザーにメッセージを送信 client.chat_postMessage(channel=user, text=msg) ``` +
    \ No newline at end of file diff --git a/docs/_basic/ja_listening_responding_commands.md b/docs/_basic/ja_listening_responding_commands.md index da782ce6e..3895c4d79 100644 --- a/docs/_basic/ja_listening_responding_commands.md +++ b/docs/_basic/ja_listening_responding_commands.md @@ -17,6 +17,8 @@ order: 9 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # echoコマンドは受け取ったコマンドをそのまま返す @app.command("/echo") @@ -25,3 +27,4 @@ def repeat_text(ack, say, command): ack() say(f"{command['text']}") ``` +
    diff --git a/docs/_basic/ja_listening_responding_options.md b/docs/_basic/ja_listening_responding_options.md index 8bd7eb705..e7951eeb3 100644 --- a/docs/_basic/ja_listening_responding_options.md +++ b/docs/_basic/ja_listening_responding_options.md @@ -16,6 +16,8 @@ order: 14 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # 外部データを使用する選択メニューオプションに応答するサンプル例 @app.options("external_action") @@ -32,3 +34,4 @@ def show_options(ack): ] ack(options=options) ``` +
    \ No newline at end of file diff --git a/docs/_basic/ja_listening_responding_shortcuts.md b/docs/_basic/ja_listening_responding_shortcuts.md index 30af2a1aa..03fbb7fb8 100644 --- a/docs/_basic/ja_listening_responding_shortcuts.md +++ b/docs/_basic/ja_listening_responding_shortcuts.md @@ -21,8 +21,9 @@ order: 8 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python - # 'open_modal' という callback_id のショートカットをリッスン @app.shortcut("open_modal") def open_modal(ack, shortcut, client): @@ -57,6 +58,7 @@ def open_modal(ack, shortcut, client): } ) ``` +
    diff --git a/docs/_basic/ja_opening_modals.md b/docs/_basic/ja_opening_modals.md index 65aa100ce..8a8333147 100644 --- a/docs/_basic/ja_opening_modals.md +++ b/docs/_basic/ja_opening_modals.md @@ -15,6 +15,8 @@ order: 10 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # ショートカットの呼び出しをリッスン @app.shortcut("open_modal") @@ -56,3 +58,4 @@ def open_modal(ack, body, client): } ) ``` +
    \ No newline at end of file diff --git a/docs/_basic/ja_publishing_views.md b/docs/_basic/ja_publishing_views.md index 62278161b..280643482 100644 --- a/docs/_basic/ja_publishing_views.md +++ b/docs/_basic/ja_publishing_views.md @@ -11,6 +11,8 @@ order: 13 `app_home_opened` イベントをサブスクライブすると、ユーザーが App Home を開く操作をリッスンできます。 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python @app.event("app_home_opened") def update_home_tab(client, event, logger): @@ -40,7 +42,7 @@ def update_home_tab(client, event, logger): ] } ) - except Exception as e: logger.error(f"Error publishing home tab: {e}") ``` +
    \ No newline at end of file diff --git a/docs/_basic/ja_responding_actions.md b/docs/_basic/ja_responding_actions.md index e8ddd9b30..0068073e6 100644 --- a/docs/_basic/ja_responding_actions.md +++ b/docs/_basic/ja_responding_actions.md @@ -10,9 +10,10 @@ order: 6 アクションへの応答には、主に 2 つの方法があります。1 つ目の最も一般的なやり方は `say()` を使用する方法です。そのイベントが発生した会話(チャンネルや DM)にメッセージを返します。 2 つ目は、`respond()` を使用する方法です。これは、アクションに関連づけられた `response_url` を使ったメッセージ送信を行うためのユーティリティです。 - +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # 'approve_button' という action_id のインタラクティブコンポーネントがトリガーされると、このリスナーが呼ばれる @app.action("approve_button") @@ -21,6 +22,7 @@ def approve_request(ack, say): ack() say("Request approved 👍") ``` +
    diff --git a/docs/_basic/ja_sending_messages.md b/docs/_basic/ja_sending_messages.md index 40499555e..afa30d8d1 100644 --- a/docs/_basic/ja_sending_messages.md +++ b/docs/_basic/ja_sending_messages.md @@ -13,12 +13,15 @@ order: 2 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # 'knock knock' が含まれるメッセージをリッスンし、イタリック体で 'Who's there?' と返信 @app.message("knock knock") def ask_who(message, say): say("_Who's there?_") ``` +
    diff --git a/docs/_basic/ja_updating_pushing_modals.md b/docs/_basic/ja_updating_pushing_modals.md index 26fdcdc94..cf5ea4b0a 100644 --- a/docs/_basic/ja_updating_pushing_modals.md +++ b/docs/_basic/ja_updating_pushing_modals.md @@ -19,6 +19,8 @@ order: 11 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # モーダルに含まれる、`button_abc` という action_id のボタンの呼び出しをリッスン @app.action("button_abc") @@ -51,3 +53,5 @@ def update_modal(ack, body, client): } ) ``` +
    + diff --git a/docs/_basic/ja_web_api.md b/docs/_basic/ja_web_api.md index c44c8da22..7c5ab21fc 100644 --- a/docs/_basic/ja_web_api.md +++ b/docs/_basic/ja_web_api.md @@ -12,6 +12,8 @@ Bolt の初期化に使用するトークンは `context` オブジェクトに +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python @app.message("wake me up") def say_hello(client, message): @@ -24,3 +26,4 @@ def say_hello(client, message): text="Summer has come and passed" ) ``` +
    \ No newline at end of file diff --git a/docs/_basic/listening_actions.md b/docs/_basic/listening_actions.md index af85c4c4c..338f77332 100644 --- a/docs/_basic/listening_actions.md +++ b/docs/_basic/listening_actions.md @@ -14,6 +14,8 @@ You'll notice in all `action()` examples, `ack()` is used. It is required to cal +
    +Refer to the module document to learn the available listener arguments. ```python # Your listener will be called every time a block element with the action_id "approve_button" is triggered @app.action("approve_button") @@ -21,6 +23,7 @@ def update_message(ack): ack() # Update the message to reflect the action ``` +
    diff --git a/docs/_basic/listening_events.md b/docs/_basic/listening_events.md index f3136b18e..e5df72952 100644 --- a/docs/_basic/listening_events.md +++ b/docs/_basic/listening_events.md @@ -13,6 +13,8 @@ The `event()` method requires an `eventType` of type `str`. +
    +Refer to the module document to learn the available listener arguments. ```python # When a user joins the workspace, send a message in a predefined channel asking them to introduce themselves @app.event("team_join") @@ -22,6 +24,7 @@ def ask_for_introduction(event, say): text = f"Welcome to the team, <@{user_id}>! 🎉 You can introduce yourself in this channel." say(text=text, channel=welcome_channel_id) ``` +
    diff --git a/docs/_basic/listening_messages.md b/docs/_basic/listening_messages.md index d193da141..f48aa5e96 100644 --- a/docs/_basic/listening_messages.md +++ b/docs/_basic/listening_messages.md @@ -13,6 +13,8 @@ To listen to messages that [your app has access to receive](https://api.slack.co +
    +Refer to the module document to learn the available listener arguments. ```python # This will match any message that contains 👋 @app.message(":wave:") @@ -20,6 +22,7 @@ def say_hello(message, say): user = message['user'] say(f"Hi there, <@{user}>!") ``` +
    diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md index 48aa70ba4..855791160 100644 --- a/docs/_basic/listening_modals.md +++ b/docs/_basic/listening_modals.md @@ -15,6 +15,8 @@ Read more about view submissions in our Refer to the module document to learn the available listener arguments. ```python # Handle a view_submission event @app.view("view_1") @@ -46,3 +48,4 @@ def handle_submission(ack, body, client, view): # Message the user client.chat_postMessage(channel=user, text=msg) ``` + \ No newline at end of file diff --git a/docs/_basic/listening_responding_commands.md b/docs/_basic/listening_responding_commands.md index b6e1aee65..c25316228 100644 --- a/docs/_basic/listening_responding_commands.md +++ b/docs/_basic/listening_responding_commands.md @@ -17,6 +17,8 @@ When setting up commands within your app configuration, you'll append `/slack/ev +
    +Refer to the module document to learn the available listener arguments. ```python # The echo command simply echoes on command @app.command("/echo") @@ -25,3 +27,4 @@ def repeat_text(ack, say, command): ack() say(f"{command['text']}") ``` +
    \ No newline at end of file diff --git a/docs/_basic/listening_responding_options.md b/docs/_basic/listening_responding_options.md index 50997ccce..a50a3f06c 100644 --- a/docs/_basic/listening_responding_options.md +++ b/docs/_basic/listening_responding_options.md @@ -15,6 +15,8 @@ To respond to options requests, you'll need to call `ack()` with a valid `option +
    +Refer to the module document to learn the available listener arguments. ```python # Example of responding to an external_select options request @app.options("external_action") @@ -31,3 +33,4 @@ def show_options(ack): ] ack(options=options) ``` +
    \ No newline at end of file diff --git a/docs/_basic/listening_responding_shortcuts.md b/docs/_basic/listening_responding_shortcuts.md index a7f414501..662a98e07 100644 --- a/docs/_basic/listening_responding_shortcuts.md +++ b/docs/_basic/listening_responding_shortcuts.md @@ -21,6 +21,8 @@ When setting up shortcuts within your app configuration, as with other URLs, you +
    +Refer to the module document to learn the available listener arguments. ```python # The open_modal shortcut listens to a shortcut with the callback_id "open_modal" @app.shortcut("open_modal") @@ -56,6 +58,7 @@ def open_modal(ack, shortcut, client): } ) ``` +
    diff --git a/docs/_basic/opening_modals.md b/docs/_basic/opening_modals.md index 2276e0c79..c42c5fee0 100644 --- a/docs/_basic/opening_modals.md +++ b/docs/_basic/opening_modals.md @@ -15,6 +15,8 @@ Read more about modal composition in the Refer to the module document to learn the available listener arguments. ```python # Listen for a shortcut invocation @app.shortcut("open_modal") @@ -56,3 +58,4 @@ def open_modal(ack, body, client): } ) ``` + \ No newline at end of file diff --git a/docs/_basic/publishing_views.md b/docs/_basic/publishing_views.md index 09f678e06..38d4e793d 100644 --- a/docs/_basic/publishing_views.md +++ b/docs/_basic/publishing_views.md @@ -11,6 +11,8 @@ order: 13 You can subscribe to the `app_home_opened` event to listen for when users open your App Home. +
    +Refer to the module document to learn the available listener arguments. ```python @app.event("app_home_opened") def update_home_tab(client, event, logger): @@ -40,7 +42,7 @@ def update_home_tab(client, event, logger): ] } ) - except Exception as e: logger.error(f"Error publishing home tab: {e}") ``` +
    \ No newline at end of file diff --git a/docs/_basic/responding_actions.md b/docs/_basic/responding_actions.md index 8f432e19b..9f143ce18 100644 --- a/docs/_basic/responding_actions.md +++ b/docs/_basic/responding_actions.md @@ -13,6 +13,8 @@ The second way to respond to actions is using `respond()`, which is a utility to +
    +Refer to the module document to learn the available listener arguments. ```python # Your listener will be called every time an interactive component with the action_id “approve_button” is triggered @app.action("approve_button") @@ -21,6 +23,7 @@ def approve_request(ack, say): ack() say("Request approved 👍") ``` +
    diff --git a/docs/_basic/sending_messages.md b/docs/_basic/sending_messages.md index bdf9174de..adc5a5526 100644 --- a/docs/_basic/sending_messages.md +++ b/docs/_basic/sending_messages.md @@ -13,12 +13,15 @@ In the case that you'd like to send a message outside of a listener or you want +
    +Refer to the module document to learn the available listener arguments. ```python # Listens for messages containing "knock knock" and responds with an italicized "who's there?" @app.message("knock knock") def ask_who(message, say): say("_Who's there?_") ``` +
    diff --git a/docs/_basic/updating_pushing_modals.md b/docs/_basic/updating_pushing_modals.md index d017be210..353745c45 100644 --- a/docs/_basic/updating_pushing_modals.md +++ b/docs/_basic/updating_pushing_modals.md @@ -19,6 +19,8 @@ Learn more about updating and pushing views in our Refer to the module document to learn the available listener arguments. ```python # Listen for a button invocation with action_id `button_abc` (assume it's inside of a modal) @app.action("button_abc") @@ -51,3 +53,4 @@ def update_modal(ack, body, client): } ) ``` + \ No newline at end of file diff --git a/docs/_basic/web_api.md b/docs/_basic/web_api.md index d226b6e15..4e7c6e3c8 100644 --- a/docs/_basic/web_api.md +++ b/docs/_basic/web_api.md @@ -12,6 +12,8 @@ The token used to initialize Bolt can be found in the `context` object, which is +
    +Refer to the module document to learn the available listener arguments. ```python @app.message("wake me up") def say_hello(client, message): @@ -24,3 +26,4 @@ def say_hello(client, message): text="Summer has come and passed" ) ``` +
    \ No newline at end of file diff --git a/docs/_steps/adding_editing_workflow_step.md b/docs/_steps/adding_editing_workflow_step.md index bf7b5d960..de9e877e8 100644 --- a/docs/_steps/adding_editing_workflow_step.md +++ b/docs/_steps/adding_editing_workflow_step.md @@ -17,6 +17,8 @@ To learn more about opening configuration modals, [read the documentation](https +
    +Refer to the module documents (common / step-specific) to learn the available arguments. ```python def edit(ack, step, configure): ack() @@ -53,3 +55,4 @@ ws = WorkflowStep( ) app.step(ws) ``` +
    diff --git a/docs/_steps/creating_workflow_step.md b/docs/_steps/creating_workflow_step.md index 5d3db5000..8532ad57f 100644 --- a/docs/_steps/creating_workflow_step.md +++ b/docs/_steps/creating_workflow_step.md @@ -17,6 +17,8 @@ After instantiating a `WorkflowStep`, you can pass it into `app.step()`. Behind +
    +Refer to the module documents (common / step-specific) to learn the available arguments. ```python import os from slack_bolt import App @@ -47,3 +49,4 @@ ws = WorkflowStep( # Pass Step to set up listeners app.step(ws) ``` +
    diff --git a/docs/_steps/executing_workflow_steps.md b/docs/_steps/executing_workflow_steps.md index b767112a0..7b067eedb 100644 --- a/docs/_steps/executing_workflow_steps.md +++ b/docs/_steps/executing_workflow_steps.md @@ -15,6 +15,8 @@ Within the `execute` callback, your app must either call `complete()` to indicat +
    +Refer to the module documents (common / step-specific) to learn the available arguments. ```python def execute(step, complete, fail): inputs = step["inputs"] @@ -36,4 +38,5 @@ ws = WorkflowStep( execute=execute, ) app.step(ws) -``` \ No newline at end of file +``` +
    \ No newline at end of file diff --git a/docs/_steps/ja_adding_editing_workflow_step.md b/docs/_steps/ja_adding_editing_workflow_step.md index e231825e1..7f3c9d44e 100644 --- a/docs/_steps/ja_adding_editing_workflow_step.md +++ b/docs/_steps/ja_adding_editing_workflow_step.md @@ -17,6 +17,8 @@ order: 3 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 ```python def edit(ack, step, configure): ack() @@ -53,3 +55,4 @@ ws = WorkflowStep( ) app.step(ws) ``` +
    \ No newline at end of file diff --git a/docs/_steps/ja_creating_workflow_step.md b/docs/_steps/ja_creating_workflow_step.md index d9438e0e4..0a56b9add 100644 --- a/docs/_steps/ja_creating_workflow_step.md +++ b/docs/_steps/ja_creating_workflow_step.md @@ -17,6 +17,8 @@ order: 2 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 ```python import os from slack_bolt import App @@ -47,3 +49,4 @@ ws = WorkflowStep( # ワークフローステップを渡してリスナーを設定する app.step(ws) ``` +
    \ No newline at end of file diff --git a/docs/_steps/ja_executing_workflow_steps.md b/docs/_steps/ja_executing_workflow_steps.md index c9540175d..68e682ee3 100644 --- a/docs/_steps/ja_executing_workflow_steps.md +++ b/docs/_steps/ja_executing_workflow_steps.md @@ -15,6 +15,8 @@ order: 5 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 ```python def execute(step, complete, fail): inputs = step["inputs"] @@ -36,4 +38,5 @@ ws = WorkflowStep( execute=execute, ) app.step(ws) -``` \ No newline at end of file +``` +
    \ No newline at end of file diff --git a/docs/_steps/ja_saving_workflow_step.md b/docs/_steps/ja_saving_workflow_step.md index 80689bb41..b6f3f3076 100644 --- a/docs/_steps/ja_saving_workflow_step.md +++ b/docs/_steps/ja_saving_workflow_step.md @@ -20,6 +20,8 @@ order: 4 +
    +指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 ```python def save(ack, view, update): ack() @@ -53,4 +55,5 @@ ws = WorkflowStep( execute=execute, ) app.step(ws) -``` \ No newline at end of file +``` +
    \ No newline at end of file diff --git a/docs/_steps/saving_workflow_step.md b/docs/_steps/saving_workflow_step.md index 34f02f6a4..4d81f955d 100644 --- a/docs/_steps/saving_workflow_step.md +++ b/docs/_steps/saving_workflow_step.md @@ -11,7 +11,7 @@ After the configuration modal is opened, your app will listen for the `view_subm Within the `save` callback, the `update()` method can be used to save the builder's step configuration by passing in the following arguments: -- `inputs` is an dictionary representing the data your app expects to receive from the user upon workflow step execution. +- `inputs` is a dictionary representing the data your app expects to receive from the user upon workflow step execution. - `outputs` is a list of objects containing data that your app will provide upon the workflow step's completion. Outputs can then be used in subsequent steps of the workflow. - `step_name` overrides the default Step name - `step_image_url` overrides the default Step image @@ -20,6 +20,8 @@ To learn more about how to structure these parameters, [read the documentation]( +
    +Refer to the module documents (common / step-specific) to learn the available arguments. ```python def save(ack, view, update): ack() @@ -53,4 +55,5 @@ ws = WorkflowStep( execute=execute, ) app.step(ws) -``` \ No newline at end of file +``` +
    \ No newline at end of file diff --git a/docs/_tutorials/getting_started.md b/docs/_tutorials/getting_started.md index c71ba9436..97fde25bb 100644 --- a/docs/_tutorials/getting_started.md +++ b/docs/_tutorials/getting_started.md @@ -186,6 +186,8 @@ app = App( ) # Listens to incoming messages that contain "hello" +# To learn available listener arguments, +# visit https://slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html @app.message("hello") def message_hello(message, say): # say() sends a message to the channel where the event was triggered diff --git a/docs/_tutorials/ja_getting_started.md b/docs/_tutorials/ja_getting_started.md index 9feb4375f..6c90c4809 100644 --- a/docs/_tutorials/ja_getting_started.md +++ b/docs/_tutorials/ja_getting_started.md @@ -174,6 +174,8 @@ app = App( ) # 'hello' を含むメッセージをリッスンします +# 指定可能なリスナーのメソッド引数の一覧は以下のモジュールドキュメントを参考にしてください: +# https://slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html @app.message("hello") def message_hello(message, say): # イベントがトリガーされたチャンネルへ say() でメッセージを送信します diff --git a/docs/api-docs/slack_bolt/kwargs_injection/index.html b/docs/api-docs/slack_bolt/kwargs_injection/index.html index 181d66960..e53d92bf2 100644 --- a/docs/api-docs/slack_bolt/kwargs_injection/index.html +++ b/docs/api-docs/slack_bolt/kwargs_injection/index.html @@ -4,7 +4,7 @@ -slack_bolt.kwargs_injection API documentation +slack_bolt.the API documentation diff --git a/docs/assets/style.css b/docs/assets/style.css index 2041edc07..411d3b5ba 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -278,6 +278,10 @@ pre tbody td.gl pre { line-height: 1.9em; } +.content .section-wrapper .annotation { + font-size: 0.7em; +} + .content .section-wrapper h3 { grid-area: head; font-size: 1.45em; diff --git a/examples/getting_started/app.py b/examples/getting_started/app.py index e394dd2fa..96944a594 100644 --- a/examples/getting_started/app.py +++ b/examples/getting_started/app.py @@ -8,6 +8,8 @@ ) # Listens to incoming messages that contain "hello" +# To learn available listener method arguments, +# visit https://slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html @app.message("hello") def message_hello(message, say): # say() sends a message to the channel where the event was triggered From 3622b6810fd87a1c69483cfef3c3de2cbb07e084 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 2 Jun 2021 11:01:58 +0900 Subject: [PATCH 336/865] Fix #206 Add error handling to view submission code snippet in docs (#359) --- docs/_basic/ja_listening_modals.md | 10 +++++++--- docs/_basic/listening_modals.md | 9 ++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/_basic/ja_listening_modals.md b/docs/_basic/ja_listening_modals.md index 16935948d..66d6aa020 100644 --- a/docs/_basic/ja_listening_modals.md +++ b/docs/_basic/ja_listening_modals.md @@ -20,7 +20,7 @@ order: 12 ```python # view_submission イベントを処理 @app.view("view_1") -def handle_submission(ack, body, client, view): +def handle_submission(ack, body, client, view, logger): # `block_c`という block_id に `dreamy_input` を持つ input ブロックがある場合 hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"] user = body["user"]["id"] @@ -44,8 +44,12 @@ def handle_submission(ack, body, client, view): except Exception as e: # エラーをハンドリング msg = "There was an error with your submission" - finally: - # ユーザーにメッセージを送信 + + # ユーザーにメッセージを送信 + try: client.chat_postMessage(channel=user, text=msg) + except e: + logger.exception(f"Failed to post a message {e}") + ``` \ No newline at end of file diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md index 855791160..e48e05c24 100644 --- a/docs/_basic/listening_modals.md +++ b/docs/_basic/listening_modals.md @@ -20,7 +20,7 @@ Read more about view submissions in our \ No newline at end of file From e6079cdc8865f28eb06500a3ed2c82de104b55bc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 3 Jun 2021 16:51:36 +0900 Subject: [PATCH 337/865] Fix #346 Allow unfurl_media / unfurl_links in ack / respond (#363) --- slack_bolt/context/ack/ack.py | 4 ++++ slack_bolt/context/ack/async_ack.py | 4 ++++ slack_bolt/context/ack/internals.py | 6 ++++++ slack_bolt/context/respond/async_respond.py | 4 ++++ slack_bolt/context/respond/internals.py | 6 ++++++ slack_bolt/context/respond/respond.py | 4 ++++ slack_bolt/context/say/async_say.py | 4 ++++ slack_bolt/context/say/say.py | 4 ++++ tests/slack_bolt/context/test_ack.py | 13 +++++++++++++ tests/slack_bolt/context/test_respond.py | 6 ++++++ tests/slack_bolt/context/test_respond_internals.py | 6 ++++++ tests/slack_bolt/context/test_say.py | 7 +++++++ tests/slack_bolt_async/context/test_async_ack.py | 14 ++++++++++++++ .../slack_bolt_async/context/test_async_respond.py | 7 +++++++ tests/slack_bolt_async/context/test_async_say.py | 8 ++++++++ 15 files changed, 97 insertions(+) diff --git a/slack_bolt/context/ack/ack.py b/slack_bolt/context/ack/ack.py index 69cf928cc..5ebc5f5ad 100644 --- a/slack_bolt/context/ack/ack.py +++ b/slack_bolt/context/ack/ack.py @@ -19,6 +19,8 @@ def __call__( text: Union[str, dict] = "", # text: str or whole_response: dict blocks: Optional[Sequence[Union[dict, Block]]] = None, attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion options: Optional[Sequence[Union[dict, Option]]] = None, @@ -33,6 +35,8 @@ def __call__( text_or_whole_response=text, blocks=blocks, attachments=attachments, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, response_type=response_type, options=options, option_groups=option_groups, diff --git a/slack_bolt/context/ack/async_ack.py b/slack_bolt/context/ack/async_ack.py index 3be06918a..c12e7b6d2 100644 --- a/slack_bolt/context/ack/async_ack.py +++ b/slack_bolt/context/ack/async_ack.py @@ -19,6 +19,8 @@ async def __call__( text: Union[str, dict] = "", # text: str or whole_response: dict blocks: Optional[Sequence[Union[dict, Block]]] = None, attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion options: Optional[Sequence[Union[dict, Option]]] = None, @@ -33,6 +35,8 @@ async def __call__( text_or_whole_response=text, blocks=blocks, attachments=attachments, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, response_type=response_type, options=options, option_groups=option_groups, diff --git a/slack_bolt/context/ack/internals.py b/slack_bolt/context/ack/internals.py index 8d21b6559..1985e26ca 100644 --- a/slack_bolt/context/ack/internals.py +++ b/slack_bolt/context/ack/internals.py @@ -14,6 +14,8 @@ def _set_response( text_or_whole_response: Union[str, dict] = "", blocks: Optional[Sequence[Union[dict, Block]]] = None, attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion options: Optional[Sequence[Union[dict, Option]]] = None, @@ -28,6 +30,10 @@ def _set_response( body = {"text": text} if response_type: body["response_type"] = response_type + if unfurl_links is not None: + body["unfurl_links"] = unfurl_links + if unfurl_media is not None: + body["unfurl_media"] = unfurl_media if attachments and len(attachments) > 0: body.update( {"text": text, "attachments": convert_to_dict_list(attachments)} diff --git a/slack_bolt/context/respond/async_respond.py b/slack_bolt/context/respond/async_respond.py index 77a4665ac..60e4fe473 100644 --- a/slack_bolt/context/respond/async_respond.py +++ b/slack_bolt/context/respond/async_respond.py @@ -21,6 +21,8 @@ async def __call__( response_type: Optional[str] = None, replace_original: Optional[bool] = None, delete_original: Optional[bool] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, ) -> WebhookResponse: if self.response_url is not None: client = AsyncWebhookClient(self.response_url) @@ -33,6 +35,8 @@ async def __call__( response_type=response_type, replace_original=replace_original, delete_original=delete_original, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, ) 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 28cbbbec4..21acfaf43 100644 --- a/slack_bolt/context/respond/internals.py +++ b/slack_bolt/context/respond/internals.py @@ -13,6 +13,8 @@ def _build_message( response_type: Optional[str] = None, replace_original: Optional[bool] = None, delete_original: Optional[bool] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, ) -> Dict[str, Any]: message = {"text": text} if blocks is not None and len(blocks) > 0: @@ -25,4 +27,8 @@ def _build_message( message["replace_original"] = replace_original if delete_original is not None: message["delete_original"] = delete_original + if unfurl_links is not None: + message["unfurl_links"] = unfurl_links + if unfurl_media is not None: + message["unfurl_media"] = unfurl_media return message diff --git a/slack_bolt/context/respond/respond.py b/slack_bolt/context/respond/respond.py index 09e2727df..21de92263 100644 --- a/slack_bolt/context/respond/respond.py +++ b/slack_bolt/context/respond/respond.py @@ -21,6 +21,8 @@ def __call__( response_type: Optional[str] = None, replace_original: Optional[bool] = None, delete_original: Optional[bool] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, ) -> WebhookResponse: if self.response_url is not None: client = WebhookClient(self.response_url) @@ -34,6 +36,8 @@ def __call__( response_type=response_type, replace_original=replace_original, delete_original=delete_original, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, ) return client.send_dict(message) elif isinstance(text_or_whole_response, dict): diff --git a/slack_bolt/context/say/async_say.py b/slack_bolt/context/say/async_say.py index a47838e1b..88afe2533 100644 --- a/slack_bolt/context/say/async_say.py +++ b/slack_bolt/context/say/async_say.py @@ -26,6 +26,8 @@ async def __call__( attachments: Optional[Sequence[Union[Dict, Attachment]]] = None, channel: Optional[str] = None, thread_ts: Optional[str] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, **kwargs, ) -> AsyncSlackResponse: if _can_say(self, channel): @@ -38,6 +40,8 @@ async def __call__( blocks=blocks, attachments=attachments, thread_ts=thread_ts, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, **kwargs, ) elif isinstance(text_or_whole_response, dict): diff --git a/slack_bolt/context/say/say.py b/slack_bolt/context/say/say.py index ccf7d6d91..58535da90 100644 --- a/slack_bolt/context/say/say.py +++ b/slack_bolt/context/say/say.py @@ -27,6 +27,8 @@ def __call__( attachments: Optional[Sequence[Union[Dict, Attachment]]] = None, channel: Optional[str] = None, thread_ts: Optional[str] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, **kwargs, ) -> SlackResponse: if _can_say(self, channel): @@ -39,6 +41,8 @@ def __call__( blocks=blocks, attachments=attachments, thread_ts=thread_ts, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, **kwargs, ) elif isinstance(text_or_whole_response, dict): diff --git a/tests/slack_bolt/context/test_ack.py b/tests/slack_bolt/context/test_ack.py index e7d7443e9..2ae0b1050 100644 --- a/tests/slack_bolt/context/test_ack.py +++ b/tests/slack_bolt/context/test_ack.py @@ -54,6 +54,19 @@ def test_blocks(self): '{"text": "foo", "blocks": [{"type": "divider"}]}', ) + def test_unfurl_options(self): + ack = Ack() + response: BoltResponse = ack( + text="foo", + blocks=[{"type": "divider"}], + unfurl_links=True, + unfurl_media=True, + ) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "unfurl_links": true, "unfurl_media": true, "blocks": [{"type": "divider"}]}', + ) + sample_options = [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}] def test_options(self): diff --git a/tests/slack_bolt/context/test_respond.py b/tests/slack_bolt/context/test_respond.py index 1a76e507d..f108664f3 100644 --- a/tests/slack_bolt/context/test_respond.py +++ b/tests/slack_bolt/context/test_respond.py @@ -23,3 +23,9 @@ def test_respond2(self): respond = Respond(response_url=response_url) response = respond({"text": "Hi there!"}) assert response.status_code == 200 + + def test_unfurl_options(self): + response_url = "http://localhost:8888" + respond = Respond(response_url=response_url) + response = respond(text="Hi there!", unfurl_media=True, unfurl_links=True) + assert response.status_code == 200 diff --git a/tests/slack_bolt/context/test_respond_internals.py b/tests/slack_bolt/context/test_respond_internals.py index 173fdf003..facb293da 100644 --- a/tests/slack_bolt/context/test_respond_internals.py +++ b/tests/slack_bolt/context/test_respond_internals.py @@ -43,3 +43,9 @@ def test_build_message_replace_original(self): def test_build_message_delete_original(self): message = _build_message(delete_original=True) assert message is not None + + def test_build_message_unfurl_options(self): + message = _build_message(text="Hi there!", unfurl_links=True, unfurl_media=True) + assert message is not None + assert message.get("unfurl_links") is True + assert message.get("unfurl_media") is True diff --git a/tests/slack_bolt/context/test_say.py b/tests/slack_bolt/context/test_say.py index a28fbbd9d..fae42d1f0 100644 --- a/tests/slack_bolt/context/test_say.py +++ b/tests/slack_bolt/context/test_say.py @@ -26,6 +26,13 @@ def test_say(self): response: SlackResponse = say(text="Hi there!") assert response.status_code == 200 + def test_say_unfurl_options(self): + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say( + text="Hi there!", unfurl_media=True, unfurl_links=True + ) + assert response.status_code == 200 + def test_say_dict(self): say = Say(client=self.web_client, channel="C111") response: SlackResponse = say({"text": "Hi!"}) diff --git a/tests/slack_bolt_async/context/test_async_ack.py b/tests/slack_bolt_async/context/test_async_ack.py index 6004002dd..e4f498842 100644 --- a/tests/slack_bolt_async/context/test_async_ack.py +++ b/tests/slack_bolt_async/context/test_async_ack.py @@ -22,6 +22,20 @@ async def test_blocks(self): '{"text": "foo", "blocks": [{"type": "divider"}]}', ) + @pytest.mark.asyncio + async def test_unfurl_options(self): + ack = AsyncAck() + response: BoltResponse = await ack( + text="foo", + blocks=[{"type": "divider"}], + unfurl_links=True, + unfurl_media=True, + ) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "unfurl_links": true, "unfurl_media": true, "blocks": [{"type": "divider"}]}', + ) + sample_attachments = [ { "fallback": "Plain-text summary of the attachment.", diff --git a/tests/slack_bolt_async/context/test_async_respond.py b/tests/slack_bolt_async/context/test_async_respond.py index fb3083132..18ae114e8 100644 --- a/tests/slack_bolt_async/context/test_async_respond.py +++ b/tests/slack_bolt_async/context/test_async_respond.py @@ -31,3 +31,10 @@ async def test_respond2(self): respond = AsyncRespond(response_url=response_url) response = await respond({"text": "Hi there!"}) assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_respond_unfurl_options(self): + response_url = "http://localhost:8888" + respond = AsyncRespond(response_url=response_url) + response = await respond(text="Hi there!", unfurl_media=True, unfurl_links=True) + assert response.status_code == 200 diff --git a/tests/slack_bolt_async/context/test_async_say.py b/tests/slack_bolt_async/context/test_async_say.py index 03a2978ec..77f846154 100644 --- a/tests/slack_bolt_async/context/test_async_say.py +++ b/tests/slack_bolt_async/context/test_async_say.py @@ -32,6 +32,14 @@ async def test_say(self): response: AsyncSlackResponse = await say(text="Hi there!") assert response.status_code == 200 + @pytest.mark.asyncio + async def test_say_unfurl_options(self): + say = AsyncSay(client=self.web_client, channel="C111") + response: AsyncSlackResponse = await say( + text="Hi there!", unfurl_links=True, unfurl_media=True + ) + assert response.status_code == 200 + @pytest.mark.asyncio async def test_say_dict(self): say = AsyncSay(client=self.web_client, channel="C111") From 0b28dfe7ac42c5239bb3e7177991b96d3a9e8195 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 3 Jun 2021 16:55:50 +0900 Subject: [PATCH 338/865] version 1.6.1 --- .../slack_bolt/adapter/aws_lambda/handler.html | 17 ++++++++++++++++- docs/api-docs/slack_bolt/context/ack/ack.html | 8 ++++++++ .../slack_bolt/context/ack/async_ack.html | 8 ++++++++ .../slack_bolt/context/ack/internals.html | 6 ++++++ .../context/respond/async_respond.html | 8 ++++++++ .../slack_bolt/context/respond/internals.html | 6 ++++++ .../slack_bolt/context/respond/respond.html | 8 ++++++++ .../slack_bolt/context/say/async_say.html | 8 ++++++++ docs/api-docs/slack_bolt/context/say/say.html | 8 ++++++++ .../slack_bolt/kwargs_injection/index.html | 2 +- .../slack_bolt/request/async_request.html | 13 ++++++++----- .../api-docs/slack_bolt/request/internals.html | 18 ------------------ docs/api-docs/slack_bolt/request/request.html | 11 ++++++----- docs/api-docs/slack_bolt/version.html | 2 +- scripts/deploy_to_prod_pypi_org.sh | 1 - scripts/deploy_to_test_pypi_org.sh | 1 - slack_bolt/version.py | 2 +- 17 files changed, 93 insertions(+), 34 deletions(-) diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html index 848564915..43edffe39 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html @@ -103,6 +103,12 @@

    Module slack_bolt.adapter.aws_lambda.handler

    def to_bolt_request(event) -> BoltRequest: + """Note that this handler supports only the payload format 2.0. + This means you can use this with HTTP API while REST API is not supported. + + Read https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html + for more details. + """ body = event.get("body", "") if event["isBase64Encoded"]: body = base64.b64decode(body).decode("utf-8") @@ -177,12 +183,21 @@

    Functions

    def to_bolt_request(event) ‑> 
    BoltRequest
    -
    +

    Note that this handler supports only the payload format 2.0. +This means you can use this with HTTP API while REST API is not supported.

    +

    Read https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html +for more details.

    Expand source code
    def to_bolt_request(event) -> BoltRequest:
    +    """Note that this handler supports only the payload format 2.0.
    +    This means you can use this with HTTP API while REST API is not supported.
    +
    +    Read https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
    +    for more details.
    +    """
         body = event.get("body", "")
         if event["isBase64Encoded"]:
             body = base64.b64decode(body).decode("utf-8")
    diff --git a/docs/api-docs/slack_bolt/context/ack/ack.html b/docs/api-docs/slack_bolt/context/ack/ack.html
    index ca8c14b3b..42ea41468 100644
    --- a/docs/api-docs/slack_bolt/context/ack/ack.html
    +++ b/docs/api-docs/slack_bolt/context/ack/ack.html
    @@ -47,6 +47,8 @@ 

    Module slack_bolt.context.ack.ack

    text: Union[str, dict] = "", # text: str or whole_response: dict blocks: Optional[Sequence[Union[dict, Block]]] = None, attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion options: Optional[Sequence[Union[dict, Option]]] = None, @@ -61,6 +63,8 @@

    Module slack_bolt.context.ack.ack

    text_or_whole_response=text, blocks=blocks, attachments=attachments, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, response_type=response_type, options=options, option_groups=option_groups, @@ -99,6 +103,8 @@

    Classes

    text: Union[str, dict] = "", # text: str or whole_response: dict blocks: Optional[Sequence[Union[dict, Block]]] = None, attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion options: Optional[Sequence[Union[dict, Option]]] = None, @@ -113,6 +119,8 @@

    Classes

    text_or_whole_response=text, blocks=blocks, attachments=attachments, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, response_type=response_type, options=options, option_groups=option_groups, diff --git a/docs/api-docs/slack_bolt/context/ack/async_ack.html b/docs/api-docs/slack_bolt/context/ack/async_ack.html index 35dc126be..d662cac7f 100644 --- a/docs/api-docs/slack_bolt/context/ack/async_ack.html +++ b/docs/api-docs/slack_bolt/context/ack/async_ack.html @@ -47,6 +47,8 @@

    Module slack_bolt.context.ack.async_ack

    text: Union[str, dict] = "", # text: str or whole_response: dict blocks: Optional[Sequence[Union[dict, Block]]] = None, attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion options: Optional[Sequence[Union[dict, Option]]] = None, @@ -61,6 +63,8 @@

    Module slack_bolt.context.ack.async_ack

    text_or_whole_response=text, blocks=blocks, attachments=attachments, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, response_type=response_type, options=options, option_groups=option_groups, @@ -99,6 +103,8 @@

    Classes

    text: Union[str, dict] = "", # text: str or whole_response: dict blocks: Optional[Sequence[Union[dict, Block]]] = None, attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion options: Optional[Sequence[Union[dict, Option]]] = None, @@ -113,6 +119,8 @@

    Classes

    text_or_whole_response=text, blocks=blocks, attachments=attachments, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, response_type=response_type, options=options, option_groups=option_groups, diff --git a/docs/api-docs/slack_bolt/context/ack/internals.html b/docs/api-docs/slack_bolt/context/ack/internals.html index a5a648043..29295bd96 100644 --- a/docs/api-docs/slack_bolt/context/ack/internals.html +++ b/docs/api-docs/slack_bolt/context/ack/internals.html @@ -42,6 +42,8 @@

    Module slack_bolt.context.ack.internals

    text_or_whole_response: Union[str, dict] = "", blocks: Optional[Sequence[Union[dict, Block]]] = None, attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion options: Optional[Sequence[Union[dict, Option]]] = None, @@ -56,6 +58,10 @@

    Module slack_bolt.context.ack.internals

    body = {"text": text} if response_type: body["response_type"] = response_type + if unfurl_links is not None: + body["unfurl_links"] = unfurl_links + if unfurl_media is not None: + body["unfurl_media"] = unfurl_media if attachments and len(attachments) > 0: body.update( {"text": text, "attachments": convert_to_dict_list(attachments)} diff --git a/docs/api-docs/slack_bolt/context/respond/async_respond.html b/docs/api-docs/slack_bolt/context/respond/async_respond.html index 88dd9b64d..8e332b1cd 100644 --- a/docs/api-docs/slack_bolt/context/respond/async_respond.html +++ b/docs/api-docs/slack_bolt/context/respond/async_respond.html @@ -49,6 +49,8 @@

    Module slack_bolt.context.respond.async_respondModule slack_bolt.context.respond.async_respondClasses

    response_type: Optional[str] = None, replace_original: Optional[bool] = None, delete_original: Optional[bool] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, ) -> WebhookResponse: if self.response_url is not None: client = AsyncWebhookClient(self.response_url) @@ -118,6 +124,8 @@

    Classes

    response_type=response_type, replace_original=replace_original, delete_original=delete_original, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, ) return await client.send_dict(message) elif isinstance(text_or_whole_response, dict): diff --git a/docs/api-docs/slack_bolt/context/respond/internals.html b/docs/api-docs/slack_bolt/context/respond/internals.html index bf4034832..0442915c2 100644 --- a/docs/api-docs/slack_bolt/context/respond/internals.html +++ b/docs/api-docs/slack_bolt/context/respond/internals.html @@ -41,6 +41,8 @@

    Module slack_bolt.context.respond.internals

    response_type: Optional[str] = None, replace_original: Optional[bool] = None, delete_original: Optional[bool] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, ) -> Dict[str, Any]: message = {"text": text} if blocks is not None and len(blocks) > 0: @@ -53,6 +55,10 @@

    Module slack_bolt.context.respond.internals

    message["replace_original"] = replace_original if delete_original is not None: message["delete_original"] = delete_original + if unfurl_links is not None: + message["unfurl_links"] = unfurl_links + if unfurl_media is not None: + message["unfurl_media"] = unfurl_media return message
    diff --git a/docs/api-docs/slack_bolt/context/respond/respond.html b/docs/api-docs/slack_bolt/context/respond/respond.html index 7e921f425..54717eeaa 100644 --- a/docs/api-docs/slack_bolt/context/respond/respond.html +++ b/docs/api-docs/slack_bolt/context/respond/respond.html @@ -49,6 +49,8 @@

    Module slack_bolt.context.respond.respond

    response_type: Optional[str] = None, replace_original: Optional[bool] = None, delete_original: Optional[bool] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, ) -> WebhookResponse: if self.response_url is not None: client = WebhookClient(self.response_url) @@ -62,6 +64,8 @@

    Module slack_bolt.context.respond.respond

    response_type=response_type, replace_original=replace_original, delete_original=delete_original, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, ) return client.send_dict(message) elif isinstance(text_or_whole_response, dict): @@ -108,6 +112,8 @@

    Classes

    response_type: Optional[str] = None, replace_original: Optional[bool] = None, delete_original: Optional[bool] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, ) -> WebhookResponse: if self.response_url is not None: client = WebhookClient(self.response_url) @@ -121,6 +127,8 @@

    Classes

    response_type=response_type, replace_original=replace_original, delete_original=delete_original, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, ) return client.send_dict(message) elif isinstance(text_or_whole_response, dict): diff --git a/docs/api-docs/slack_bolt/context/say/async_say.html b/docs/api-docs/slack_bolt/context/say/async_say.html index 471ed618a..092d6c038 100644 --- a/docs/api-docs/slack_bolt/context/say/async_say.html +++ b/docs/api-docs/slack_bolt/context/say/async_say.html @@ -54,6 +54,8 @@

    Module slack_bolt.context.say.async_say

    attachments: Optional[Sequence[Union[Dict, Attachment]]] = None, channel: Optional[str] = None, thread_ts: Optional[str] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, **kwargs, ) -> AsyncSlackResponse: if _can_say(self, channel): @@ -66,6 +68,8 @@

    Module slack_bolt.context.say.async_say

    blocks=blocks, attachments=attachments, thread_ts=thread_ts, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, **kwargs, ) elif isinstance(text_or_whole_response, dict): @@ -119,6 +123,8 @@

    Classes

    attachments: Optional[Sequence[Union[Dict, Attachment]]] = None, channel: Optional[str] = None, thread_ts: Optional[str] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, **kwargs, ) -> AsyncSlackResponse: if _can_say(self, channel): @@ -131,6 +137,8 @@

    Classes

    blocks=blocks, attachments=attachments, thread_ts=thread_ts, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, **kwargs, ) elif isinstance(text_or_whole_response, dict): diff --git a/docs/api-docs/slack_bolt/context/say/say.html b/docs/api-docs/slack_bolt/context/say/say.html index ce78149f1..455e945d5 100644 --- a/docs/api-docs/slack_bolt/context/say/say.html +++ b/docs/api-docs/slack_bolt/context/say/say.html @@ -55,6 +55,8 @@

    Module slack_bolt.context.say.say

    attachments: Optional[Sequence[Union[Dict, Attachment]]] = None, channel: Optional[str] = None, thread_ts: Optional[str] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, **kwargs, ) -> SlackResponse: if _can_say(self, channel): @@ -67,6 +69,8 @@

    Module slack_bolt.context.say.say

    blocks=blocks, attachments=attachments, thread_ts=thread_ts, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, **kwargs, ) elif isinstance(text_or_whole_response, dict): @@ -120,6 +124,8 @@

    Classes

    attachments: Optional[Sequence[Union[Dict, Attachment]]] = None, channel: Optional[str] = None, thread_ts: Optional[str] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, **kwargs, ) -> SlackResponse: if _can_say(self, channel): @@ -132,6 +138,8 @@

    Classes

    blocks=blocks, attachments=attachments, thread_ts=thread_ts, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, **kwargs, ) elif isinstance(text_or_whole_response, dict): diff --git a/docs/api-docs/slack_bolt/kwargs_injection/index.html b/docs/api-docs/slack_bolt/kwargs_injection/index.html index e53d92bf2..181d66960 100644 --- a/docs/api-docs/slack_bolt/kwargs_injection/index.html +++ b/docs/api-docs/slack_bolt/kwargs_injection/index.html @@ -4,7 +4,7 @@ -slack_bolt.the API documentation +slack_bolt.kwargs_injection API documentation diff --git a/docs/api-docs/slack_bolt/request/async_request.html b/docs/api-docs/slack_bolt/request/async_request.html index 97be298e9..63aa9a079 100644 --- a/docs/api-docs/slack_bolt/request/async_request.html +++ b/docs/api-docs/slack_bolt/request/async_request.html @@ -37,7 +37,6 @@

    Module slack_bolt.request.async_request

    build_normalized_headers, extract_content_type, error_message_raw_body_required_in_http_mode, - error_message_unknown_request_body_type, ) @@ -73,7 +72,7 @@

    Module slack_bolt.request.async_request

    if mode == "http": # HTTP Mode - if not isinstance(body, str): + if body is not None and not isinstance(body, str): raise BoltError(error_message_raw_body_required_in_http_mode()) self.raw_body = body if body is not None else "" else: @@ -88,12 +87,14 @@

    Module slack_bolt.request.async_request

    self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) + if isinstance(body, str): self.body = parse_body(self.raw_body, self.content_type) elif isinstance(body, dict): self.body = body else: - raise BoltError(error_message_unknown_request_body_type()) + self.body = {} + self.context = build_async_context( AsyncBoltContext(context if context else {}), self.body ) @@ -168,7 +169,7 @@

    Args

    if mode == "http": # HTTP Mode - if not isinstance(body, str): + if body is not None and not isinstance(body, str): raise BoltError(error_message_raw_body_required_in_http_mode()) self.raw_body = body if body is not None else "" else: @@ -183,12 +184,14 @@

    Args

    self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) + if isinstance(body, str): self.body = parse_body(self.raw_body, self.content_type) elif isinstance(body, dict): self.body = body else: - raise BoltError(error_message_unknown_request_body_type()) + self.body = {} + self.context = build_async_context( AsyncBoltContext(context if context else {}), self.body ) diff --git a/docs/api-docs/slack_bolt/request/internals.html b/docs/api-docs/slack_bolt/request/internals.html index c65edf9d3..e74c966a6 100644 --- a/docs/api-docs/slack_bolt/request/internals.html +++ b/docs/api-docs/slack_bolt/request/internals.html @@ -211,10 +211,6 @@

    Module slack_bolt.request.internals

    return "`body` must be a raw string data when running in the HTTP server mode" -def error_message_unknown_request_body_type() -> str: - return "`body` must be either str or dict" - - def debug_multiple_response_urls_detected() -> str: return ( "`response_urls` in the body has multiple URLs in it. " @@ -323,19 +319,6 @@

    Functions

    return "`body` must be a raw string data when running in the HTTP server mode"
    -
    -def error_message_unknown_request_body_type() ‑> str -
    -
    -
    -
    - -Expand source code - -
    def error_message_unknown_request_body_type() -> str:
    -    return "`body` must be either str or dict"
    -
    -
    def extract_channel_id(payload: Dict[str, Any]) ‑> Optional[str]
    @@ -559,7 +542,6 @@

    Index

  • build_normalized_headers
  • debug_multiple_response_urls_detected
  • error_message_raw_body_required_in_http_mode
  • -
  • error_message_unknown_request_body_type
  • extract_channel_id
  • extract_content_type
  • extract_enterprise_id
  • diff --git a/docs/api-docs/slack_bolt/request/request.html b/docs/api-docs/slack_bolt/request/request.html index ce8fd5b42..10ddce01d 100644 --- a/docs/api-docs/slack_bolt/request/request.html +++ b/docs/api-docs/slack_bolt/request/request.html @@ -37,7 +37,6 @@

    Module slack_bolt.request.request

    build_context, extract_content_type, error_message_raw_body_required_in_http_mode, - error_message_unknown_request_body_type, ) @@ -72,7 +71,7 @@

    Module slack_bolt.request.request

    """ if mode == "http": # HTTP Mode - if not isinstance(body, str): + if body is not None and not isinstance(body, str): raise BoltError(error_message_raw_body_required_in_http_mode()) self.raw_body = body if body is not None else "" else: @@ -87,12 +86,13 @@

    Module slack_bolt.request.request

    self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) + if isinstance(body, str): self.body = parse_body(self.raw_body, self.content_type) elif isinstance(body, dict): self.body = body else: - raise BoltError(error_message_unknown_request_body_type()) + self.body = {} self.context = build_context(BoltContext(context if context else {}), self.body) self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0]) @@ -165,7 +165,7 @@

    Args

    """ if mode == "http": # HTTP Mode - if not isinstance(body, str): + if body is not None and not isinstance(body, str): raise BoltError(error_message_raw_body_required_in_http_mode()) self.raw_body = body if body is not None else "" else: @@ -180,12 +180,13 @@

    Args

    self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) + if isinstance(body, str): self.body = parse_body(self.raw_body, self.content_type) elif isinstance(body, dict): self.body = body else: - raise BoltError(error_message_unknown_request_body_type()) + self.body = {} self.context = build_context(BoltContext(context if context else {}), self.body) self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0]) diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index 274497ee7..c2b60cf6b 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.6.0"
    +__version__ = "1.6.1"
    diff --git a/scripts/deploy_to_prod_pypi_org.sh b/scripts/deploy_to_prod_pypi_org.sh index 24c09850f..5ac4c9907 100755 --- a/scripts/deploy_to_prod_pypi_org.sh +++ b/scripts/deploy_to_prod_pypi_org.sh @@ -5,7 +5,6 @@ cd ${script_dir}/.. rm -rf ./slack_bolt.egg-info pip install -U pip && \ - python setup.py test && \ pip install twine wheel && \ rm -rf dist/ build/ slack_bolt.egg-info/ && \ python setup.py sdist bdist_wheel && \ diff --git a/scripts/deploy_to_test_pypi_org.sh b/scripts/deploy_to_test_pypi_org.sh index 30bf560f4..8419aa7d5 100755 --- a/scripts/deploy_to_test_pypi_org.sh +++ b/scripts/deploy_to_test_pypi_org.sh @@ -5,7 +5,6 @@ cd ${script_dir}/.. rm -rf ./slack_bolt.egg-info pip install -U pip && \ - python setup.py test && \ pip install twine wheel && \ rm -rf dist/ build/ slack_bolt.egg-info/ && \ python setup.py sdist bdist_wheel && \ diff --git a/slack_bolt/version.py b/slack_bolt/version.py index dfa62616b..5494d958b 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.6.0" +__version__ = "1.6.1" From 0b16f56f1a1a326626c1e1fd3430dd91488280d4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Jun 2021 07:11:22 +0900 Subject: [PATCH 339/865] Update websocket_client (optional dep) version to v1 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index fc6c4f5d5..01e0867c6 100755 --- a/setup.py +++ b/setup.py @@ -80,7 +80,7 @@ "uvicorn<1", "gunicorn>=20,<21", # Socket Mode 3rd party implementation - "websocket_client>=0.57,<1", + "websocket_client>=1,<2", ], # pip install -e ".[testing]" "testing": test_dependencies, From 0927de45b265bc4d16570ceb6a23cb5dd0a21317 Mon Sep 17 00:00:00 2001 From: Bhavya Agarwal Date: Wed, 9 Jun 2021 21:57:24 -0700 Subject: [PATCH 340/865] Update install page to avoid favicon downloads (#375) * Update internals.py * Updating content length in tests Updating content length in tests to match the new length due to added tag. --- slack_bolt/oauth/internals.py | 1 + tests/adapter_tests_async/test_async_fastapi.py | 2 +- tests/adapter_tests_async/test_async_sanic.py | 2 +- tests/adapter_tests_async/test_async_starlette.py | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/slack_bolt/oauth/internals.py b/slack_bolt/oauth/internals.py index 39fcd9fcf..87f4e167b 100644 --- a/slack_bolt/oauth/internals.py +++ b/slack_bolt/oauth/internals.py @@ -74,6 +74,7 @@ def _build_callback_failure_response( # type: ignore def _build_default_install_page_html(url: str) -> str: return f""" + - - - - - - -
    -
    -
    -

    Module slack_bolt.listener.async_internals

    -
    -
    -
    - -Expand source code - -
    from logging import Logger
    -from typing import Dict, Any, Optional
    -
    -from slack_bolt.request.async_request import AsyncBoltRequest
    -from slack_bolt.request.payload_utils import (
    -    to_options,
    -    to_shortcut,
    -    to_action,
    -    to_view,
    -    to_command,
    -    to_event,
    -    to_message,
    -    to_step,
    -)
    -from slack_bolt.response import BoltResponse
    -
    -
    -def _build_all_available_args(
    -    logger: Logger,
    -    request: AsyncBoltRequest,
    -    response: Optional[BoltResponse],
    -    error: Optional[Exception] = None,
    -) -> Dict[str, Any]:
    -    all_available_args = {
    -        "logger": logger,
    -        "error": error,
    -        "client": request.context.client,
    -        "req": request,
    -        "request": request,
    -        "resp": response,
    -        "response": response,
    -        "context": request.context,
    -        # payload
    -        "body": request.body,
    -        "options": to_options(request.body),
    -        "shortcut": to_shortcut(request.body),
    -        "action": to_action(request.body),
    -        "view": to_view(request.body),
    -        "command": to_command(request.body),
    -        "event": to_event(request.body),
    -        "message": to_message(request.body),
    -        "step": to_step(request.body),
    -        # utilities
    -        "say": request.context.say,
    -        "respond": request.context.respond,
    -    }
    -    all_available_args["payload"] = (
    -        all_available_args["options"]
    -        or all_available_args["shortcut"]
    -        or all_available_args["action"]
    -        or all_available_args["view"]
    -        or all_available_args["command"]
    -        or all_available_args["event"]
    -        or all_available_args["message"]
    -        or all_available_args["step"]
    -        or request.body
    -    )
    -    return all_available_args
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    - - - \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html b/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html index 3ec3f124a..7152f6e44 100644 --- a/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html +++ b/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html @@ -31,13 +31,7 @@

    Module slack_bolt.listener.async_listener_completion_han from logging import Logger from typing import Callable, Dict, Any, Awaitable, Optional -from slack_bolt.listener.async_internals import ( - _build_all_available_args, -) -from slack_bolt.listener.internals import ( - _convert_all_available_args_to_kwargs, -) - +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse @@ -70,15 +64,12 @@

    Module slack_bolt.listener.async_listener_completion_han request: AsyncBoltRequest, response: Optional[BoltResponse], ) -> None: - all_available_args = _build_all_available_args( + kwargs: Dict[str, Any] = build_async_required_kwargs( + required_arg_names=self.arg_names, logger=self.logger, request=request, response=response, - ) - kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( - all_available_args=all_available_args, - arg_names=self.arg_names, - logger=self.logger, + next_keys_required=False, ) await self.func(**kwargs) @@ -125,15 +116,12 @@

    Classes

    request: AsyncBoltRequest, response: Optional[BoltResponse], ) -> None: - all_available_args = _build_all_available_args( + kwargs: Dict[str, Any] = build_async_required_kwargs( + required_arg_names=self.arg_names, logger=self.logger, request=request, response=response, - ) - kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( - all_available_args=all_available_args, - arg_names=self.arg_names, - logger=self.logger, + next_keys_required=False, ) await self.func(**kwargs)
    diff --git a/docs/api-docs/slack_bolt/listener/async_listener_error_handler.html b/docs/api-docs/slack_bolt/listener/async_listener_error_handler.html index 5d1b73662..26e30d4fd 100644 --- a/docs/api-docs/slack_bolt/listener/async_listener_error_handler.html +++ b/docs/api-docs/slack_bolt/listener/async_listener_error_handler.html @@ -31,13 +31,7 @@

    Module slack_bolt.listener.async_listener_error_handler< from logging import Logger from typing import Callable, Dict, Any, Awaitable, Optional -from slack_bolt.listener.async_internals import ( - _build_all_available_args, -) -from slack_bolt.listener.internals import ( - _convert_all_available_args_to_kwargs, -) - +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse @@ -74,16 +68,13 @@

    Module slack_bolt.listener.async_listener_error_handler< request: AsyncBoltRequest, response: Optional[BoltResponse], ) -> None: - all_available_args = _build_all_available_args( + kwargs: Dict[str, Any] = build_async_required_kwargs( + required_arg_names=self.arg_names, logger=self.logger, error=error, request=request, response=response, - ) - kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( - all_available_args=all_available_args, - arg_names=self.arg_names, - logger=self.logger, + next_keys_required=False, ) returned_response = await self.func(**kwargs) if returned_response is not None and isinstance( @@ -141,16 +132,13 @@

    Classes

    request: AsyncBoltRequest, response: Optional[BoltResponse], ) -> None: - all_available_args = _build_all_available_args( + kwargs: Dict[str, Any] = build_async_required_kwargs( + required_arg_names=self.arg_names, logger=self.logger, error=error, request=request, response=response, - ) - kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( - all_available_args=all_available_args, - arg_names=self.arg_names, - logger=self.logger, + next_keys_required=False, ) returned_response = await self.func(**kwargs) if returned_response is not None and isinstance( diff --git a/docs/api-docs/slack_bolt/listener/index.html b/docs/api-docs/slack_bolt/listener/index.html index 0c6b922c1..3b639d9b3 100644 --- a/docs/api-docs/slack_bolt/listener/index.html +++ b/docs/api-docs/slack_bolt/listener/index.html @@ -53,10 +53,6 @@

    Sub-modules

    -
    slack_bolt.listener.async_internals
    -
    -
    -
    slack_bolt.listener.async_listener
    @@ -81,10 +77,6 @@

    Sub-modules

    -
    slack_bolt.listener.internals
    -
    -
    -
    slack_bolt.listener.listener
    @@ -124,14 +116,12 @@

    Index

  • Sub-modules

    • slack_bolt.listener.async_builtins
    • -
    • slack_bolt.listener.async_internals
    • slack_bolt.listener.async_listener
    • slack_bolt.listener.async_listener_completion_handler
    • slack_bolt.listener.async_listener_error_handler
    • slack_bolt.listener.asyncio_runner
    • slack_bolt.listener.builtins
    • slack_bolt.listener.custom_listener
    • -
    • slack_bolt.listener.internals
    • slack_bolt.listener.listener
    • slack_bolt.listener.listener_completion_handler
    • slack_bolt.listener.listener_error_handler
    • diff --git a/docs/api-docs/slack_bolt/listener/internals.html b/docs/api-docs/slack_bolt/listener/internals.html deleted file mode 100644 index c2104f104..000000000 --- a/docs/api-docs/slack_bolt/listener/internals.html +++ /dev/null @@ -1,133 +0,0 @@ - - - - - - -slack_bolt.listener.internals API documentation - - - - - - - - - - - -
      -
      -
      -

      Module slack_bolt.listener.internals

      -
      -
      -
      - -Expand source code - -
      from logging import Logger
      -from typing import Optional, Dict, Any, List
      -
      -from slack_bolt.request.request import BoltRequest
      -from slack_bolt.response.response import BoltResponse
      -
      -from slack_bolt.request.payload_utils import (
      -    to_options,
      -    to_shortcut,
      -    to_action,
      -    to_view,
      -    to_command,
      -    to_event,
      -    to_message,
      -    to_step,
      -)
      -
      -
      -def _build_all_available_args(
      -    logger: Logger,
      -    request: BoltRequest,
      -    response: Optional[BoltResponse],
      -    error: Optional[Exception] = None,
      -) -> Dict[str, Any]:
      -    all_available_args = {
      -        "logger": logger,
      -        "error": error,
      -        "client": request.context.client,
      -        "req": request,
      -        "request": request,
      -        "resp": response,
      -        "response": response,
      -        "context": request.context,
      -        # payload
      -        "body": request.body,
      -        "options": to_options(request.body),
      -        "shortcut": to_shortcut(request.body),
      -        "action": to_action(request.body),
      -        "view": to_view(request.body),
      -        "command": to_command(request.body),
      -        "event": to_event(request.body),
      -        "message": to_message(request.body),
      -        "step": to_step(request.body),
      -        # utilities
      -        "say": request.context.say,
      -        "respond": request.context.respond,
      -    }
      -    all_available_args["payload"] = (
      -        all_available_args["options"]
      -        or all_available_args["shortcut"]
      -        or all_available_args["action"]
      -        or all_available_args["view"]
      -        or all_available_args["command"]
      -        or all_available_args["event"]
      -        or all_available_args["message"]
      -        or all_available_args["step"]
      -        or request.body
      -    )
      -    return all_available_args
      -
      -
      -def _convert_all_available_args_to_kwargs(
      -    all_available_args: Dict[str, Any],
      -    arg_names: List[str],
      -    logger: Logger,
      -) -> Dict[str, Any]:
      -    kwargs: Dict[str, Any] = {  # type: ignore
      -        k: v for k, v in all_available_args.items() if k in arg_names  # type: ignore
      -    }
      -    found_arg_names = kwargs.keys()
      -    for name in arg_names:
      -        if name not in found_arg_names:
      -            logger.warning(f"{name} is not a valid argument")
      -            kwargs[name] = None
      -    return kwargs
      -
      -
      -
      -
      -
      -
      -
      -
      -
      -
      -
      - -
      - - - \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/listener.html b/docs/api-docs/slack_bolt/listener/listener.html index e9ebd3e61..1b27ecfe3 100644 --- a/docs/api-docs/slack_bolt/listener/listener.html +++ b/docs/api-docs/slack_bolt/listener/listener.html @@ -73,10 +73,10 @@

      Module slack_bolt.listener.listener

      for m in self.middleware: middleware_state = {"next_called": False} - def next(): + def next_(): middleware_state["next_called"] = True - resp = m.process(req=req, resp=resp, next=next) + resp = m.process(req=req, resp=resp, next=next_) if not middleware_state["next_called"]: # next() was not called in this middleware return (resp, True) @@ -154,10 +154,10 @@

      Classes

      for m in self.middleware: middleware_state = {"next_called": False} - def next(): + def next_(): middleware_state["next_called"] = True - resp = m.process(req=req, resp=resp, next=next) + resp = m.process(req=req, resp=resp, next=next_) if not middleware_state["next_called"]: # next() was not called in this middleware return (resp, True) @@ -300,10 +300,10 @@

      Returns

      for m in self.middleware: middleware_state = {"next_called": False} - def next(): + def next_(): middleware_state["next_called"] = True - resp = m.process(req=req, resp=resp, next=next) + resp = m.process(req=req, resp=resp, next=next_) if not middleware_state["next_called"]: # next() was not called in this middleware return (resp, True) diff --git a/docs/api-docs/slack_bolt/listener/listener_completion_handler.html b/docs/api-docs/slack_bolt/listener/listener_completion_handler.html index c4a976548..bac54d8dd 100644 --- a/docs/api-docs/slack_bolt/listener/listener_completion_handler.html +++ b/docs/api-docs/slack_bolt/listener/listener_completion_handler.html @@ -31,10 +31,7 @@

      Module slack_bolt.listener.listener_completion_handlerModule slack_bolt.listener.listener_completion_handlerClasses

      request: BoltRequest, response: Optional[BoltResponse], ): - all_available_args = _build_all_available_args( + kwargs: Dict[str, Any] = build_required_kwargs( + required_arg_names=self.arg_names, logger=self.logger, request=request, response=response, - ) - kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( - all_available_args=all_available_args, - arg_names=self.arg_names, - logger=self.logger, + next_keys_required=False, ) self.func(**kwargs) diff --git a/docs/api-docs/slack_bolt/listener/listener_error_handler.html b/docs/api-docs/slack_bolt/listener/listener_error_handler.html index 675de1360..c43127b7f 100644 --- a/docs/api-docs/slack_bolt/listener/listener_error_handler.html +++ b/docs/api-docs/slack_bolt/listener/listener_error_handler.html @@ -31,11 +31,7 @@

      Module slack_bolt.listener.listener_error_handler from logging import Logger from typing import Callable, Dict, Any, Optional -from slack_bolt.listener.internals import ( - _build_all_available_args, - _convert_all_available_args_to_kwargs, -) - +from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.request.request import BoltRequest from slack_bolt.response.response import BoltResponse @@ -70,16 +66,13 @@

      Module slack_bolt.listener.listener_error_handler request: BoltRequest, response: Optional[BoltResponse], ): - all_available_args = _build_all_available_args( + kwargs: Dict[str, Any] = build_required_kwargs( + required_arg_names=self.arg_names, logger=self.logger, error=error, request=request, response=response, - ) - kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( - all_available_args=all_available_args, - arg_names=self.arg_names, - logger=self.logger, + next_keys_required=False, ) returned_response = self.func(**kwargs) if returned_response is not None and isinstance( @@ -135,16 +128,13 @@

      Classes

      request: BoltRequest, response: Optional[BoltResponse], ): - all_available_args = _build_all_available_args( + kwargs: Dict[str, Any] = build_required_kwargs( + required_arg_names=self.arg_names, logger=self.logger, error=error, request=request, response=response, - ) - kwargs: Dict[str, Any] = _convert_all_available_args_to_kwargs( - all_available_args=all_available_args, - arg_names=self.arg_names, - logger=self.logger, + next_keys_required=False, ) returned_response = self.func(**kwargs) if returned_response is not None and isinstance( diff --git a/docs/api-docs/slack_bolt/logger/messages.html b/docs/api-docs/slack_bolt/logger/messages.html index 02976d2a1..a8897a065 100644 --- a/docs/api-docs/slack_bolt/logger/messages.html +++ b/docs/api-docs/slack_bolt/logger/messages.html @@ -127,7 +127,7 @@

      Module slack_bolt.logger.messages

      name: str, req: Union[BoltRequest, "AsyncBoltRequest"] # type: ignore ) -> str: # type: ignore return ( - f"A global middleware ({name}) skipped calling `next()` " + f"A global middleware ({name}) skipped calling either `next()` or `next_()` " f"without providing a response for the request ({req.body})" ) @@ -210,6 +210,7 @@

      Module slack_bolt.logger.messages

      ) -> str: # type: ignore filtered_body = _build_filtered_body(req.body) default_message = f"Unhandled request ({filtered_body})" + is_async = type(req) != BoltRequest if ( is_workflow_step_edit(req.body) or is_workflow_step_save(req.body) @@ -224,8 +225,8 @@

      Module slack_bolt.logger.messages

      return _build_unhandled_request_suggestion( default_message, f""" -from slack_bolt.workflows.step import WorkflowStep -ws = WorkflowStep( +from slack_bolt.workflows.step{'.async_step' if is_async else ''} import {'Async' if is_async else ''}WorkflowStep +ws = {'Async' if is_async else ''}WorkflowStep( callback_id="{callback_id}", edit=edit, save=save, @@ -244,8 +245,8 @@

      Module slack_bolt.logger.messages

      default_message, f""" @app.action("{action_id_or_callback_id}") -def handle_some_action(ack, body, logger): - ack() +{'async ' if is_async else ''}def handle_some_action(ack, body, logger): + {'await ' if is_async else ''}ack() logger.info(body) """, ) @@ -260,8 +261,8 @@

      Module slack_bolt.logger.messages

      default_message, f""" @app.options({constraints}) -def handle_some_options(ack): - ack(options=[ ... ]) +{'async ' if is_async else ''}def handle_some_options(ack): + {'await ' if is_async else ''}ack(options=[ ... ]) """, ) if is_shortcut(req.body): @@ -271,8 +272,8 @@

      Module slack_bolt.logger.messages

      default_message, f""" @app.shortcut("{id}") -def handle_shortcuts(ack, body, logger): - ack() +{'async ' if is_async else ''}def handle_shortcuts(ack, body, logger): + {'await ' if is_async else ''}ack() logger.info(body) """, ) @@ -282,8 +283,8 @@

      Module slack_bolt.logger.messages

      default_message, f""" @app.view("{req.body.get('view', {}).get('callback_id', 'modal-view-id')}") -def handle_view_events(ack, body, logger): - ack() +{'async ' if is_async else ''}def handle_view_events(ack, body, logger): + {'await ' if is_async else ''}ack() logger.info(body) """, ) @@ -294,7 +295,7 @@

      Module slack_bolt.logger.messages

      default_message, f""" @app.event("{event_type}") -def handle_{event_type}_events(body, logger): +{'async ' if is_async else ''}def handle_{event_type}_events(body, logger): logger.info(body) """, ) @@ -305,8 +306,8 @@

      Module slack_bolt.logger.messages

      default_message, f""" @app.command("{command}") -def handle_some_command(ack, body, logger): - ack() +{'async ' if is_async else ''}def handle_some_command(ack, body, logger): + {'await ' if is_async else ''}ack() logger.info(body) """, ) @@ -738,7 +739,7 @@

      Functions

      name: str, req: Union[BoltRequest, "AsyncBoltRequest"] # type: ignore ) -> str: # type: ignore return ( - f"A global middleware ({name}) skipped calling `next()` " + f"A global middleware ({name}) skipped calling either `next()` or `next_()` " f"without providing a response for the request ({req.body})" )
      @@ -757,6 +758,7 @@

      Functions

      ) -> str: # type: ignore filtered_body = _build_filtered_body(req.body) default_message = f"Unhandled request ({filtered_body})" + is_async = type(req) != BoltRequest if ( is_workflow_step_edit(req.body) or is_workflow_step_save(req.body) @@ -771,8 +773,8 @@

      Functions

      return _build_unhandled_request_suggestion( default_message, f""" -from slack_bolt.workflows.step import WorkflowStep -ws = WorkflowStep( +from slack_bolt.workflows.step{'.async_step' if is_async else ''} import {'Async' if is_async else ''}WorkflowStep +ws = {'Async' if is_async else ''}WorkflowStep( callback_id="{callback_id}", edit=edit, save=save, @@ -791,8 +793,8 @@

      Functions

      default_message, f""" @app.action("{action_id_or_callback_id}") -def handle_some_action(ack, body, logger): - ack() +{'async ' if is_async else ''}def handle_some_action(ack, body, logger): + {'await ' if is_async else ''}ack() logger.info(body) """, ) @@ -807,8 +809,8 @@

      Functions

      default_message, f""" @app.options({constraints}) -def handle_some_options(ack): - ack(options=[ ... ]) +{'async ' if is_async else ''}def handle_some_options(ack): + {'await ' if is_async else ''}ack(options=[ ... ]) """, ) if is_shortcut(req.body): @@ -818,8 +820,8 @@

      Functions

      default_message, f""" @app.shortcut("{id}") -def handle_shortcuts(ack, body, logger): - ack() +{'async ' if is_async else ''}def handle_shortcuts(ack, body, logger): + {'await ' if is_async else ''}ack() logger.info(body) """, ) @@ -829,8 +831,8 @@

      Functions

      default_message, f""" @app.view("{req.body.get('view', {}).get('callback_id', 'modal-view-id')}") -def handle_view_events(ack, body, logger): - ack() +{'async ' if is_async else ''}def handle_view_events(ack, body, logger): + {'await ' if is_async else ''}ack() logger.info(body) """, ) @@ -841,7 +843,7 @@

      Functions

      default_message, f""" @app.event("{event_type}") -def handle_{event_type}_events(body, logger): +{'async ' if is_async else ''}def handle_{event_type}_events(body, logger): logger.info(body) """, ) @@ -852,8 +854,8 @@

      Functions

      default_message, f""" @app.command("{command}") -def handle_some_command(ack, body, logger): - ack() +{'async ' if is_async else ''}def handle_some_command(ack, body, logger): + {'await ' if is_async else ''}ack() logger.info(body) """, ) diff --git a/docs/api-docs/slack_bolt/middleware/async_custom_middleware.html b/docs/api-docs/slack_bolt/middleware/async_custom_middleware.html index 8819f3f7f..88fd3dab0 100644 --- a/docs/api-docs/slack_bolt/middleware/async_custom_middleware.html +++ b/docs/api-docs/slack_bolt/middleware/async_custom_middleware.html @@ -59,6 +59,9 @@

      Module slack_bolt.middleware.async_custom_middlewareClasses

      *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: return await self.func( diff --git a/docs/api-docs/slack_bolt/middleware/async_middleware.html b/docs/api-docs/slack_bolt/middleware/async_middleware.html index 6bca6bb5d..d7edd2ceb 100644 --- a/docs/api-docs/slack_bolt/middleware/async_middleware.html +++ b/docs/api-docs/slack_bolt/middleware/async_middleware.html @@ -42,6 +42,9 @@

      Module slack_bolt.middleware.async_middleware

      Module slack_bolt.middleware.async_middleware
  • Classes

    *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> Optional[BoltResponse]: """Processes a request data before other middleware and listeners. @@ -105,6 +119,14 @@

    Classes

    # do something here await next() + This `async_process(req, resp, next)` method is supposed to be invoked only inside bolt-python. + If you want to avoid the name `next()` in your middleware functions, you can use `next_()` method instead. + + @app.middleware + async def simple_middleware(req, resp, next_): + # do something here + await next_() + Args: req: The incoming request resp: The response @@ -160,6 +182,13 @@

    Methods

    # do something here await next()
    +

    This async_process(req, resp, next) method is supposed to be invoked only inside bolt-python. +If you want to avoid the name next() in your middleware functions, you can use next_() method instead.

    +
    @app.middleware
    +async def simple_middleware(req, resp, next_):
    +    # do something here
    +    await next_()
    +

    Args

    req
    @@ -181,6 +210,9 @@

    Returns

    *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> Optional[BoltResponse]: """Processes a request data before other middleware and listeners. @@ -191,6 +223,14 @@

    Returns

    # do something here await next() + This `async_process(req, resp, next)` method is supposed to be invoked only inside bolt-python. + If you want to avoid the name `next()` in your middleware functions, you can use `next_()` method instead. + + @app.middleware + async def simple_middleware(req, resp, next_): + # do something here + await next_() + Args: req: The incoming request resp: The response diff --git a/docs/api-docs/slack_bolt/middleware/async_middleware_error_handler.html b/docs/api-docs/slack_bolt/middleware/async_middleware_error_handler.html new file mode 100644 index 000000000..9738cc16e --- /dev/null +++ b/docs/api-docs/slack_bolt/middleware/async_middleware_error_handler.html @@ -0,0 +1,307 @@ + + + + + + +slack_bolt.middleware.async_middleware_error_handler API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.middleware.async_middleware_error_handler

    +
    +
    +
    + +Expand source code + +
    import inspect
    +from abc import ABCMeta, abstractmethod
    +from logging import Logger
    +from typing import Callable, Dict, Any, Awaitable, Optional
    +
    +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs
    +from slack_bolt.request.async_request import AsyncBoltRequest
    +from slack_bolt.response import BoltResponse
    +
    +
    +class AsyncMiddlewareErrorHandler(metaclass=ABCMeta):
    +    @abstractmethod
    +    async def handle(
    +        self,
    +        error: Exception,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        """Handles an unhandled exception.
    +
    +        Args:
    +            error: The raised exception.
    +            request: The request.
    +            response: The response.
    +        """
    +        raise NotImplementedError()
    +
    +
    +class AsyncCustomMiddlewareErrorHandler(AsyncMiddlewareErrorHandler):
    +    def __init__(
    +        self, logger: Logger, func: Callable[..., Awaitable[Optional[BoltResponse]]]
    +    ):
    +        self.func = func
    +        self.logger = logger
    +        self.arg_names = inspect.getfullargspec(func).args
    +
    +    async def handle(
    +        self,
    +        error: Exception,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        kwargs: Dict[str, Any] = build_async_required_kwargs(
    +            required_arg_names=self.arg_names,
    +            logger=self.logger,
    +            error=error,
    +            request=request,
    +            response=response,
    +            next_keys_required=False,
    +        )
    +        returned_response = await self.func(**kwargs)
    +        if returned_response is not None and isinstance(
    +            returned_response, BoltResponse
    +        ):
    +            response.status = returned_response.status
    +            response.headers = returned_response.headers
    +            response.body = returned_response.body
    +
    +
    +class AsyncDefaultMiddlewareErrorHandler(AsyncMiddlewareErrorHandler):
    +    def __init__(self, logger: Logger):
    +        self.logger = logger
    +
    +    async def handle(
    +        self,
    +        error: Exception,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        message = f"Failed to run a middleware function (error: {error})"
    +        self.logger.exception(message)
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class AsyncCustomMiddlewareErrorHandler +(logger: logging.Logger, func: Callable[..., Awaitable[Optional[BoltResponse]]]) +
    +
    +
    +
    + +Expand source code + +
    class AsyncCustomMiddlewareErrorHandler(AsyncMiddlewareErrorHandler):
    +    def __init__(
    +        self, logger: Logger, func: Callable[..., Awaitable[Optional[BoltResponse]]]
    +    ):
    +        self.func = func
    +        self.logger = logger
    +        self.arg_names = inspect.getfullargspec(func).args
    +
    +    async def handle(
    +        self,
    +        error: Exception,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        kwargs: Dict[str, Any] = build_async_required_kwargs(
    +            required_arg_names=self.arg_names,
    +            logger=self.logger,
    +            error=error,
    +            request=request,
    +            response=response,
    +            next_keys_required=False,
    +        )
    +        returned_response = await self.func(**kwargs)
    +        if returned_response is not None and isinstance(
    +            returned_response, BoltResponse
    +        ):
    +            response.status = returned_response.status
    +            response.headers = returned_response.headers
    +            response.body = returned_response.body
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    +
    +class AsyncDefaultMiddlewareErrorHandler +(logger: logging.Logger) +
    +
    +
    +
    + +Expand source code + +
    class AsyncDefaultMiddlewareErrorHandler(AsyncMiddlewareErrorHandler):
    +    def __init__(self, logger: Logger):
    +        self.logger = logger
    +
    +    async def handle(
    +        self,
    +        error: Exception,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        message = f"Failed to run a middleware function (error: {error})"
    +        self.logger.exception(message)
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    +
    +class AsyncMiddlewareErrorHandler +
    +
    +
    +
    + +Expand source code + +
    class AsyncMiddlewareErrorHandler(metaclass=ABCMeta):
    +    @abstractmethod
    +    async def handle(
    +        self,
    +        error: Exception,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        """Handles an unhandled exception.
    +
    +        Args:
    +            error: The raised exception.
    +            request: The request.
    +            response: The response.
    +        """
    +        raise NotImplementedError()
    +
    +

    Subclasses

    + +

    Methods

    +
    +
    +async def handle(self, error: Exception, request: AsyncBoltRequest, response: Optional[BoltResponse]) ‑> NoneType +
    +
    +

    Handles an unhandled exception.

    +

    Args

    +
    +
    error
    +
    The raised exception.
    +
    request
    +
    The request.
    +
    response
    +
    The response.
    +
    +
    + +Expand source code + +
    @abstractmethod
    +async def handle(
    +    self,
    +    error: Exception,
    +    request: AsyncBoltRequest,
    +    response: Optional[BoltResponse],
    +) -> None:
    +    """Handles an unhandled exception.
    +
    +    Args:
    +        error: The raised exception.
    +        request: The request.
    +        response: The response.
    +    """
    +    raise NotImplementedError()
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html index 611e8f2b8..60ef76a9d 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html @@ -56,6 +56,9 @@

    Module slack_bolt.middleware.authorization.async_multi_t *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: if _is_no_auth_required(req): @@ -138,6 +141,9 @@

    Args

    *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: if _is_no_auth_required(req): diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html index 0b76ad6f1..15307fedf 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html @@ -50,6 +50,9 @@

    Module slack_bolt.middleware.authorization.async_single_ *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: if _is_no_auth_required(req): @@ -117,6 +120,9 @@

    Classes

    *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: if _is_no_auth_required(req): diff --git a/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html index 14da4c9ab..f588554de 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html @@ -58,6 +58,9 @@

    Module slack_bolt.middleware.authorization.single_team_a *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> BoltResponse: if _is_no_auth_required(req): @@ -135,6 +138,9 @@

    Args

    *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> BoltResponse: if _is_no_auth_required(req): diff --git a/docs/api-docs/slack_bolt/middleware/custom_middleware.html b/docs/api-docs/slack_bolt/middleware/custom_middleware.html index 6d6835d1c..39ad5cd79 100644 --- a/docs/api-docs/slack_bolt/middleware/custom_middleware.html +++ b/docs/api-docs/slack_bolt/middleware/custom_middleware.html @@ -55,6 +55,9 @@

    Module slack_bolt.middleware.custom_middlewareClasses

    *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> BoltResponse: return self.func( diff --git a/docs/api-docs/slack_bolt/middleware/index.html b/docs/api-docs/slack_bolt/middleware/index.html index fb9e2ae37..e19b89063 100644 --- a/docs/api-docs/slack_bolt/middleware/index.html +++ b/docs/api-docs/slack_bolt/middleware/index.html @@ -74,6 +74,10 @@

    Sub-modules

    +
    slack_bolt.middleware.async_middleware_error_handler
    +
    +
    +
    slack_bolt.middleware.authorization
    @@ -94,6 +98,10 @@

    Sub-modules

    +
    slack_bolt.middleware.middleware_error_handler
    +
    +
    +
    slack_bolt.middleware.request_verification
    @@ -131,11 +139,13 @@

    Index

  • slack_bolt.middleware.async_builtins
  • slack_bolt.middleware.async_custom_middleware
  • slack_bolt.middleware.async_middleware
  • +
  • slack_bolt.middleware.async_middleware_error_handler
  • slack_bolt.middleware.authorization
  • slack_bolt.middleware.custom_middleware
  • slack_bolt.middleware.ignoring_self_events
  • slack_bolt.middleware.message_listener_matches
  • slack_bolt.middleware.middleware
  • +
  • slack_bolt.middleware.middleware_error_handler
  • slack_bolt.middleware.request_verification
  • slack_bolt.middleware.ssl_check
  • slack_bolt.middleware.url_verification
  • diff --git a/docs/api-docs/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.html b/docs/api-docs/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.html index f9991db05..266574386 100644 --- a/docs/api-docs/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.html +++ b/docs/api-docs/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.html @@ -44,13 +44,20 @@

    Module slack_bolt.middleware.message_listener_matches.as *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: text = req.body.get("event", {}).get("text", "") if text: - m = re.search(self.keyword, text) - if m is not None: - req.context["matches"] = m.groups() # tuple + m = re.findall(self.keyword, text) + if m is not None and m != []: + if type(m[0]) is not tuple: + m = tuple(m) + else: + m = m[0] + req.context["matches"] = m # tuple or list return await next() # As the text doesn't match, skip running the listener @@ -87,13 +94,20 @@

    Classes

    *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: text = req.body.get("event", {}).get("text", "") if text: - m = re.search(self.keyword, text) - if m is not None: - req.context["matches"] = m.groups() # tuple + m = re.findall(self.keyword, text) + if m is not None and m != []: + if type(m[0]) is not tuple: + m = tuple(m) + else: + m = m[0] + req.context["matches"] = m # tuple or list return await next() # As the text doesn't match, skip running the listener diff --git a/docs/api-docs/slack_bolt/middleware/message_listener_matches/message_listener_matches.html b/docs/api-docs/slack_bolt/middleware/message_listener_matches/message_listener_matches.html index 336984bfc..60dc26b23 100644 --- a/docs/api-docs/slack_bolt/middleware/message_listener_matches/message_listener_matches.html +++ b/docs/api-docs/slack_bolt/middleware/message_listener_matches/message_listener_matches.html @@ -44,13 +44,20 @@

    Module slack_bolt.middleware.message_listener_matches.me *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> BoltResponse: text = req.body.get("event", {}).get("text", "") if text: - m = re.search(self.keyword, text) - if m is not None: - req.context["matches"] = m.groups() # tuple + m = re.findall(self.keyword, text) + if m is not None and m != []: + if type(m[0]) is not tuple: + m = tuple(m) + else: + m = m[0] + req.context["matches"] = m # tuple or list return next() # As the text doesn't match, skip running the listener @@ -87,13 +94,20 @@

    Classes

    *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> BoltResponse: text = req.body.get("event", {}).get("text", "") if text: - m = re.search(self.keyword, text) - if m is not None: - req.context["matches"] = m.groups() # tuple + m = re.findall(self.keyword, text) + if m is not None and m != []: + if type(m[0]) is not tuple: + m = tuple(m) + else: + m = m[0] + req.context["matches"] = m # tuple or list return next() # As the text doesn't match, skip running the listener diff --git a/docs/api-docs/slack_bolt/middleware/middleware.html b/docs/api-docs/slack_bolt/middleware/middleware.html index 39172c4b5..7c07cfb1b 100644 --- a/docs/api-docs/slack_bolt/middleware/middleware.html +++ b/docs/api-docs/slack_bolt/middleware/middleware.html @@ -42,6 +42,9 @@

    Module slack_bolt.middleware.middleware

    *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> Optional[BoltResponse]: """Processes a request data before other middleware and listeners. @@ -52,6 +55,14 @@

    Module slack_bolt.middleware.middleware

    # do something here next() + This `process(req, resp, next)` method is supposed to be invoked only inside bolt-python. + If you want to avoid the name `next()` in your middleware functions, you can use `next_()` method instead. + + @app.middleware + def simple_middleware(req, resp, next_): + # do something here + next_() + Args: req: The incoming request resp: The response @@ -95,6 +106,9 @@

    Classes

    *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> Optional[BoltResponse]: """Processes a request data before other middleware and listeners. @@ -105,6 +119,14 @@

    Classes

    # do something here next() + This `process(req, resp, next)` method is supposed to be invoked only inside bolt-python. + If you want to avoid the name `next()` in your middleware functions, you can use `next_()` method instead. + + @app.middleware + def simple_middleware(req, resp, next_): + # do something here + next_() + Args: req: The incoming request resp: The response @@ -160,6 +182,13 @@

    Methods

    # do something here next()
    +

    This process(req, resp, next) method is supposed to be invoked only inside bolt-python. +If you want to avoid the name next() in your middleware functions, you can use next_() method instead.

    +
    @app.middleware
    +def simple_middleware(req, resp, next_):
    +    # do something here
    +    next_()
    +

    Args

    req
    @@ -181,6 +210,9 @@

    Returns

    *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> Optional[BoltResponse]: """Processes a request data before other middleware and listeners. @@ -191,6 +223,14 @@

    Returns

    # do something here next() + This `process(req, resp, next)` method is supposed to be invoked only inside bolt-python. + If you want to avoid the name `next()` in your middleware functions, you can use `next_()` method instead. + + @app.middleware + def simple_middleware(req, resp, next_): + # do something here + next_() + Args: req: The incoming request resp: The response diff --git a/docs/api-docs/slack_bolt/middleware/middleware_error_handler.html b/docs/api-docs/slack_bolt/middleware/middleware_error_handler.html new file mode 100644 index 000000000..ff2d0b318 --- /dev/null +++ b/docs/api-docs/slack_bolt/middleware/middleware_error_handler.html @@ -0,0 +1,303 @@ + + + + + + +slack_bolt.middleware.middleware_error_handler API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.middleware.middleware_error_handler

    +
    +
    +
    + +Expand source code + +
    import inspect
    +from abc import ABCMeta, abstractmethod
    +from logging import Logger
    +from typing import Callable, Optional, Any, Dict
    +
    +from slack_bolt.kwargs_injection.utils import build_required_kwargs
    +from slack_bolt.request.request import BoltRequest
    +from slack_bolt.response.response import BoltResponse
    +
    +
    +class MiddlewareErrorHandler(metaclass=ABCMeta):
    +    @abstractmethod
    +    def handle(
    +        self,
    +        error: Exception,
    +        request: BoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        """Handles an unhandled exception.
    +
    +        Args:
    +            error: The raised exception.
    +            request: The request.
    +            response: The response.
    +        """
    +        raise NotImplementedError()
    +
    +
    +class CustomMiddlewareErrorHandler(MiddlewareErrorHandler):
    +    def __init__(self, logger: Logger, func: Callable[..., Optional[BoltResponse]]):
    +        self.func = func
    +        self.logger = logger
    +        self.arg_names = inspect.getfullargspec(func).args
    +
    +    def handle(
    +        self,
    +        error: Exception,
    +        request: BoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        kwargs: Dict[str, Any] = build_required_kwargs(
    +            required_arg_names=self.arg_names,
    +            logger=self.logger,
    +            error=error,
    +            request=request,
    +            response=response,
    +            next_keys_required=False,
    +        )
    +        returned_response = self.func(**kwargs)
    +        if returned_response is not None and isinstance(
    +            returned_response, BoltResponse
    +        ):
    +            response.status = returned_response.status
    +            response.headers = returned_response.headers
    +            response.body = returned_response.body
    +
    +
    +class DefaultMiddlewareErrorHandler(MiddlewareErrorHandler):
    +    def __init__(self, logger: Logger):
    +        self.logger = logger
    +
    +    def handle(
    +        self,
    +        error: Exception,
    +        request: BoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        message = f"Failed to run a middleware middleware (error: {error})"
    +        self.logger.exception(message)
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class CustomMiddlewareErrorHandler +(logger: logging.Logger, func: Callable[..., Optional[BoltResponse]]) +
    +
    +
    +
    + +Expand source code + +
    class CustomMiddlewareErrorHandler(MiddlewareErrorHandler):
    +    def __init__(self, logger: Logger, func: Callable[..., Optional[BoltResponse]]):
    +        self.func = func
    +        self.logger = logger
    +        self.arg_names = inspect.getfullargspec(func).args
    +
    +    def handle(
    +        self,
    +        error: Exception,
    +        request: BoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        kwargs: Dict[str, Any] = build_required_kwargs(
    +            required_arg_names=self.arg_names,
    +            logger=self.logger,
    +            error=error,
    +            request=request,
    +            response=response,
    +            next_keys_required=False,
    +        )
    +        returned_response = self.func(**kwargs)
    +        if returned_response is not None and isinstance(
    +            returned_response, BoltResponse
    +        ):
    +            response.status = returned_response.status
    +            response.headers = returned_response.headers
    +            response.body = returned_response.body
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    +
    +class DefaultMiddlewareErrorHandler +(logger: logging.Logger) +
    +
    +
    +
    + +Expand source code + +
    class DefaultMiddlewareErrorHandler(MiddlewareErrorHandler):
    +    def __init__(self, logger: Logger):
    +        self.logger = logger
    +
    +    def handle(
    +        self,
    +        error: Exception,
    +        request: BoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        message = f"Failed to run a middleware middleware (error: {error})"
    +        self.logger.exception(message)
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    +
    +class MiddlewareErrorHandler +
    +
    +
    +
    + +Expand source code + +
    class MiddlewareErrorHandler(metaclass=ABCMeta):
    +    @abstractmethod
    +    def handle(
    +        self,
    +        error: Exception,
    +        request: BoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        """Handles an unhandled exception.
    +
    +        Args:
    +            error: The raised exception.
    +            request: The request.
    +            response: The response.
    +        """
    +        raise NotImplementedError()
    +
    +

    Subclasses

    + +

    Methods

    +
    +
    +def handle(self, error: Exception, request: BoltRequest, response: Optional[BoltResponse]) ‑> NoneType +
    +
    +

    Handles an unhandled exception.

    +

    Args

    +
    +
    error
    +
    The raised exception.
    +
    request
    +
    The request.
    +
    response
    +
    The response.
    +
    +
    + +Expand source code + +
    @abstractmethod
    +def handle(
    +    self,
    +    error: Exception,
    +    request: BoltRequest,
    +    response: Optional[BoltResponse],
    +) -> None:
    +    """Handles an unhandled exception.
    +
    +    Args:
    +        error: The raised exception.
    +        request: The request.
    +        response: The response.
    +    """
    +    raise NotImplementedError()
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html b/docs/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html index 21e726a52..5426e8beb 100644 --- a/docs/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html +++ b/docs/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html @@ -46,6 +46,9 @@

    Module slack_bolt.middleware.request_verification.async_ *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: if self._can_skip(req.mode, req.body): @@ -102,6 +105,9 @@

    Args

    *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: if self._can_skip(req.mode, req.body): diff --git a/docs/api-docs/slack_bolt/middleware/request_verification/request_verification.html b/docs/api-docs/slack_bolt/middleware/request_verification/request_verification.html index aac34e2e9..c6a70ec2e 100644 --- a/docs/api-docs/slack_bolt/middleware/request_verification/request_verification.html +++ b/docs/api-docs/slack_bolt/middleware/request_verification/request_verification.html @@ -54,6 +54,9 @@

    Module slack_bolt.middleware.request_verification.reques *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> BoltResponse: if self._can_skip(req.mode, req.body): @@ -132,6 +135,9 @@

    Args

    *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> BoltResponse: if self._can_skip(req.mode, req.body): diff --git a/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html b/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html index 98468b075..e29cb2a1e 100644 --- a/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html +++ b/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html @@ -40,6 +40,9 @@

    Module slack_bolt.middleware.ssl_check.async_ssl_checkClasses

    class AsyncSslCheck -(verification_token: str = None) +(verification_token: Optional[str] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -83,6 +86,9 @@

    Args

    *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: if self._is_ssl_check_request(req.body): @@ -98,6 +104,17 @@

    Ancestors

  • Middleware
  • AsyncMiddleware
  • +

    Class variables

    +
    +
    var logger : logging.Logger
    +
    +
    +
    +
    var verification_token : Optional[str]
    +
    +
    +
    +

    Inherited members

    • SslCheck: @@ -131,6 +148,10 @@

      Index

    • diff --git a/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html b/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html index dc8a2e9f4..cc328c988 100644 --- a/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html +++ b/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html @@ -26,7 +26,8 @@

      Module slack_bolt.middleware.ssl_check.ssl_check< Expand source code -
      from typing import Callable
      +
      from logging import Logger
      +from typing import Callable, Optional
       
       from slack_bolt.logger import get_bolt_logger
       from slack_bolt.middleware.middleware import Middleware
      @@ -35,7 +36,10 @@ 

      Module slack_bolt.middleware.ssl_check.ssl_check< class SslCheck(Middleware): # type: ignore - def __init__(self, verification_token: str = None): + verification_token: Optional[str] + logger: Logger + + def __init__(self, verification_token: Optional[str] = None): """Handles `ssl_check` requests. Refer to https://api.slack.com/interactivity/slash-commands for details. @@ -51,6 +55,9 @@

      Module slack_bolt.middleware.ssl_check.ssl_check< *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> BoltResponse: if self._is_ssl_check_request(req.body): @@ -89,7 +96,7 @@

      Classes

      class SslCheck -(verification_token: str = None) +(verification_token: Optional[str] = None)

      A middleware can process request data before other middleware and listener functions.

      @@ -106,7 +113,10 @@

      Args

      Expand source code
      class SslCheck(Middleware):  # type: ignore
      -    def __init__(self, verification_token: str = None):
      +    verification_token: Optional[str]
      +    logger: Logger
      +
      +    def __init__(self, verification_token: Optional[str] = None):
               """Handles `ssl_check` requests.
               Refer to https://api.slack.com/interactivity/slash-commands for details.
       
      @@ -122,6 +132,9 @@ 

      Args

      *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> BoltResponse: if self._is_ssl_check_request(req.body): @@ -156,6 +169,17 @@

      Subclasses

      +

      Class variables

      +
      +
      var logger : logging.Logger
      +
      +
      +
      +
      var verification_token : Optional[str]
      +
      +
      +
      +

      Inherited members

      • Middleware: @@ -184,6 +208,10 @@

        Index

      • diff --git a/docs/api-docs/slack_bolt/middleware/url_verification/url_verification.html b/docs/api-docs/slack_bolt/middleware/url_verification/url_verification.html index fdd131fb3..82789210b 100644 --- a/docs/api-docs/slack_bolt/middleware/url_verification/url_verification.html +++ b/docs/api-docs/slack_bolt/middleware/url_verification/url_verification.html @@ -47,6 +47,9 @@

        Module slack_bolt.middleware.url_verification.url_verifi *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> BoltResponse: if self._is_url_verification_request(req.body): @@ -98,6 +101,9 @@

        Classes

        *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> BoltResponse: if self._is_url_verification_request(req.body): diff --git a/docs/api-docs/slack_bolt/oauth/async_oauth_flow.html b/docs/api-docs/slack_bolt/oauth/async_oauth_flow.html index ba5439965..3730a317f 100644 --- a/docs/api-docs/slack_bolt/oauth/async_oauth_flow.html +++ b/docs/api-docs/slack_bolt/oauth/async_oauth_flow.html @@ -366,9 +366,13 @@

        Module slack_bolt.oauth.async_oauth_flow

        bot_id=bot_id, bot_user_id=oauth_response.get("bot_user_id"), bot_scopes=oauth_response.get("scope"), # comma-separated string + bot_refresh_token=oauth_response.get("refresh_token"), # since v1.7 + bot_token_expires_in=oauth_response.get("expires_in"), # since v1.7 user_id=installer.get("id"), user_token=installer.get("access_token"), user_scopes=installer.get("scope"), # comma-separated string + user_refresh_token=installer.get("refresh_token"), # since v1.7 + user_token_expires_in=installer.get("expires_in"), # since v1.7 incoming_webhook_url=incoming_webhook.get("url"), incoming_webhook_channel=incoming_webhook.get("channel"), incoming_webhook_channel_id=incoming_webhook.get("channel_id"), @@ -733,9 +737,13 @@

        Args

        bot_id=bot_id, bot_user_id=oauth_response.get("bot_user_id"), bot_scopes=oauth_response.get("scope"), # comma-separated string + bot_refresh_token=oauth_response.get("refresh_token"), # since v1.7 + bot_token_expires_in=oauth_response.get("expires_in"), # since v1.7 user_id=installer.get("id"), user_token=installer.get("access_token"), user_scopes=installer.get("scope"), # comma-separated string + user_refresh_token=installer.get("refresh_token"), # since v1.7 + user_token_expires_in=installer.get("expires_in"), # since v1.7 incoming_webhook_url=incoming_webhook.get("url"), incoming_webhook_channel=incoming_webhook.get("channel"), incoming_webhook_channel_id=incoming_webhook.get("channel_id"), @@ -1129,9 +1137,13 @@

        Methods

        bot_id=bot_id, bot_user_id=oauth_response.get("bot_user_id"), bot_scopes=oauth_response.get("scope"), # comma-separated string + bot_refresh_token=oauth_response.get("refresh_token"), # since v1.7 + bot_token_expires_in=oauth_response.get("expires_in"), # since v1.7 user_id=installer.get("id"), user_token=installer.get("access_token"), user_scopes=installer.get("scope"), # comma-separated string + user_refresh_token=installer.get("refresh_token"), # since v1.7 + user_token_expires_in=installer.get("expires_in"), # since v1.7 incoming_webhook_url=incoming_webhook.get("url"), incoming_webhook_channel=incoming_webhook.get("channel"), incoming_webhook_channel_id=incoming_webhook.get("channel_id"), diff --git a/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html b/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html index 5c3a4a0f7..29e0eaf01 100644 --- a/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html +++ b/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html @@ -69,6 +69,7 @@

        Module slack_bolt.oauth.async_oauth_settings

        # Installation Management installation_store: AsyncInstallationStore installation_store_bot_only: bool + token_rotation_expiration_minutes: int authorize: AsyncAuthorize # state parameter related configurations state_store: AsyncOAuthStateStore @@ -101,6 +102,7 @@

        Module slack_bolt.oauth.async_oauth_settings

        # Installation Management installation_store: Optional[AsyncInstallationStore] = None, installation_store_bot_only: bool = False, + token_rotation_expiration_minutes: int = 120, # state parameter related configurations state_store: Optional[AsyncOAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, @@ -125,6 +127,7 @@

        Module slack_bolt.oauth.async_oauth_settings

        authorization_url: Set a URL if you want to customize the URL `https://slack.com/oauth/v2/authorize` installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) + token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) @@ -168,8 +171,12 @@

        Module slack_bolt.oauth.async_oauth_settings

        installation_store or get_or_create_default_installation_store(client_id) ) self.installation_store_bot_only = installation_store_bot_only + self.token_rotation_expiration_minutes = token_rotation_expiration_minutes self.authorize = AsyncInstallationStoreAuthorize( logger=logger, + client_id=self.client_id, + client_secret=self.client_secret, + token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, installation_store=self.installation_store, bot_only=self.installation_store_bot_only, ) @@ -211,7 +218,7 @@

        Classes

        class AsyncOAuthSettings -(*, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Union[Sequence[str], str, NoneType] = None, user_scopes: Union[Sequence[str], str, NoneType] = None, redirect_uri: Optional[str] = None, install_path: str = '/slack/install', install_page_rendering_enabled: bool = True, redirect_uri_path: str = '/slack/oauth_redirect', callback_options: Optional[AsyncCallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: bool = False, state_store: Optional[slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, logger: logging.Logger = <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>) +(*, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Union[str, Sequence[str], NoneType] = None, user_scopes: Union[str, Sequence[str], NoneType] = None, redirect_uri: Optional[str] = None, install_path: str = '/slack/install', install_page_rendering_enabled: bool = True, redirect_uri_path: str = '/slack/oauth_redirect', callback_options: Optional[AsyncCallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, state_store: Optional[slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, logger: logging.Logger = <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>)

        The settings for Slack App installation (OAuth flow).

        @@ -245,6 +252,8 @@

        Args

        Specify the instance of InstallationStore (Default: FileInstallationStore)
        installation_store_bot_only
        Use InstallationStore#find_bot() if True (Default: False)
        +
        token_rotation_expiration_minutes
        +
        Minutes before refreshing tokens (Default: 2 hours)
        state_store
        Specify the instance of InstallationStore (Default: FileOAuthStateStore)
        state_cookie_name
        @@ -276,6 +285,7 @@

        Args

        # Installation Management installation_store: AsyncInstallationStore installation_store_bot_only: bool + token_rotation_expiration_minutes: int authorize: AsyncAuthorize # state parameter related configurations state_store: AsyncOAuthStateStore @@ -308,6 +318,7 @@

        Args

        # Installation Management installation_store: Optional[AsyncInstallationStore] = None, installation_store_bot_only: bool = False, + token_rotation_expiration_minutes: int = 120, # state parameter related configurations state_store: Optional[AsyncOAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, @@ -332,6 +343,7 @@

        Args

        authorization_url: Set a URL if you want to customize the URL `https://slack.com/oauth/v2/authorize` installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) + token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) @@ -375,8 +387,12 @@

        Args

        installation_store or get_or_create_default_installation_store(client_id) ) self.installation_store_bot_only = installation_store_bot_only + self.token_rotation_expiration_minutes = token_rotation_expiration_minutes self.authorize = AsyncInstallationStoreAuthorize( logger=logger, + client_id=self.client_id, + client_secret=self.client_secret, + token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, installation_store=self.installation_store, bot_only=self.installation_store_bot_only, ) @@ -492,6 +508,10 @@

        Class variables

        +
        var token_rotation_expiration_minutes : int
        +
        +
        +
        var user_scopes : Optional[Sequence[str]]
        @@ -538,6 +558,7 @@

        state_store
      • state_utils
      • success_url
      • +
      • token_rotation_expiration_minutes
      • user_scopes
      diff --git a/docs/api-docs/slack_bolt/oauth/internals.html b/docs/api-docs/slack_bolt/oauth/internals.html index 30864eb75..68a5ce7bc 100644 --- a/docs/api-docs/slack_bolt/oauth/internals.html +++ b/docs/api-docs/slack_bolt/oauth/internals.html @@ -102,6 +102,7 @@

      Module slack_bolt.oauth.internals

      def _build_default_install_page_html(url: str) -> str: return f"""<html> <head> +<link rel="icon" href="data:,"> <style> body {{ padding: 10px 15px; diff --git a/docs/api-docs/slack_bolt/oauth/oauth_flow.html b/docs/api-docs/slack_bolt/oauth/oauth_flow.html index ba6b75c03..4d345e60b 100644 --- a/docs/api-docs/slack_bolt/oauth/oauth_flow.html +++ b/docs/api-docs/slack_bolt/oauth/oauth_flow.html @@ -135,6 +135,7 @@

      Module slack_bolt.oauth.oauth_flow

      state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, installation_store_bot_only: bool = False, + token_rotation_expiration_minutes: int = 120, client: Optional[WebClient] = None, logger: Optional[Logger] = None, ) -> "OAuthFlow": @@ -168,6 +169,7 @@

      Module slack_bolt.oauth.oauth_flow

      logger=logger, ), installation_store_bot_only=installation_store_bot_only, + token_rotation_expiration_minutes=token_rotation_expiration_minutes, # state parameter related configurations state_store=SQLite3OAuthStateStore( database=database, @@ -361,9 +363,13 @@

      Module slack_bolt.oauth.oauth_flow

      bot_id=bot_id, bot_user_id=oauth_response.get("bot_user_id"), bot_scopes=oauth_response.get("scope"), # comma-separated string + bot_refresh_token=oauth_response.get("refresh_token"), # since v1.7 + bot_token_expires_in=oauth_response.get("expires_in"), # since v1.7 user_id=installer.get("id"), user_token=installer.get("access_token"), user_scopes=installer.get("scope"), # comma-separated string + user_refresh_token=installer.get("refresh_token"), # since v1.7 + user_token_expires_in=installer.get("expires_in"), # since v1.7 incoming_webhook_url=incoming_webhook.get("url"), incoming_webhook_channel=incoming_webhook.get("channel"), incoming_webhook_channel_id=incoming_webhook.get("channel_id"), @@ -496,6 +502,7 @@

      Args

      state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, installation_store_bot_only: bool = False, + token_rotation_expiration_minutes: int = 120, client: Optional[WebClient] = None, logger: Optional[Logger] = None, ) -> "OAuthFlow": @@ -529,6 +536,7 @@

      Args

      logger=logger, ), installation_store_bot_only=installation_store_bot_only, + token_rotation_expiration_minutes=token_rotation_expiration_minutes, # state parameter related configurations state_store=SQLite3OAuthStateStore( database=database, @@ -722,9 +730,13 @@

      Args

      bot_id=bot_id, bot_user_id=oauth_response.get("bot_user_id"), bot_scopes=oauth_response.get("scope"), # comma-separated string + bot_refresh_token=oauth_response.get("refresh_token"), # since v1.7 + bot_token_expires_in=oauth_response.get("expires_in"), # since v1.7 user_id=installer.get("id"), user_token=installer.get("access_token"), user_scopes=installer.get("scope"), # comma-separated string + user_refresh_token=installer.get("refresh_token"), # since v1.7 + user_token_expires_in=installer.get("expires_in"), # since v1.7 incoming_webhook_url=incoming_webhook.get("url"), incoming_webhook_channel=incoming_webhook.get("channel"), incoming_webhook_channel_id=incoming_webhook.get("channel_id"), @@ -784,7 +796,7 @@

      Class variables

      Static methods

      -def sqlite3(database: str, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Optional[Sequence[str]] = None, user_scopes: Optional[Sequence[str]] = None, redirect_uri: Optional[str] = None, install_path: Optional[str] = None, redirect_uri_path: Optional[str] = None, callback_options: Optional[CallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, installation_store_bot_only: bool = False, client: Optional[slack_sdk.web.client.WebClient] = None, logger: Optional[logging.Logger] = None) ‑> OAuthFlow +def sqlite3(database: str, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Optional[Sequence[str]] = None, user_scopes: Optional[Sequence[str]] = None, redirect_uri: Optional[str] = None, install_path: Optional[str] = None, redirect_uri_path: Optional[str] = None, callback_options: Optional[CallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, client: Optional[slack_sdk.web.client.WebClient] = None, logger: Optional[logging.Logger] = None) ‑> OAuthFlow
      @@ -814,6 +826,7 @@

      Static methods

      state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, installation_store_bot_only: bool = False, + token_rotation_expiration_minutes: int = 120, client: Optional[WebClient] = None, logger: Optional[Logger] = None, ) -> "OAuthFlow": @@ -847,6 +860,7 @@

      Static methods

      logger=logger, ), installation_store_bot_only=installation_store_bot_only, + token_rotation_expiration_minutes=token_rotation_expiration_minutes, # state parameter related configurations state_store=SQLite3OAuthStateStore( database=database, @@ -1120,9 +1134,13 @@

      Methods

      bot_id=bot_id, bot_user_id=oauth_response.get("bot_user_id"), bot_scopes=oauth_response.get("scope"), # comma-separated string + bot_refresh_token=oauth_response.get("refresh_token"), # since v1.7 + bot_token_expires_in=oauth_response.get("expires_in"), # since v1.7 user_id=installer.get("id"), user_token=installer.get("access_token"), user_scopes=installer.get("scope"), # comma-separated string + user_refresh_token=installer.get("refresh_token"), # since v1.7 + user_token_expires_in=installer.get("expires_in"), # since v1.7 incoming_webhook_url=incoming_webhook.get("url"), incoming_webhook_channel=incoming_webhook.get("channel"), incoming_webhook_channel_id=incoming_webhook.get("channel_id"), diff --git a/docs/api-docs/slack_bolt/oauth/oauth_settings.html b/docs/api-docs/slack_bolt/oauth/oauth_settings.html index 4624e9f85..83cee181b 100644 --- a/docs/api-docs/slack_bolt/oauth/oauth_settings.html +++ b/docs/api-docs/slack_bolt/oauth/oauth_settings.html @@ -64,6 +64,7 @@

      Module slack_bolt.oauth.oauth_settings

      # Installation Management installation_store: InstallationStore installation_store_bot_only: bool + token_rotation_expiration_minutes: int authorize: Authorize # state parameter related configurations state_store: OAuthStateStore @@ -96,6 +97,7 @@

      Module slack_bolt.oauth.oauth_settings

      # Installation Management installation_store: Optional[InstallationStore] = None, installation_store_bot_only: bool = False, + token_rotation_expiration_minutes: int = 120, # state parameter related configurations state_store: Optional[OAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, @@ -120,6 +122,7 @@

      Module slack_bolt.oauth.oauth_settings

      authorization_url: Set a URL if you want to customize the URL `https://slack.com/oauth/v2/authorize` installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) + token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) @@ -162,8 +165,12 @@

      Module slack_bolt.oauth.oauth_settings

      installation_store or get_or_create_default_installation_store(client_id) ) self.installation_store_bot_only = installation_store_bot_only + self.token_rotation_expiration_minutes = token_rotation_expiration_minutes self.authorize = InstallationStoreAuthorize( logger=logger, + client_id=self.client_id, + client_secret=self.client_secret, + token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, installation_store=self.installation_store, bot_only=self.installation_store_bot_only, ) @@ -205,7 +212,7 @@

      Classes

      class OAuthSettings -(*, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Union[Sequence[str], str, NoneType] = None, user_scopes: Union[Sequence[str], str, NoneType] = None, redirect_uri: Optional[str] = None, install_path: str = '/slack/install', install_page_rendering_enabled: bool = True, redirect_uri_path: str = '/slack/oauth_redirect', callback_options: Optional[CallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: bool = False, state_store: Optional[slack_sdk.oauth.state_store.state_store.OAuthStateStore] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, logger: logging.Logger = <Logger slack_bolt.oauth.oauth_settings (WARNING)>) +(*, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Union[str, Sequence[str], NoneType] = None, user_scopes: Union[str, Sequence[str], NoneType] = None, redirect_uri: Optional[str] = None, install_path: str = '/slack/install', install_page_rendering_enabled: bool = True, redirect_uri_path: str = '/slack/oauth_redirect', callback_options: Optional[CallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, state_store: Optional[slack_sdk.oauth.state_store.state_store.OAuthStateStore] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, logger: logging.Logger = <Logger slack_bolt.oauth.oauth_settings (WARNING)>)

      The settings for Slack App installation (OAuth flow).

      @@ -239,6 +246,8 @@

      Args

      Specify the instance of InstallationStore (Default: FileInstallationStore)
      installation_store_bot_only
      Use InstallationStore#find_bot() if True (Default: False)
      +
      token_rotation_expiration_minutes
      +
      Minutes before refreshing tokens (Default: 2 hours)
      state_store
      Specify the instance of InstallationStore (Default: FileOAuthStateStore)
      state_cookie_name
      @@ -270,6 +279,7 @@

      Args

      # Installation Management installation_store: InstallationStore installation_store_bot_only: bool + token_rotation_expiration_minutes: int authorize: Authorize # state parameter related configurations state_store: OAuthStateStore @@ -302,6 +312,7 @@

      Args

      # Installation Management installation_store: Optional[InstallationStore] = None, installation_store_bot_only: bool = False, + token_rotation_expiration_minutes: int = 120, # state parameter related configurations state_store: Optional[OAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, @@ -326,6 +337,7 @@

      Args

      authorization_url: Set a URL if you want to customize the URL `https://slack.com/oauth/v2/authorize` installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) + token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) @@ -368,8 +380,12 @@

      Args

      installation_store or get_or_create_default_installation_store(client_id) ) self.installation_store_bot_only = installation_store_bot_only + self.token_rotation_expiration_minutes = token_rotation_expiration_minutes self.authorize = InstallationStoreAuthorize( logger=logger, + client_id=self.client_id, + client_secret=self.client_secret, + token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, installation_store=self.installation_store, bot_only=self.installation_store_bot_only, ) @@ -485,6 +501,10 @@

      Class variables

      +
      var token_rotation_expiration_minutes : int
      +
      +
      +
      var user_scopes : Optional[Sequence[str]]
      @@ -531,6 +551,7 @@

      state_store
    • state_utils
    • success_url
    • +
    • token_rotation_expiration_minutes
    • user_scopes
    diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index c2b60cf6b..9ad0e32f7 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.6.1"
    +__version__ = "1.8.0"

    diff --git a/docs/api-docs/slack_bolt/workflows/step/step_middleware.html b/docs/api-docs/slack_bolt/workflows/step/step_middleware.html index 8be25951a..a80d4d335 100644 --- a/docs/api-docs/slack_bolt/workflows/step/step_middleware.html +++ b/docs/api-docs/slack_bolt/workflows/step/step_middleware.html @@ -49,6 +49,9 @@

    Module slack_bolt.workflows.step.step_middleware< *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> Optional[BoltResponse]: @@ -116,6 +119,9 @@

    Classes

    *, req: BoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], BoltResponse], ) -> Optional[BoltResponse]: diff --git a/setup.py b/setup.py index 14be59c33..fef36e7e2 100755 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ ), include_package_data=True, # MANIFEST.in install_requires=[ - "slack_sdk>=3.9.0rc1,<4", # TODO: Update once v3.9.0 is released + "slack_sdk>=3.9.0,<4", ], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 89f6876ab..f9c62ba4d 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.8.0rc1" +__version__ = "1.8.0" From 3a3e349421524be99d234d5b0feb919ea93f406d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 26 Aug 2021 07:24:17 +0900 Subject: [PATCH 376/865] Update the entity name (#446) --- LICENSE | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index dfbeb4a96..f2e8442ad 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2020- Slack Technologies, Inc +Copyright (c) 2020- Slack Technologies, LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/setup.py b/setup.py index fef36e7e2..fd8a09e2a 100755 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ name="slack_bolt", version=__version__, license="MIT", - author="Slack Technologies, Inc.", + author="Slack Technologies, LLC", author_email="opensource@slack.com", description="The Bolt Framework for Python", long_description=long_description, From 93bd437682203aff5f4409e84ca8590612a3c49b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 26 Aug 2021 11:26:59 +0900 Subject: [PATCH 377/865] Improve the mock server for testing to work with additional query string values --- tests/mock_web_api_server.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index 9e6d008e1..2c3204912 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -8,7 +8,7 @@ from http.server import HTTPServer, SimpleHTTPRequestHandler from typing import Type from unittest import TestCase -from urllib.parse import urlparse, parse_qs +from urllib.parse import urlparse, parse_qs, ParseResult from multiprocessing import Process from urllib.request import urlopen, Request @@ -90,38 +90,38 @@ def set_common_headers(self): """ def _handle(self): - self.received_requests[self.path] = self.received_requests.get(self.path, 0) + 1 + parsed_path: ParseResult = urlparse(self.path) + path = parsed_path.path + self.received_requests[path] = self.received_requests.get(path, 0) + 1 try: - if self.path == "/webhook": + if path == "/webhook": self.send_response(200) self.set_common_headers() self.wfile.write("OK".encode("utf-8")) return - if self.path == "/received_requests.json": + if path == "/received_requests.json": self.send_response(200) self.set_common_headers() self.wfile.write(json.dumps(self.received_requests).encode("utf-8")) return body = {"ok": True} - if self.path == "/oauth.v2.access": + if path == "/oauth.v2.access": self.send_response(200) self.set_common_headers() self.wfile.write(self.oauth_v2_access_response.encode("utf-8")) return if self.is_valid_user_token(): - if self.path == "/auth.test": + if path == "/auth.test": self.send_response(200) self.set_common_headers() self.wfile.write(self.user_auth_test_response.encode("utf-8")) return if self.is_valid_token(): - parsed_path = urlparse(self.path) - - if self.path == "/auth.test": + if path == "/auth.test": self.send_response(200) self.set_common_headers() self.wfile.write(self.bot_auth_test_response.encode("utf-8")) @@ -148,7 +148,7 @@ def _handle(self): k: v[0] for k, v in parse_qs(parsed_path.query).items() } - self.logger.info(f"request body: {request_body}") + self.logger.info(f"request: {path} {request_body}") header = self.headers["authorization"] pattern = str(header).split("xoxb-", 1)[1] From 4ec44449ae7e331ec01871f99aae90de7c41eaef Mon Sep 17 00:00:00 2001 From: Jennifer Kramer Date: Wed, 25 Aug 2021 21:42:09 -0500 Subject: [PATCH 378/865] Fix app.html doc typo (#449) --- docs/api-docs/slack_bolt/app/app.html | 6 +++--- docs/api-docs/slack_bolt/app/async_app.html | 6 +++--- slack_bolt/app/app.py | 2 +- slack_bolt/app/async_app.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index 9d7e7ce51..1f10657ec 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -165,7 +165,7 @@

    Module slack_bolt.app.app

    Refer to https://slack.dev/bolt-python/tutorial/getting-started for details. - If yoy would like to build an OAuth app for enabling the app to run with multiple workspaces, + If you would like to build an OAuth app for enabling the app to run with multiple workspaces, refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app. Args: @@ -1492,7 +1492,7 @@

    Classes

    app.start(port=int(os.environ.get("PORT", 3000)))

    Refer to https://slack.dev/bolt-python/tutorial/getting-started for details.

    -

    If yoy would like to build an OAuth app for enabling the app to run with multiple workspaces, +

    If you would like to build an OAuth app for enabling the app to run with multiple workspaces, refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app.

    Args

    @@ -1607,7 +1607,7 @@

    Args

    Refer to https://slack.dev/bolt-python/tutorial/getting-started for details. - If yoy would like to build an OAuth app for enabling the app to run with multiple workspaces, + If you would like to build an OAuth app for enabling the app to run with multiple workspaces, refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app. Args: diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index 64942993c..618898025 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -175,7 +175,7 @@

    Module slack_bolt.app.async_app

    Refer to https://slack.dev/bolt-python/concepts#async for details. - If yoy would like to build an OAuth app for enabling the app to run with multiple workspaces, + If you would like to build an OAuth app for enabling the app to run with multiple workspaces, refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app. Args: @@ -1440,7 +1440,7 @@

    Classes

    app.start(port=int(os.environ.get("PORT", 3000)))

    Refer to https://slack.dev/bolt-python/concepts#async for details.

    -

    If yoy would like to build an OAuth app for enabling the app to run with multiple workspaces, +

    If you would like to build an OAuth app for enabling the app to run with multiple workspaces, refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app.

    Args

    @@ -1552,7 +1552,7 @@

    Args

    Refer to https://slack.dev/bolt-python/concepts#async for details. - If yoy would like to build an OAuth app for enabling the app to run with multiple workspaces, + If you would like to build an OAuth app for enabling the app to run with multiple workspaces, refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app. Args: diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index db6e973eb..7f843e452 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -137,7 +137,7 @@ def message_hello(message, say): Refer to https://slack.dev/bolt-python/tutorial/getting-started for details. - If yoy would like to build an OAuth app for enabling the app to run with multiple workspaces, + If you would like to build an OAuth app for enabling the app to run with multiple workspaces, refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app. Args: diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index f89d46c4d..718955c08 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -147,7 +147,7 @@ async def message_hello(message, say): # async function Refer to https://slack.dev/bolt-python/concepts#async for details. - If yoy would like to build an OAuth app for enabling the app to run with multiple workspaces, + If you would like to build an OAuth app for enabling the app to run with multiple workspaces, refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app. Args: From 05951c6f2c9316d9c64a1b07259d9e407d5ba00f Mon Sep 17 00:00:00 2001 From: Naveen Sangapala Date: Fri, 27 Aug 2021 17:48:58 -0700 Subject: [PATCH 379/865] Try lower then uppercase key value to extract cookie (#451) --- slack_bolt/adapter/aws_lambda/handler.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/slack_bolt/adapter/aws_lambda/handler.py b/slack_bolt/adapter/aws_lambda/handler.py index dc59d92c9..9037ac397 100644 --- a/slack_bolt/adapter/aws_lambda/handler.py +++ b/slack_bolt/adapter/aws_lambda/handler.py @@ -75,12 +75,6 @@ def handle(self, event, context): def to_bolt_request(event) -> BoltRequest: - """Note that this handler supports only the payload format 2.0. - This means you can use this with HTTP API while REST API is not supported. - - Read https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html - for more details. - """ body = event.get("body", "") if event["isBase64Encoded"]: body = base64.b64decode(body).decode("utf-8") @@ -88,7 +82,10 @@ def to_bolt_request(event) -> BoltRequest: if cookies is None or len(cookies) == 0: # In the case of format v1 multiValueHeaders = event.get("multiValueHeaders", {}) - cookies = multiValueHeaders.get("Cookie", []) + cookies = multiValueHeaders.get("cookie", []) + if len(cookies) == 0: + # Try using uppercase + cookies = multiValueHeaders.get("Cookie", []) headers = event.get("headers", {}) headers["cookie"] = cookies return BoltRequest( From 1272f891549b1f59f81f00ed4ede0e3ebb17872e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 28 Aug 2021 09:51:03 +0900 Subject: [PATCH 380/865] version 1.8.1 --- .../adapter/aws_lambda/handler.html | 27 +++++++------------ docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html index 0ef4a7b2c..8f6af7ce6 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html @@ -103,12 +103,6 @@

    Module slack_bolt.adapter.aws_lambda.handler

    def to_bolt_request(event) -> BoltRequest: - """Note that this handler supports only the payload format 2.0. - This means you can use this with HTTP API while REST API is not supported. - - Read https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html - for more details. - """ body = event.get("body", "") if event["isBase64Encoded"]: body = base64.b64decode(body).decode("utf-8") @@ -116,7 +110,10 @@

    Module slack_bolt.adapter.aws_lambda.handler

    if cookies is None or len(cookies) == 0: # In the case of format v1 multiValueHeaders = event.get("multiValueHeaders", {}) - cookies = multiValueHeaders.get("Cookie", []) + cookies = multiValueHeaders.get("cookie", []) + if len(cookies) == 0: + # Try using uppercase + cookies = multiValueHeaders.get("Cookie", []) headers = event.get("headers", {}) headers["cookie"] = cookies return BoltRequest( @@ -187,21 +184,12 @@

    Functions

    def to_bolt_request(event) ‑> BoltRequest
    -

    Note that this handler supports only the payload format 2.0. -This means you can use this with HTTP API while REST API is not supported.

    -

    Read https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html -for more details.

    +
    Expand source code
    def to_bolt_request(event) -> BoltRequest:
    -    """Note that this handler supports only the payload format 2.0.
    -    This means you can use this with HTTP API while REST API is not supported.
    -
    -    Read https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
    -    for more details.
    -    """
         body = event.get("body", "")
         if event["isBase64Encoded"]:
             body = base64.b64decode(body).decode("utf-8")
    @@ -209,7 +197,10 @@ 

    Functions

    if cookies is None or len(cookies) == 0: # In the case of format v1 multiValueHeaders = event.get("multiValueHeaders", {}) - cookies = multiValueHeaders.get("Cookie", []) + cookies = multiValueHeaders.get("cookie", []) + if len(cookies) == 0: + # Try using uppercase + cookies = multiValueHeaders.get("Cookie", []) headers = event.get("headers", {}) headers["cookie"] = cookies return BoltRequest( diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index 9ad0e32f7..d82713ad6 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.8.0"
    +__version__ = "1.8.1"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index f9c62ba4d..65174d63d 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.8.0" +__version__ = "1.8.1" From 6684df65296868b942abea704a626958d7b54a24 Mon Sep 17 00:00:00 2001 From: Chris Bouchard Date: Sun, 29 Aug 2021 04:22:28 -0400 Subject: [PATCH 381/865] Allow a custom Executor in App (#453) Co-authored-by: Kazuhiro Sera --- slack_bolt/app/app.py | 9 ++++++++- slack_bolt/lazy_listener/thread_runner.py | 4 ++-- slack_bolt/listener/thread_runner.py | 6 +++--- tests/scenario_tests/test_app.py | 18 ++++++++++++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 7f843e452..ef52b7c02 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -3,6 +3,7 @@ import logging import os import time +from concurrent.futures import Executor from concurrent.futures.thread import ThreadPoolExecutor from http.server import SimpleHTTPRequestHandler, HTTPServer from typing import List, Union, Pattern, Callable, Dict, Optional, Sequence, Any @@ -113,6 +114,8 @@ def __init__( oauth_flow: Optional[OAuthFlow] = None, # No need to set (the value is used only in response to ssl_check requests) verification_token: Optional[str] = None, + # Set this one only when you want to customize the executor + listener_executor: Optional[Executor] = None, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -173,6 +176,8 @@ def message_hello(message, say): oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. + listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will + be used. """ signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") token = token or os.environ.get("SLACK_BOT_TOKEN") @@ -302,7 +307,9 @@ def message_hello(message, say): self._middleware_list: List[Union[Callable, Middleware]] = [] self._listeners: List[Listener] = [] - listener_executor = ThreadPoolExecutor(max_workers=5) + if listener_executor is None: + listener_executor = ThreadPoolExecutor(max_workers=5) + self._process_before_response = process_before_response self._listener_runner = ThreadListenerRunner( logger=self._framework_logger, diff --git a/slack_bolt/lazy_listener/thread_runner.py b/slack_bolt/lazy_listener/thread_runner.py index d2e7a09a0..3720f9ac5 100644 --- a/slack_bolt/lazy_listener/thread_runner.py +++ b/slack_bolt/lazy_listener/thread_runner.py @@ -1,4 +1,4 @@ -from concurrent.futures.thread import ThreadPoolExecutor +from concurrent.futures import Executor from logging import Logger from typing import Callable @@ -13,7 +13,7 @@ class ThreadLazyListenerRunner(LazyListenerRunner): def __init__( self, logger: Logger, - executor: ThreadPoolExecutor, + executor: Executor, ): self.logger = logger self.executor = executor diff --git a/slack_bolt/listener/thread_runner.py b/slack_bolt/listener/thread_runner.py index 74d20dd98..941926722 100644 --- a/slack_bolt/listener/thread_runner.py +++ b/slack_bolt/listener/thread_runner.py @@ -1,5 +1,5 @@ import time -from concurrent.futures.thread import ThreadPoolExecutor +from concurrent.futures import Executor from logging import Logger from typing import Optional, Callable @@ -22,7 +22,7 @@ class ThreadListenerRunner: process_before_response: bool listener_error_handler: ListenerErrorHandler listener_completion_handler: ListenerCompletionHandler - listener_executor: ThreadPoolExecutor + listener_executor: Executor lazy_listener_runner: LazyListenerRunner def __init__( @@ -31,7 +31,7 @@ def __init__( process_before_response: bool, listener_error_handler: ListenerErrorHandler, listener_completion_handler: ListenerCompletionHandler, - listener_executor: ThreadPoolExecutor, + listener_executor: Executor, lazy_listener_runner: LazyListenerRunner, ): self.logger = logger diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index 8d3a4a3db..936ad68ec 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -1,3 +1,5 @@ +from concurrent.futures import Executor + import pytest from slack_sdk import WebClient from slack_sdk.oauth.installation_store import FileInstallationStore @@ -56,6 +58,22 @@ def test_listener_registration_error(self): with pytest.raises(BoltError): app.action({"type": "invalid_type", "action_id": "a"})(self.simple_listener) + def test_listener_executor(self): + class TestExecutor(Executor): + """A executor that does nothing for testing""" + + pass + + executor = TestExecutor() + app = App( + signing_secret="valid", + client=self.web_client, + listener_executor=executor, + ) + + assert app.listener_runner.listener_executor == executor + assert app.listener_runner.lazy_listener_runner.executor == executor + # -------------------------- # single team auth # -------------------------- From 908daf46d5f0facb6bc18933969528564fee5f17 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 30 Aug 2021 17:32:17 +0900 Subject: [PATCH 382/865] Update the API doc generation script to use the latest pdoc3 --- scripts/generate_api_docs.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/generate_api_docs.sh b/scripts/generate_api_docs.sh index 30e29c7b8..5362849f9 100755 --- a/scripts/generate_api_docs.sh +++ b/scripts/generate_api_docs.sh @@ -4,7 +4,7 @@ script_dir=`dirname $0` cd ${script_dir}/.. -pip install pdoc3 +pip install -U pdoc3 rm -rf docs/api-docs pdoc slack_bolt --html -o docs/api-docs open docs/api-docs/slack_bolt/index.html From 9ca77da077cf21630018d1fd5a82bf2486e1dd2a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 31 Aug 2021 13:09:12 +0900 Subject: [PATCH 383/865] Fix #454 Add oauth_settings.state_validation_enabled to customize the OAuth flow (#455) * Fix #454 Add oauth_settings.state_validation_enabled to customize the OAuth flow * Add more tests --- slack_bolt/oauth/async_oauth_flow.py | 78 +++++++++++-------- slack_bolt/oauth/async_oauth_settings.py | 4 + slack_bolt/oauth/oauth_flow.py | 77 ++++++++++-------- slack_bolt/oauth/oauth_settings.py | 4 + tests/slack_bolt/oauth/test_oauth_flow.py | 73 ++++++++++++++++- .../oauth/test_async_oauth_flow.py | 71 +++++++++++++++++ 6 files changed, 237 insertions(+), 70 deletions(-) diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 29ccb9ef3..b086c0b2a 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -161,30 +161,32 @@ def sqlite3( # ----------------------------- async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse: - state = await self.issue_new_state(request) - url = await self.build_authorize_url(state, request) - set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( - state - ) + set_cookie_value: Optional[str] = None + url = await self.build_authorize_url("", request) + if self.settings.state_validation_enabled is True: + state = await self.issue_new_state(request) + url = await self.build_authorize_url(state, request) + set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( + state + ) if self.settings.install_page_rendering_enabled: html = await self.build_install_page_html(url, request) return BoltResponse( status=200, body=html, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Set-Cookie": [set_cookie_value], - }, + headers=await self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8"}, + set_cookie_value, + ), ) else: return BoltResponse( status=302, body="", - headers={ - "Content-Type": "text/html; charset=utf-8", - "Location": url, - "Set-Cookie": [set_cookie_value], - }, + headers=await self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8", "Location": url}, + set_cookie_value, + ), ) # ---------------------- @@ -199,6 +201,13 @@ async def build_authorize_url(self, state: str, request: AsyncBoltRequest) -> st async def build_install_page_html(self, url: str, request: AsyncBoltRequest) -> str: return _build_default_install_page_html(url) + async def append_set_cookie_headers( + self, headers: dict, set_cookie_value: Optional[str] + ): + if set_cookie_value is not None: + headers["Set-Cookie"] = [set_cookie_value] + return headers + # ----------------------------- # Callback # ----------------------------- @@ -219,29 +228,30 @@ async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse: ) # state parameter verification - state: Optional[str] = request.query.get("state", [None])[0] - if not self.settings.state_utils.is_valid_browser(state, request.headers): - return await self.failure_handler( - AsyncFailureArgs( - request=request, - reason="invalid_browser", - suggested_status_code=400, - settings=self.settings, - default=self.default_callback_options, + if self.settings.state_validation_enabled is True: + state: Optional[str] = request.query.get("state", [None])[0] + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) - valid_state_consumed = await self.settings.state_store.async_consume(state) - if not valid_state_consumed: - return await self.failure_handler( - AsyncFailureArgs( - request=request, - reason="invalid_state", - suggested_status_code=401, - settings=self.settings, - default=self.default_callback_options, + valid_state_consumed = await self.settings.state_store.async_consume(state) + if not valid_state_consumed: + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_state", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) # run installation code = request.query.get("code", [None])[0] diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index 7b347eed5..dc6f112c5 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -44,6 +44,7 @@ class AsyncOAuthSettings: token_rotation_expiration_minutes: int authorize: AsyncAuthorize # state parameter related configurations + state_validation_enabled: bool state_store: AsyncOAuthStateStore state_cookie_name: str state_expiration_seconds: int @@ -76,6 +77,7 @@ def __init__( installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, # state parameter related configurations + state_validation_enabled: bool = True, state_store: Optional[AsyncOAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, @@ -100,6 +102,7 @@ def __init__( installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) @@ -153,6 +156,7 @@ def __init__( bot_only=self.installation_store_bot_only, ) # state parameter related configurations + self.state_validation_enabled = state_validation_enabled self.state_store = state_store or FileOAuthStateStore( expiration_seconds=state_expiration_seconds, client_id=client_id, diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index 4b65858ac..daf23c761 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -158,30 +158,33 @@ def sqlite3( # ----------------------------- def handle_installation(self, request: BoltRequest) -> BoltResponse: - state = self.issue_new_state(request) - url = self.build_authorize_url(state, request) - set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( - state - ) + set_cookie_value: Optional[str] = None + url = self.build_authorize_url("", request) + if self.settings.state_validation_enabled is True: + state = self.issue_new_state(request) + url = self.build_authorize_url(state, request) + set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( + state + ) + if self.settings.install_page_rendering_enabled: html = self.build_install_page_html(url, request) return BoltResponse( status=200, body=html, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Set-Cookie": [set_cookie_value], - }, + headers=self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8"}, + set_cookie_value, + ), ) else: return BoltResponse( status=302, body="", - headers={ - "Content-Type": "text/html; charset=utf-8", - "Location": url, - "Set-Cookie": [set_cookie_value], - }, + headers=self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8", "Location": url}, + set_cookie_value, + ), ) # ---------------------- @@ -196,6 +199,11 @@ def build_authorize_url(self, state: str, request: BoltRequest) -> str: def build_install_page_html(self, url: str, request: BoltRequest) -> str: return _build_default_install_page_html(url) + def append_set_cookie_headers(self, headers: dict, set_cookie_value: Optional[str]): + if set_cookie_value is not None: + headers["Set-Cookie"] = [set_cookie_value] + return headers + # ----------------------------- # Callback # ----------------------------- @@ -216,29 +224,30 @@ def handle_callback(self, request: BoltRequest) -> BoltResponse: ) # state parameter verification - state = request.query.get("state", [None])[0] - if not self.settings.state_utils.is_valid_browser(state, request.headers): - return self.failure_handler( - FailureArgs( - request=request, - reason="invalid_browser", - suggested_status_code=400, - settings=self.settings, - default=self.default_callback_options, + if self.settings.state_validation_enabled is True: + state = request.query.get("state", [None])[0] + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) - valid_state_consumed = self.settings.state_store.consume(state) - if not valid_state_consumed: - return self.failure_handler( - FailureArgs( - request=request, - reason="invalid_state", - suggested_status_code=401, - settings=self.settings, - default=self.default_callback_options, + valid_state_consumed = self.settings.state_store.consume(state) + if not valid_state_consumed: + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_state", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) # run installation code = request.query.get("code", [None])[0] diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index 2217f65f3..88ac04567 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -39,6 +39,7 @@ class OAuthSettings: token_rotation_expiration_minutes: int authorize: Authorize # state parameter related configurations + state_validation_enabled: bool state_store: OAuthStateStore state_cookie_name: str state_expiration_seconds: int @@ -71,6 +72,7 @@ def __init__( installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, # state parameter related configurations + state_validation_enabled: bool = True, state_store: Optional[OAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, @@ -95,6 +97,7 @@ def __init__( installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) @@ -147,6 +150,7 @@ def __init__( bot_only=self.installation_store_bot_only, ) # state parameter related configurations + self.state_validation_enabled = state_validation_enabled self.state_store = state_store or FileOAuthStateStore( expiration_seconds=state_expiration_seconds, client_id=client_id, diff --git a/tests/slack_bolt/oauth/test_oauth_flow.py b/tests/slack_bolt/oauth/test_oauth_flow.py index a43db0b5f..fe1665660 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow.py +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -4,7 +4,10 @@ from slack_sdk import WebClient from slack_sdk.oauth.installation_store import FileInstallationStore -from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.oauth.state_store import ( + OAuthStateStore, + FileOAuthStateStore, +) from slack_sdk.signature import SignatureVerifier from slack_bolt import BoltRequest, BoltResponse, App @@ -56,10 +59,11 @@ def test_handle_installation_default(self): resp = oauth_flow.handle_installation(req) assert resp.status == 200 assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] + assert resp.headers.get("set-cookie") is not None assert "https://slack.com/oauth/v2/authorize?state=" in resp.body # https://github.com/slackapi/bolt-python/issues/183 - # For direct install URL suppport + # For direct install URL support def test_handle_installation_no_rendering(self): oauth_flow = OAuthFlow( settings=OAuthSettings( @@ -77,6 +81,25 @@ def test_handle_installation_no_rendering(self): assert resp.status == 302 location_header = resp.headers.get("location")[0] assert "https://slack.com/oauth/v2/authorize?state=" in location_header + assert resp.headers.get("set-cookie") is not None + + def test_handle_installation_no_state_validation(self): + oauth_flow = OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + user_scopes=["search:read"], + installation_store=FileInstallationStore(), + install_page_rendering_enabled=False, # disabled + state_validation_enabled=False, # disabled + state_store=None, + ) + ) + req = BoltRequest(body="") + resp = oauth_flow.handle_installation(req) + assert resp.status == 302 + assert resp.headers.get("set-cookie") is None def test_scopes_as_str(self): settings = OAuthSettings( @@ -160,6 +183,52 @@ def test_handle_callback_invalid_state(self): resp = oauth_flow.handle_callback(req) assert resp.status == 400 + def test_handle_callback_already_expired_state(self): + class MyOAuthStateStore(OAuthStateStore): + def issue(self, *args, **kwargs) -> str: + return "expired_one" + + def consume(self, state: str) -> bool: + return False + + oauth_flow = OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + state_store=MyOAuthStateStore(), + ) + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 401 + + def test_handle_callback_no_state_validation(self): + oauth_flow = OAuthFlow( + client=WebClient(base_url=self.mock_api_server_base_url), + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_validation_enabled=False, # disabled + state_store=None, + ), + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 200 + def test_handle_callback_using_options(self): def success(args: SuccessArgs) -> BoltResponse: assert args.request is not None diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index eea474b87..9a4787a6f 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -6,6 +6,7 @@ import pytest from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore +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 @@ -108,6 +109,7 @@ async def test_handle_installation_default(self): assert resp.status == 200 assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] assert "https://slack.com/oauth/v2/authorize?state=" in resp.body + assert resp.headers.get("set-cookie") is not None @pytest.mark.asyncio async def test_handle_installation_no_rendering(self): @@ -126,6 +128,27 @@ async def test_handle_installation_no_rendering(self): assert resp.status == 302 location_header = resp.headers.get("location")[0] assert "https://slack.com/oauth/v2/authorize?state=" in location_header + assert resp.headers.get("set-cookie") is not None + + @pytest.mark.asyncio + async def test_handle_installation_no_state_validation(self): + oauth_flow = AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + install_page_rendering_enabled=False, # disabled + state_validation_enabled=False, # disabled + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + req = AsyncBoltRequest(body="") + resp = await oauth_flow.handle_installation(req) + assert resp.status == 302 + location_header = resp.headers.get("location")[0] + assert "https://slack.com/oauth/v2/authorize?state=" in location_header + assert resp.headers.get("set-cookie") is None @pytest.mark.asyncio async def test_handle_callback(self): @@ -201,6 +224,54 @@ async def test_handle_callback_invalid_state(self): resp = await oauth_flow.handle_callback(req) assert resp.status == 400 + @pytest.mark.asyncio + async def test_handle_callback_invalid_state(self): + class MyOAuthStateStore(AsyncOAuthStateStore): + async def async_issue(self, *args, **kwargs) -> str: + return "expired_one" + + async def async_consume(self, state: str) -> bool: + return False + + oauth_flow = AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + state_store=MyOAuthStateStore(), + ) + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 401 + + @pytest.mark.asyncio + async def test_handle_callback_no_state_validation(self): + oauth_flow = AsyncOAuthFlow( + client=AsyncWebClient(base_url=self.mock_api_server_base_url), + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_validation_enabled=False, # disabled + state_store=None, + ), + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 200 + @pytest.mark.asyncio async def test_handle_callback_using_options(self): async def success(args: AsyncSuccessArgs) -> BoltResponse: From 28824cf6231cc7a96cf2e57ffe29cf6a390b881d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 31 Aug 2021 13:43:44 +0900 Subject: [PATCH 384/865] version 1.9.0 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 65174d63d..2e6e949f5 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.8.1" +__version__ = "1.9.0" From 568c1681fe531f3325f072f74ecc7f64c2212d12 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 31 Aug 2021 13:44:34 +0900 Subject: [PATCH 385/865] Apply API document changes for v1.9.0 --- .../slack_bolt/adapter/aiohttp/index.html | 4 +- .../adapter/aws_lambda/chalice_handler.html | 4 +- .../chalice_lazy_listener_runner.html | 4 +- .../adapter/aws_lambda/handler.html | 4 +- .../slack_bolt/adapter/aws_lambda/index.html | 4 +- .../adapter/aws_lambda/internals.html | 4 +- .../aws_lambda/lambda_s3_oauth_flow.html | 4 +- .../aws_lambda/lazy_listener_runner.html | 4 +- .../aws_lambda/local_lambda_client.html | 4 +- .../slack_bolt/adapter/bottle/handler.html | 6 +- .../slack_bolt/adapter/bottle/index.html | 4 +- .../slack_bolt/adapter/cherrypy/handler.html | 6 +- .../slack_bolt/adapter/cherrypy/index.html | 4 +- .../slack_bolt/adapter/django/handler.html | 6 +- .../slack_bolt/adapter/django/index.html | 4 +- .../slack_bolt/adapter/falcon/index.html | 4 +- .../slack_bolt/adapter/falcon/resource.html | 4 +- .../adapter/fastapi/async_handler.html | 4 +- .../slack_bolt/adapter/fastapi/index.html | 4 +- .../slack_bolt/adapter/flask/handler.html | 4 +- .../slack_bolt/adapter/flask/index.html | 4 +- docs/api-docs/slack_bolt/adapter/index.html | 4 +- .../slack_bolt/adapter/pyramid/handler.html | 4 +- .../slack_bolt/adapter/pyramid/index.html | 4 +- .../adapter/sanic/async_handler.html | 4 +- .../slack_bolt/adapter/sanic/index.html | 4 +- .../adapter/socket_mode/aiohttp/index.html | 4 +- .../socket_mode/async_base_handler.html | 6 +- .../adapter/socket_mode/async_handler.html | 4 +- .../adapter/socket_mode/async_internals.html | 4 +- .../adapter/socket_mode/base_handler.html | 6 +- .../adapter/socket_mode/builtin/index.html | 4 +- .../slack_bolt/adapter/socket_mode/index.html | 4 +- .../adapter/socket_mode/internals.html | 4 +- .../socket_mode/websocket_client/index.html | 4 +- .../adapter/socket_mode/websockets/index.html | 4 +- .../adapter/starlette/async_handler.html | 4 +- .../slack_bolt/adapter/starlette/handler.html | 4 +- .../slack_bolt/adapter/starlette/index.html | 4 +- .../slack_bolt/adapter/tornado/handler.html | 6 +- .../slack_bolt/adapter/tornado/index.html | 4 +- docs/api-docs/slack_bolt/app/app.html | 36 ++- docs/api-docs/slack_bolt/app/async_app.html | 12 +- .../api-docs/slack_bolt/app/async_server.html | 6 +- docs/api-docs/slack_bolt/app/index.html | 4 +- docs/api-docs/slack_bolt/async_app.html | 4 +- .../authorization/async_authorize.html | 4 +- .../authorization/async_authorize_args.html | 4 +- .../slack_bolt/authorization/authorize.html | 4 +- .../authorization/authorize_args.html | 4 +- .../authorization/authorize_result.html | 4 +- .../slack_bolt/authorization/index.html | 4 +- docs/api-docs/slack_bolt/context/ack/ack.html | 4 +- .../slack_bolt/context/ack/async_ack.html | 4 +- .../slack_bolt/context/ack/index.html | 4 +- .../slack_bolt/context/ack/internals.html | 4 +- .../slack_bolt/context/async_context.html | 4 +- .../slack_bolt/context/base_context.html | 4 +- docs/api-docs/slack_bolt/context/context.html | 4 +- docs/api-docs/slack_bolt/context/index.html | 4 +- .../context/respond/async_respond.html | 4 +- .../slack_bolt/context/respond/index.html | 4 +- .../slack_bolt/context/respond/internals.html | 4 +- .../slack_bolt/context/respond/respond.html | 4 +- .../slack_bolt/context/say/async_say.html | 4 +- .../slack_bolt/context/say/index.html | 4 +- .../slack_bolt/context/say/internals.html | 4 +- docs/api-docs/slack_bolt/context/say/say.html | 4 +- docs/api-docs/slack_bolt/error/index.html | 4 +- docs/api-docs/slack_bolt/index.html | 4 +- .../slack_bolt/kwargs_injection/args.html | 10 +- .../kwargs_injection/async_args.html | 10 +- .../kwargs_injection/async_utils.html | 6 +- .../slack_bolt/kwargs_injection/index.html | 4 +- .../slack_bolt/kwargs_injection/utils.html | 6 +- .../lazy_listener/async_internals.html | 6 +- .../lazy_listener/async_runner.html | 8 +- .../lazy_listener/asyncio_runner.html | 4 +- .../slack_bolt/lazy_listener/index.html | 4 +- .../slack_bolt/lazy_listener/internals.html | 6 +- .../slack_bolt/lazy_listener/runner.html | 8 +- .../lazy_listener/thread_runner.html | 12 +- .../slack_bolt/listener/async_builtins.html | 8 +- .../slack_bolt/listener/async_listener.html | 14 +- .../async_listener_completion_handler.html | 8 +- .../async_listener_error_handler.html | 6 +- .../slack_bolt/listener/asyncio_runner.html | 4 +- .../slack_bolt/listener/builtins.html | 8 +- .../slack_bolt/listener/custom_listener.html | 8 +- docs/api-docs/slack_bolt/listener/index.html | 4 +- .../slack_bolt/listener/listener.html | 6 +- .../listener/listener_completion_handler.html | 8 +- .../listener/listener_error_handler.html | 6 +- .../slack_bolt/listener/thread_runner.html | 18 +- .../listener_matcher/async_builtins.html | 4 +- .../async_listener_matcher.html | 4 +- .../slack_bolt/listener_matcher/builtins.html | 6 +- .../custom_listener_matcher.html | 4 +- .../slack_bolt/listener_matcher/index.html | 4 +- .../listener_matcher/listener_matcher.html | 4 +- docs/api-docs/slack_bolt/logger/index.html | 4 +- docs/api-docs/slack_bolt/logger/messages.html | 4 +- .../slack_bolt/middleware/async_builtins.html | 4 +- .../middleware/async_custom_middleware.html | 4 +- .../middleware/async_middleware.html | 4 +- .../async_middleware_error_handler.html | 6 +- .../authorization/async_authorization.html | 4 +- .../authorization/async_internals.html | 4 +- .../async_multi_teams_authorization.html | 4 +- .../async_single_team_authorization.html | 4 +- .../authorization/authorization.html | 4 +- .../middleware/authorization/index.html | 4 +- .../middleware/authorization/internals.html | 4 +- .../multi_teams_authorization.html | 4 +- .../single_team_authorization.html | 4 +- .../middleware/custom_middleware.html | 4 +- .../async_ignoring_self_events.html | 4 +- .../ignoring_self_events.html | 4 +- .../ignoring_self_events/index.html | 4 +- .../api-docs/slack_bolt/middleware/index.html | 4 +- .../async_message_listener_matches.html | 4 +- .../message_listener_matches/index.html | 4 +- .../message_listener_matches.html | 4 +- .../slack_bolt/middleware/middleware.html | 4 +- .../middleware/middleware_error_handler.html | 6 +- .../async_request_verification.html | 4 +- .../request_verification/index.html | 4 +- .../request_verification.html | 4 +- .../middleware/ssl_check/async_ssl_check.html | 4 +- .../middleware/ssl_check/index.html | 4 +- .../middleware/ssl_check/ssl_check.html | 4 +- .../async_url_verification.html | 4 +- .../middleware/url_verification/index.html | 4 +- .../url_verification/url_verification.html | 4 +- .../oauth/async_callback_options.html | 4 +- .../slack_bolt/oauth/async_internals.html | 4 +- .../slack_bolt/oauth/async_oauth_flow.html | 249 ++++++++++-------- .../oauth/async_oauth_settings.html | 21 +- .../slack_bolt/oauth/callback_options.html | 4 +- docs/api-docs/slack_bolt/oauth/index.html | 4 +- docs/api-docs/slack_bolt/oauth/internals.html | 4 +- .../api-docs/slack_bolt/oauth/oauth_flow.html | 246 +++++++++-------- .../slack_bolt/oauth/oauth_settings.html | 21 +- .../slack_bolt/request/async_internals.html | 4 +- .../slack_bolt/request/async_request.html | 6 +- docs/api-docs/slack_bolt/request/index.html | 4 +- .../slack_bolt/request/internals.html | 6 +- .../slack_bolt/request/payload_utils.html | 4 +- docs/api-docs/slack_bolt/request/request.html | 6 +- docs/api-docs/slack_bolt/response/index.html | 4 +- .../slack_bolt/response/response.html | 4 +- .../api-docs/slack_bolt/util/async_utils.html | 4 +- docs/api-docs/slack_bolt/util/index.html | 4 +- docs/api-docs/slack_bolt/util/utils.html | 4 +- docs/api-docs/slack_bolt/version.html | 6 +- docs/api-docs/slack_bolt/workflows/index.html | 4 +- .../slack_bolt/workflows/step/async_step.html | 10 +- .../workflows/step/async_step_middleware.html | 4 +- .../slack_bolt/workflows/step/index.html | 4 +- .../slack_bolt/workflows/step/internals.html | 4 +- .../slack_bolt/workflows/step/step.html | 10 +- .../workflows/step/step_middleware.html | 4 +- .../step/utilities/async_complete.html | 4 +- .../step/utilities/async_configure.html | 4 +- .../workflows/step/utilities/async_fail.html | 4 +- .../step/utilities/async_update.html | 4 +- .../workflows/step/utilities/complete.html | 4 +- .../workflows/step/utilities/configure.html | 4 +- .../workflows/step/utilities/fail.html | 4 +- .../workflows/step/utilities/index.html | 4 +- .../workflows/step/utilities/update.html | 4 +- 171 files changed, 748 insertions(+), 623 deletions(-) diff --git a/docs/api-docs/slack_bolt/adapter/aiohttp/index.html b/docs/api-docs/slack_bolt/adapter/aiohttp/index.html index 3e4659719..a565a067d 100644 --- a/docs/api-docs/slack_bolt/adapter/aiohttp/index.html +++ b/docs/api-docs/slack_bolt/adapter/aiohttp/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aiohttp API documentation @@ -155,7 +155,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html index c46bbd7df..53132d9a8 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.chalice_handler API documentation @@ -404,7 +404,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html index f4780c6d9..7a517cc79 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner API documentation @@ -168,7 +168,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html index 8f6af7ce6..ec31815d9 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.handler API documentation @@ -402,7 +402,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html index 0aec8cf2b..c82448803 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda API documentation @@ -95,7 +95,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/internals.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/internals.html index 2afb7faa3..653a3c7f0 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/internals.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.internals API documentation @@ -61,7 +61,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.html index 3636bcde1..18a54947e 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow API documentation @@ -311,7 +311,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/lazy_listener_runner.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/lazy_listener_runner.html index 8cdb35c32..248bf151e 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/lazy_listener_runner.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/lazy_listener_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.lazy_listener_runner API documentation @@ -151,7 +151,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/local_lambda_client.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/local_lambda_client.html index 64f926fcf..101024e4b 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/local_lambda_client.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/local_lambda_client.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.local_lambda_client API documentation @@ -158,7 +158,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/bottle/handler.html b/docs/api-docs/slack_bolt/adapter/bottle/handler.html index cb218e70b..dc2026611 100644 --- a/docs/api-docs/slack_bolt/adapter/bottle/handler.html +++ b/docs/api-docs/slack_bolt/adapter/bottle/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.bottle.handler API documentation @@ -85,7 +85,7 @@

    Module slack_bolt.adapter.bottle.handler

    Functions

    -def set_response(bolt_resp: BoltResponse, resp: bottle.BaseResponse) ‑> NoneType +def set_response(bolt_resp: BoltResponse, resp: bottle.BaseResponse) ‑> None
    @@ -227,7 +227,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/bottle/index.html b/docs/api-docs/slack_bolt/adapter/bottle/index.html index 846b83033..924b14de2 100644 --- a/docs/api-docs/slack_bolt/adapter/bottle/index.html +++ b/docs/api-docs/slack_bolt/adapter/bottle/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.bottle API documentation @@ -65,7 +65,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/cherrypy/handler.html b/docs/api-docs/slack_bolt/adapter/cherrypy/handler.html index 7260c06fc..ae21f5d27 100644 --- a/docs/api-docs/slack_bolt/adapter/cherrypy/handler.html +++ b/docs/api-docs/slack_bolt/adapter/cherrypy/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.cherrypy.handler API documentation @@ -136,7 +136,7 @@

    Functions

    -def set_response_status_and_headers(bolt_resp: BoltResponse) ‑> NoneType +def set_response_status_and_headers(bolt_resp: BoltResponse) ‑> None
    @@ -301,7 +301,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/cherrypy/index.html b/docs/api-docs/slack_bolt/adapter/cherrypy/index.html index 68bdf158c..e1df7c0b1 100644 --- a/docs/api-docs/slack_bolt/adapter/cherrypy/index.html +++ b/docs/api-docs/slack_bolt/adapter/cherrypy/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.cherrypy API documentation @@ -65,7 +65,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/django/handler.html b/docs/api-docs/slack_bolt/adapter/django/handler.html index 690876c6a..24e8cdf4b 100644 --- a/docs/api-docs/slack_bolt/adapter/django/handler.html +++ b/docs/api-docs/slack_bolt/adapter/django/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.django.handler API documentation @@ -298,7 +298,7 @@

    Inherited members

    class DjangoThreadLazyListenerRunner -(logger: logging.Logger, executor: concurrent.futures.thread.ThreadPoolExecutor) +(logger: logging.Logger, executor: concurrent.futures._base.Executor)
    @@ -486,7 +486,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/django/index.html b/docs/api-docs/slack_bolt/adapter/django/index.html index 715e263a9..9c41c9e8e 100644 --- a/docs/api-docs/slack_bolt/adapter/django/index.html +++ b/docs/api-docs/slack_bolt/adapter/django/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.django API documentation @@ -65,7 +65,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/falcon/index.html b/docs/api-docs/slack_bolt/adapter/falcon/index.html index 5d7cc5f62..10e949002 100644 --- a/docs/api-docs/slack_bolt/adapter/falcon/index.html +++ b/docs/api-docs/slack_bolt/adapter/falcon/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.falcon API documentation @@ -65,7 +65,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/falcon/resource.html b/docs/api-docs/slack_bolt/adapter/falcon/resource.html index 1b8b68f72..ae29f18b2 100644 --- a/docs/api-docs/slack_bolt/adapter/falcon/resource.html +++ b/docs/api-docs/slack_bolt/adapter/falcon/resource.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.falcon.resource API documentation @@ -262,7 +262,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/fastapi/async_handler.html b/docs/api-docs/slack_bolt/adapter/fastapi/async_handler.html index eaa539c18..d43ab4b33 100644 --- a/docs/api-docs/slack_bolt/adapter/fastapi/async_handler.html +++ b/docs/api-docs/slack_bolt/adapter/fastapi/async_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.fastapi.async_handler API documentation @@ -53,7 +53,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/fastapi/index.html b/docs/api-docs/slack_bolt/adapter/fastapi/index.html index 45c12f045..ba5c9e031 100644 --- a/docs/api-docs/slack_bolt/adapter/fastapi/index.html +++ b/docs/api-docs/slack_bolt/adapter/fastapi/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.fastapi API documentation @@ -66,7 +66,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/flask/handler.html b/docs/api-docs/slack_bolt/adapter/flask/handler.html index 3258bf8fd..247dbba15 100644 --- a/docs/api-docs/slack_bolt/adapter/flask/handler.html +++ b/docs/api-docs/slack_bolt/adapter/flask/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.flask.handler API documentation @@ -211,7 +211,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/flask/index.html b/docs/api-docs/slack_bolt/adapter/flask/index.html index 75e489cdd..a8d8efd5d 100644 --- a/docs/api-docs/slack_bolt/adapter/flask/index.html +++ b/docs/api-docs/slack_bolt/adapter/flask/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.flask API documentation @@ -65,7 +65,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/index.html b/docs/api-docs/slack_bolt/adapter/index.html index 26067b76c..c05016afd 100644 --- a/docs/api-docs/slack_bolt/adapter/index.html +++ b/docs/api-docs/slack_bolt/adapter/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter API documentation @@ -127,7 +127,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/pyramid/handler.html b/docs/api-docs/slack_bolt/adapter/pyramid/handler.html index 87b9605e1..44b8ee923 100644 --- a/docs/api-docs/slack_bolt/adapter/pyramid/handler.html +++ b/docs/api-docs/slack_bolt/adapter/pyramid/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.pyramid.handler API documentation @@ -241,7 +241,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/pyramid/index.html b/docs/api-docs/slack_bolt/adapter/pyramid/index.html index f9fe67029..dbf5a8668 100644 --- a/docs/api-docs/slack_bolt/adapter/pyramid/index.html +++ b/docs/api-docs/slack_bolt/adapter/pyramid/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.pyramid API documentation @@ -65,7 +65,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/sanic/async_handler.html b/docs/api-docs/slack_bolt/adapter/sanic/async_handler.html index 95318abb8..1577a05f6 100644 --- a/docs/api-docs/slack_bolt/adapter/sanic/async_handler.html +++ b/docs/api-docs/slack_bolt/adapter/sanic/async_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.sanic.async_handler API documentation @@ -265,7 +265,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/sanic/index.html b/docs/api-docs/slack_bolt/adapter/sanic/index.html index 937cc0d40..71cb0ad94 100644 --- a/docs/api-docs/slack_bolt/adapter/sanic/index.html +++ b/docs/api-docs/slack_bolt/adapter/sanic/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.sanic API documentation @@ -65,7 +65,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/aiohttp/index.html b/docs/api-docs/slack_bolt/adapter/socket_mode/aiohttp/index.html index 5d5738a10..61e895370 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/aiohttp/index.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/aiohttp/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.aiohttp API documentation @@ -336,7 +336,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/async_base_handler.html b/docs/api-docs/slack_bolt/adapter/socket_mode/async_base_handler.html index c9f7c4b53..3187a6c8d 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/async_base_handler.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/async_base_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.async_base_handler API documentation @@ -200,7 +200,7 @@

    Methods

    -async def handle(self, client: slack_sdk.socket_mode.async_client.AsyncBaseSocketModeClient, req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> NoneType +async def handle(self, client: slack_sdk.socket_mode.async_client.AsyncBaseSocketModeClient, req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> None

    Handles Socket Mode envelope requests through a WebSocket connection.

    @@ -287,7 +287,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/async_handler.html b/docs/api-docs/slack_bolt/adapter/socket_mode/async_handler.html index 1e1ca2ee4..239bd0fbe 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/async_handler.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/async_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.async_handler API documentation @@ -55,7 +55,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/async_internals.html b/docs/api-docs/slack_bolt/adapter/socket_mode/async_internals.html index 824288e56..5593c5d69 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/async_internals.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/async_internals.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.async_internals API documentation @@ -170,7 +170,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/base_handler.html b/docs/api-docs/slack_bolt/adapter/socket_mode/base_handler.html index 92518d180..dab607610 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/base_handler.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/base_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.base_handler API documentation @@ -210,7 +210,7 @@

    Methods

    -def handle(self, client: slack_sdk.socket_mode.client.BaseSocketModeClient, req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> NoneType +def handle(self, client: slack_sdk.socket_mode.client.BaseSocketModeClient, req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> None

    Handles Socket Mode envelope requests through a WebSocket connection.

    @@ -301,7 +301,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/builtin/index.html b/docs/api-docs/slack_bolt/adapter/socket_mode/builtin/index.html index 3e050b73d..5ca55c520 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/builtin/index.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/builtin/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.builtin API documentation @@ -276,7 +276,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/index.html b/docs/api-docs/slack_bolt/adapter/socket_mode/index.html index 8ce6feae4..642bb5206 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/index.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode API documentation @@ -123,7 +123,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/internals.html b/docs/api-docs/slack_bolt/adapter/socket_mode/internals.html index bf7e9141b..8fb9fc6a8 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/internals.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.internals API documentation @@ -170,7 +170,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/websocket_client/index.html b/docs/api-docs/slack_bolt/adapter/socket_mode/websocket_client/index.html index ad409bdef..049a23153 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/websocket_client/index.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/websocket_client/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.websocket_client API documentation @@ -260,7 +260,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/websockets/index.html b/docs/api-docs/slack_bolt/adapter/socket_mode/websockets/index.html index af0cc2c40..594c20a09 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/websockets/index.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/websockets/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.websockets API documentation @@ -337,7 +337,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/starlette/async_handler.html b/docs/api-docs/slack_bolt/adapter/starlette/async_handler.html index d9cbd98b9..5f1ecf75a 100644 --- a/docs/api-docs/slack_bolt/adapter/starlette/async_handler.html +++ b/docs/api-docs/slack_bolt/adapter/starlette/async_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.starlette.async_handler API documentation @@ -261,7 +261,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/starlette/handler.html b/docs/api-docs/slack_bolt/adapter/starlette/handler.html index 221c63f68..cb77cba85 100644 --- a/docs/api-docs/slack_bolt/adapter/starlette/handler.html +++ b/docs/api-docs/slack_bolt/adapter/starlette/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.starlette.handler API documentation @@ -254,7 +254,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/starlette/index.html b/docs/api-docs/slack_bolt/adapter/starlette/index.html index 1c59cb366..bc4b3689f 100644 --- a/docs/api-docs/slack_bolt/adapter/starlette/index.html +++ b/docs/api-docs/slack_bolt/adapter/starlette/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.starlette API documentation @@ -71,7 +71,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/tornado/handler.html b/docs/api-docs/slack_bolt/adapter/tornado/handler.html index bd478bd7a..3c1ca74b2 100644 --- a/docs/api-docs/slack_bolt/adapter/tornado/handler.html +++ b/docs/api-docs/slack_bolt/adapter/tornado/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.tornado.handler API documentation @@ -108,7 +108,7 @@

    Module slack_bolt.adapter.tornado.handler

    Functions

    -def set_response(self, bolt_resp) ‑> NoneType +def set_response(self, bolt_resp) ‑> None
    @@ -345,7 +345,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/tornado/index.html b/docs/api-docs/slack_bolt/adapter/tornado/index.html index 75031542a..4e1719a18 100644 --- a/docs/api-docs/slack_bolt/adapter/tornado/index.html +++ b/docs/api-docs/slack_bolt/adapter/tornado/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.tornado API documentation @@ -65,7 +65,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index 1f10657ec..9db31df0c 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -3,7 +3,7 @@ - + slack_bolt.app.app API documentation @@ -31,6 +31,7 @@

    Module slack_bolt.app.app

    import logging import os import time +from concurrent.futures import Executor from concurrent.futures.thread import ThreadPoolExecutor from http.server import SimpleHTTPRequestHandler, HTTPServer from typing import List, Union, Pattern, Callable, Dict, Optional, Sequence, Any @@ -141,6 +142,8 @@

    Module slack_bolt.app.app

    oauth_flow: Optional[OAuthFlow] = None, # No need to set (the value is used only in response to ssl_check requests) verification_token: Optional[str] = None, + # Set this one only when you want to customize the executor + listener_executor: Optional[Executor] = None, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -201,6 +204,8 @@

    Module slack_bolt.app.app

    oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. + listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will + be used. """ signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") token = token or os.environ.get("SLACK_BOT_TOKEN") @@ -330,7 +335,9 @@

    Module slack_bolt.app.app

    self._middleware_list: List[Union[Callable, Middleware]] = [] self._listeners: List[Listener] = [] - listener_executor = ThreadPoolExecutor(max_workers=5) + if listener_executor is None: + listener_executor = ThreadPoolExecutor(max_workers=5) + self._process_before_response = process_before_response self._listener_runner = ThreadListenerRunner( logger=self._framework_logger, @@ -1468,7 +1475,7 @@

    Classes

    class App -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None)

    Bolt App that provides functionalities to register middleware/listeners.

    @@ -1546,6 +1553,9 @@

    Args

    Instantiated slack_bolt.oauth.OAuthFlow. This is always prioritized over oauth_settings.
    verification_token
    Deprecated verification mechanism. This can used only for ssl_check requests.
    +
    listener_executor
    +
    Custom executor to run background tasks. If absent, the default ThreadPoolExecutor will +be used.

    @@ -1583,6 +1593,8 @@

    Args

    oauth_flow: Optional[OAuthFlow] = None, # No need to set (the value is used only in response to ssl_check requests) verification_token: Optional[str] = None, + # Set this one only when you want to customize the executor + listener_executor: Optional[Executor] = None, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -1643,6 +1655,8 @@

    Args

    oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. + listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will + be used. """ signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") token = token or os.environ.get("SLACK_BOT_TOKEN") @@ -1772,7 +1786,9 @@

    Args

    self._middleware_list: List[Union[Callable, Middleware]] = [] self._listeners: List[Listener] = [] - listener_executor = ThreadPoolExecutor(max_workers=5) + if listener_executor is None: + listener_executor = ThreadPoolExecutor(max_workers=5) + self._process_before_response = process_before_response self._listener_runner = ThreadListenerRunner( logger=self._framework_logger, @@ -3358,7 +3374,7 @@

    Returns

    -def enable_token_revocation_listeners(self) ‑> NoneType +def enable_token_revocation_listeners(self) ‑> None
    @@ -3428,7 +3444,7 @@

    Args

    -def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, NoneType]]]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, ForwardRef(None)]]]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]]

    Registers a new event listener. This method can be used as either a decorator or a method.

    @@ -3880,7 +3896,7 @@

    Args

    -def start(self, port: int = 3000, path: str = '/slack/events', http_server_logger_enabled: bool = True) ‑> NoneType +def start(self, port: int = 3000, path: str = '/slack/events', http_server_logger_enabled: bool = True) ‑> None

    Starts a web server for local development.

    @@ -3934,7 +3950,7 @@

    Args

    -def step(self, callback_id: Union[str, Pattern, WorkflowStepWorkflowStepBuilder], edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], NoneType] = None, save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], NoneType] = None, execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], NoneType] = None) +def step(self, callback_id: Union[str, Pattern, WorkflowStepWorkflowStepBuilder], edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None, save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None, execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None)

    Registers a new Workflow Step listener. @@ -4348,7 +4364,7 @@

    Args

    Methods

    -def start(self) ‑> NoneType +def start(self) ‑> None

    Starts a new web server process.

    @@ -4437,7 +4453,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index 618898025..d6424b071 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -3,7 +3,7 @@ - + slack_bolt.app.async_app API documentation @@ -3370,7 +3370,7 @@

    Args

    -def enable_token_revocation_listeners(self) ‑> NoneType +def enable_token_revocation_listeners(self) ‑> None
    @@ -3445,7 +3445,7 @@

    Args

    -def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, NoneType]]]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, ForwardRef(None)]]]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]

    Registers a new event listener. This method can be used as either a decorator or a method.

    @@ -3937,7 +3937,7 @@

    Args

    -def start(self, port: int = 3000, path: str = '/slack/events') ‑> NoneType +def start(self, port: int = 3000, path: str = '/slack/events') ‑> None

    Start a web server using AIOHTTP. @@ -3964,7 +3964,7 @@

    Args

    -def step(self, callback_id: Union[str, Pattern, AsyncWorkflowStepAsyncWorkflowStepBuilder], edit: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], NoneType] = None, save: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], NoneType] = None, execute: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], NoneType] = None) +def step(self, callback_id: Union[str, Pattern, AsyncWorkflowStepAsyncWorkflowStepBuilder], edit: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], ForwardRef(None)] = None, save: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], ForwardRef(None)] = None, execute: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], ForwardRef(None)] = None)

    Registers a new Workflow Step listener. @@ -4345,7 +4345,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/app/async_server.html b/docs/api-docs/slack_bolt/app/async_server.html index f510d3a5a..b816d91c6 100644 --- a/docs/api-docs/slack_bolt/app/async_server.html +++ b/docs/api-docs/slack_bolt/app/async_server.html @@ -3,7 +3,7 @@ - + slack_bolt.app.async_server API documentation @@ -279,7 +279,7 @@

    Methods

    -def start(self) ‑> NoneType +def start(self) ‑> None

    Starts a new web server process.

    @@ -333,7 +333,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/app/index.html b/docs/api-docs/slack_bolt/app/index.html index 23e49d2a9..7951c6ce9 100644 --- a/docs/api-docs/slack_bolt/app/index.html +++ b/docs/api-docs/slack_bolt/app/index.html @@ -3,7 +3,7 @@ - + slack_bolt.app API documentation @@ -87,7 +87,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/async_app.html b/docs/api-docs/slack_bolt/async_app.html index 52cf44a7f..6eb811be1 100644 --- a/docs/api-docs/slack_bolt/async_app.html +++ b/docs/api-docs/slack_bolt/async_app.html @@ -3,7 +3,7 @@ - + slack_bolt.async_app API documentation @@ -145,7 +145,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/authorization/async_authorize.html b/docs/api-docs/slack_bolt/authorization/async_authorize.html index 9045f89e0..1f5c32de8 100644 --- a/docs/api-docs/slack_bolt/authorization/async_authorize.html +++ b/docs/api-docs/slack_bolt/authorization/async_authorize.html @@ -3,7 +3,7 @@ - + slack_bolt.authorization.async_authorize API documentation @@ -688,7 +688,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/authorization/async_authorize_args.html b/docs/api-docs/slack_bolt/authorization/async_authorize_args.html index 543fc73e0..3d1fc2585 100644 --- a/docs/api-docs/slack_bolt/authorization/async_authorize_args.html +++ b/docs/api-docs/slack_bolt/authorization/async_authorize_args.html @@ -3,7 +3,7 @@ - + slack_bolt.authorization.async_authorize_args API documentation @@ -188,7 +188,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/authorization/authorize.html b/docs/api-docs/slack_bolt/authorization/authorize.html index 61c036a65..e24b8c04a 100644 --- a/docs/api-docs/slack_bolt/authorization/authorize.html +++ b/docs/api-docs/slack_bolt/authorization/authorize.html @@ -3,7 +3,7 @@ - + slack_bolt.authorization.authorize API documentation @@ -687,7 +687,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/authorization/authorize_args.html b/docs/api-docs/slack_bolt/authorization/authorize_args.html index ca04c85b4..d7880c063 100644 --- a/docs/api-docs/slack_bolt/authorization/authorize_args.html +++ b/docs/api-docs/slack_bolt/authorization/authorize_args.html @@ -3,7 +3,7 @@ - + slack_bolt.authorization.authorize_args API documentation @@ -188,7 +188,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/authorization/authorize_result.html b/docs/api-docs/slack_bolt/authorization/authorize_result.html index e6b12ed39..c4f24925e 100644 --- a/docs/api-docs/slack_bolt/authorization/authorize_result.html +++ b/docs/api-docs/slack_bolt/authorization/authorize_result.html @@ -3,7 +3,7 @@ - + slack_bolt.authorization.authorize_result API documentation @@ -324,7 +324,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/authorization/index.html b/docs/api-docs/slack_bolt/authorization/index.html index 7fc9a86a5..d9f943a1e 100644 --- a/docs/api-docs/slack_bolt/authorization/index.html +++ b/docs/api-docs/slack_bolt/authorization/index.html @@ -3,7 +3,7 @@ - + slack_bolt.authorization API documentation @@ -94,7 +94,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/ack/ack.html b/docs/api-docs/slack_bolt/context/ack/ack.html index 42ea41468..b0987d0b3 100644 --- a/docs/api-docs/slack_bolt/context/ack/ack.html +++ b/docs/api-docs/slack_bolt/context/ack/ack.html @@ -3,7 +3,7 @@ - + slack_bolt.context.ack.ack API documentation @@ -165,7 +165,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/ack/async_ack.html b/docs/api-docs/slack_bolt/context/ack/async_ack.html index d662cac7f..4c2f5fcdc 100644 --- a/docs/api-docs/slack_bolt/context/ack/async_ack.html +++ b/docs/api-docs/slack_bolt/context/ack/async_ack.html @@ -3,7 +3,7 @@ - + slack_bolt.context.ack.async_ack API documentation @@ -165,7 +165,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/ack/index.html b/docs/api-docs/slack_bolt/context/ack/index.html index 4012ad063..514717149 100644 --- a/docs/api-docs/slack_bolt/context/ack/index.html +++ b/docs/api-docs/slack_bolt/context/ack/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.ack API documentation @@ -76,7 +76,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/ack/internals.html b/docs/api-docs/slack_bolt/context/ack/internals.html index 29295bd96..535fc4228 100644 --- a/docs/api-docs/slack_bolt/context/ack/internals.html +++ b/docs/api-docs/slack_bolt/context/ack/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.context.ack.internals API documentation @@ -159,7 +159,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/async_context.html b/docs/api-docs/slack_bolt/context/async_context.html index 201d549e1..a417e91b5 100644 --- a/docs/api-docs/slack_bolt/context/async_context.html +++ b/docs/api-docs/slack_bolt/context/async_context.html @@ -3,7 +3,7 @@ - + slack_bolt.context.async_context API documentation @@ -479,7 +479,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/base_context.html b/docs/api-docs/slack_bolt/context/base_context.html index da2cff91b..d694b9e20 100644 --- a/docs/api-docs/slack_bolt/context/base_context.html +++ b/docs/api-docs/slack_bolt/context/base_context.html @@ -3,7 +3,7 @@ - + slack_bolt.context.base_context API documentation @@ -491,7 +491,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/context.html b/docs/api-docs/slack_bolt/context/context.html index dcb95c554..9cbda05ba 100644 --- a/docs/api-docs/slack_bolt/context/context.html +++ b/docs/api-docs/slack_bolt/context/context.html @@ -3,7 +3,7 @@ - + slack_bolt.context.context API documentation @@ -480,7 +480,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/index.html b/docs/api-docs/slack_bolt/context/index.html index b2baa582b..25952a7a2 100644 --- a/docs/api-docs/slack_bolt/context/index.html +++ b/docs/api-docs/slack_bolt/context/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context API documentation @@ -103,7 +103,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/respond/async_respond.html b/docs/api-docs/slack_bolt/context/respond/async_respond.html index 8e332b1cd..0ecb02cc5 100644 --- a/docs/api-docs/slack_bolt/context/respond/async_respond.html +++ b/docs/api-docs/slack_bolt/context/respond/async_respond.html @@ -3,7 +3,7 @@ - + slack_bolt.context.respond.async_respond API documentation @@ -173,7 +173,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/respond/index.html b/docs/api-docs/slack_bolt/context/respond/index.html index e667d1094..24f1fdc1d 100644 --- a/docs/api-docs/slack_bolt/context/respond/index.html +++ b/docs/api-docs/slack_bolt/context/respond/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.respond API documentation @@ -76,7 +76,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/respond/internals.html b/docs/api-docs/slack_bolt/context/respond/internals.html index 0442915c2..9272c1411 100644 --- a/docs/api-docs/slack_bolt/context/respond/internals.html +++ b/docs/api-docs/slack_bolt/context/respond/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.context.respond.internals API documentation @@ -86,7 +86,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/respond/respond.html b/docs/api-docs/slack_bolt/context/respond/respond.html index 54717eeaa..9eaf73276 100644 --- a/docs/api-docs/slack_bolt/context/respond/respond.html +++ b/docs/api-docs/slack_bolt/context/respond/respond.html @@ -3,7 +3,7 @@ - + slack_bolt.context.respond.respond API documentation @@ -177,7 +177,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/say/async_say.html b/docs/api-docs/slack_bolt/context/say/async_say.html index 092d6c038..0b6dd7000 100644 --- a/docs/api-docs/slack_bolt/context/say/async_say.html +++ b/docs/api-docs/slack_bolt/context/say/async_say.html @@ -3,7 +3,7 @@ - + slack_bolt.context.say.async_say API documentation @@ -194,7 +194,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/say/index.html b/docs/api-docs/slack_bolt/context/say/index.html index 4e88eb90b..7f6fc657b 100644 --- a/docs/api-docs/slack_bolt/context/say/index.html +++ b/docs/api-docs/slack_bolt/context/say/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.say API documentation @@ -76,7 +76,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/say/internals.html b/docs/api-docs/slack_bolt/context/say/internals.html index d2e29c737..d9381bd9d 100644 --- a/docs/api-docs/slack_bolt/context/say/internals.html +++ b/docs/api-docs/slack_bolt/context/say/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.context.say.internals API documentation @@ -61,7 +61,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/context/say/say.html b/docs/api-docs/slack_bolt/context/say/say.html index 455e945d5..ce5bdbc95 100644 --- a/docs/api-docs/slack_bolt/context/say/say.html +++ b/docs/api-docs/slack_bolt/context/say/say.html @@ -3,7 +3,7 @@ - + slack_bolt.context.say.say API documentation @@ -195,7 +195,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/error/index.html b/docs/api-docs/slack_bolt/error/index.html index 9b104c4e3..827fecc42 100644 --- a/docs/api-docs/slack_bolt/error/index.html +++ b/docs/api-docs/slack_bolt/error/index.html @@ -3,7 +3,7 @@ - + slack_bolt.error API documentation @@ -174,7 +174,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/index.html b/docs/api-docs/slack_bolt/index.html index 4189aaa45..86e6b3646 100644 --- a/docs/api-docs/slack_bolt/index.html +++ b/docs/api-docs/slack_bolt/index.html @@ -3,7 +3,7 @@ - + slack_bolt API documentation @@ -173,7 +173,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/kwargs_injection/args.html b/docs/api-docs/slack_bolt/kwargs_injection/args.html index 55f0aae66..8e9c717d3 100644 --- a/docs/api-docs/slack_bolt/kwargs_injection/args.html +++ b/docs/api-docs/slack_bolt/kwargs_injection/args.html @@ -3,7 +3,7 @@ - + slack_bolt.kwargs_injection.args API documentation @@ -163,7 +163,7 @@

    Classes

    class Args -(*, logger: logging.Logger, client: slack_sdk.web.client.WebClient, req: BoltRequest, resp: BoltResponse, context: BoltContext, body: Dict[str, Any], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None, shortcut: Optional[Dict[str, Any]] = None, action: Optional[Dict[str, Any]] = None, view: Optional[Dict[str, Any]] = None, command: Optional[Dict[str, Any]] = None, event: Optional[Dict[str, Any]] = None, message: Optional[Dict[str, Any]] = None, ack: Ack, say: Say, respond: Respond, next: Callable[[], NoneType], **kwargs) +(*, logger: logging.Logger, client: slack_sdk.web.client.WebClient, req: BoltRequest, resp: BoltResponse, context: BoltContext, body: Dict[str, Any], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None, shortcut: Optional[Dict[str, Any]] = None, action: Optional[Dict[str, Any]] = None, view: Optional[Dict[str, Any]] = None, command: Optional[Dict[str, Any]] = None, event: Optional[Dict[str, Any]] = None, message: Optional[Dict[str, Any]] = None, ack: Ack, say: Say, respond: Respond, next: Callable[[], None], **kwargs)

    All the arguments in this class are available in any middleware / listeners. @@ -332,11 +332,11 @@

    Class variables

    An alias for payload in an @app.message listener

    -
    var next : Callable[[], NoneType]
    +
    var next : Callable[[], None]

    next() utility function, which tells the middleware chain that it can continue with the next one

    -
    var next_ : Callable[[], NoneType]
    +
    var next_ : Callable[[], None]

    An alias of next() for avoiding the Python built-in method overrides in middleware functions

    @@ -430,7 +430,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/kwargs_injection/async_args.html b/docs/api-docs/slack_bolt/kwargs_injection/async_args.html index 5c0a8378b..3d5d2721d 100644 --- a/docs/api-docs/slack_bolt/kwargs_injection/async_args.html +++ b/docs/api-docs/slack_bolt/kwargs_injection/async_args.html @@ -3,7 +3,7 @@ - + slack_bolt.kwargs_injection.async_args API documentation @@ -159,7 +159,7 @@

    Classes

    class AsyncArgs -(*, logger: logging.Logger, client: slack_sdk.web.async_client.AsyncWebClient, req: AsyncBoltRequest, resp: BoltResponse, context: AsyncBoltContext, body: Dict[str, Any], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None, shortcut: Optional[Dict[str, Any]] = None, action: Optional[Dict[str, Any]] = None, view: Optional[Dict[str, Any]] = None, command: Optional[Dict[str, Any]] = None, event: Optional[Dict[str, Any]] = None, message: Optional[Dict[str, Any]] = None, ack: AsyncAck, say: AsyncSay, respond: AsyncRespond, next: Callable[[], Awaitable[NoneType]], **kwargs) +(*, logger: logging.Logger, client: slack_sdk.web.async_client.AsyncWebClient, req: AsyncBoltRequest, resp: BoltResponse, context: AsyncBoltContext, body: Dict[str, Any], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None, shortcut: Optional[Dict[str, Any]] = None, action: Optional[Dict[str, Any]] = None, view: Optional[Dict[str, Any]] = None, command: Optional[Dict[str, Any]] = None, event: Optional[Dict[str, Any]] = None, message: Optional[Dict[str, Any]] = None, ack: AsyncAck, say: AsyncSay, respond: AsyncRespond, next: Callable[[], Awaitable[None]], **kwargs)

    All the arguments in this class are available in any middleware / listeners. @@ -325,11 +325,11 @@

    Class variables

    An alias for payload in an @app.message listener

    -
    var next : Callable[[], Awaitable[NoneType]]
    +
    var next : Callable[[], Awaitable[None]]

    next() utility function, which tells the middleware chain that it can continue with the next one

    -
    var next_ : Callable[[], Awaitable[NoneType]]
    +
    var next_ : Callable[[], Awaitable[None]]

    An alias of next() for avoiding the Python built-in method overrides in middleware functions

    @@ -423,7 +423,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/kwargs_injection/async_utils.html b/docs/api-docs/slack_bolt/kwargs_injection/async_utils.html index 9bb50f45e..689bf5162 100644 --- a/docs/api-docs/slack_bolt/kwargs_injection/async_utils.html +++ b/docs/api-docs/slack_bolt/kwargs_injection/async_utils.html @@ -3,7 +3,7 @@ - + slack_bolt.kwargs_injection.async_utils API documentation @@ -146,7 +146,7 @@

    Module slack_bolt.kwargs_injection.async_utilsFunctions

    -def build_async_required_kwargs(*, logger: logging.Logger, required_arg_names: Sequence[str], request: AsyncBoltRequest, response: Optional[BoltResponse], next_func: Callable[[], NoneType] = None, this_func: Optional[Callable] = None, error: Optional[Exception] = None, next_keys_required: bool = True) ‑> Dict[str, Any] +def build_async_required_kwargs(*, logger: logging.Logger, required_arg_names: Sequence[str], request: AsyncBoltRequest, response: Optional[BoltResponse], next_func: Callable[[], None] = None, this_func: Optional[Callable] = None, error: Optional[Exception] = None, next_keys_required: bool = True) ‑> Dict[str, Any]
    @@ -270,7 +270,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/kwargs_injection/index.html b/docs/api-docs/slack_bolt/kwargs_injection/index.html index 181d66960..d22d1e46c 100644 --- a/docs/api-docs/slack_bolt/kwargs_injection/index.html +++ b/docs/api-docs/slack_bolt/kwargs_injection/index.html @@ -3,7 +3,7 @@ - + slack_bolt.kwargs_injection API documentation @@ -91,7 +91,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/kwargs_injection/utils.html b/docs/api-docs/slack_bolt/kwargs_injection/utils.html index db77fefab..4b47237d3 100644 --- a/docs/api-docs/slack_bolt/kwargs_injection/utils.html +++ b/docs/api-docs/slack_bolt/kwargs_injection/utils.html @@ -3,7 +3,7 @@ - + slack_bolt.kwargs_injection.utils API documentation @@ -146,7 +146,7 @@

    Module slack_bolt.kwargs_injection.utils

    Functions

    -def build_required_kwargs(*, logger: logging.Logger, required_arg_names: Sequence[str], request: BoltRequest, response: Optional[BoltResponse], next_func: Callable[[], NoneType] = None, this_func: Optional[Callable] = None, error: Optional[Exception] = None, next_keys_required: bool = True) ‑> Dict[str, Any] +def build_required_kwargs(*, logger: logging.Logger, required_arg_names: Sequence[str], request: BoltRequest, response: Optional[BoltResponse], next_func: Callable[[], None] = None, this_func: Optional[Callable] = None, error: Optional[Exception] = None, next_keys_required: bool = True) ‑> Dict[str, Any]
    @@ -270,7 +270,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/lazy_listener/async_internals.html b/docs/api-docs/slack_bolt/lazy_listener/async_internals.html index 8b4e089ab..85eb04a93 100644 --- a/docs/api-docs/slack_bolt/lazy_listener/async_internals.html +++ b/docs/api-docs/slack_bolt/lazy_listener/async_internals.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener.async_internals API documentation @@ -68,7 +68,7 @@

    Module slack_bolt.lazy_listener.async_internalsFunctions

    -async def to_runnable_function(internal_func: Callable[..., Awaitable[NoneType]], logger: logging.Logger, request: AsyncBoltRequest) +async def to_runnable_function(internal_func: Callable[..., Awaitable[None]], logger: logging.Logger, request: AsyncBoltRequest)
    @@ -126,7 +126,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/lazy_listener/async_runner.html b/docs/api-docs/slack_bolt/lazy_listener/async_runner.html index 41b5d0491..8585b1c87 100644 --- a/docs/api-docs/slack_bolt/lazy_listener/async_runner.html +++ b/docs/api-docs/slack_bolt/lazy_listener/async_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener.async_runner API documentation @@ -129,7 +129,7 @@

    Class variables

    Methods

    -async def run(self, function: Callable[..., Awaitable[NoneType]], request: AsyncBoltRequest) ‑> NoneType +async def run(self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest) ‑> None

    Synchronously run the function with a given request data.

    @@ -162,7 +162,7 @@

    Args

    -def start(self, function: Callable[..., Awaitable[NoneType]], request: AsyncBoltRequest) ‑> NoneType +def start(self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest) ‑> None

    Starts a new lazy listener execution.

    @@ -222,7 +222,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/lazy_listener/asyncio_runner.html b/docs/api-docs/slack_bolt/lazy_listener/asyncio_runner.html index 83ca3140a..d4e6edc06 100644 --- a/docs/api-docs/slack_bolt/lazy_listener/asyncio_runner.html +++ b/docs/api-docs/slack_bolt/lazy_listener/asyncio_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener.asyncio_runner API documentation @@ -144,7 +144,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/lazy_listener/index.html b/docs/api-docs/slack_bolt/lazy_listener/index.html index e2bb0a602..e55ee8f71 100644 --- a/docs/api-docs/slack_bolt/lazy_listener/index.html +++ b/docs/api-docs/slack_bolt/lazy_listener/index.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener API documentation @@ -136,7 +136,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/lazy_listener/internals.html b/docs/api-docs/slack_bolt/lazy_listener/internals.html index 2b5ee8fbf..a9c0d103e 100644 --- a/docs/api-docs/slack_bolt/lazy_listener/internals.html +++ b/docs/api-docs/slack_bolt/lazy_listener/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener.internals API documentation @@ -68,7 +68,7 @@

    Module slack_bolt.lazy_listener.internals

    Functions

    -def build_runnable_function(func: Callable[..., NoneType], logger: logging.Logger, request: BoltRequest) ‑> Callable[[], NoneType] +def build_runnable_function(func: Callable[..., None], logger: logging.Logger, request: BoltRequest) ‑> Callable[[], None]
    @@ -126,7 +126,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/lazy_listener/runner.html b/docs/api-docs/slack_bolt/lazy_listener/runner.html index c27bdd401..baefed8f7 100644 --- a/docs/api-docs/slack_bolt/lazy_listener/runner.html +++ b/docs/api-docs/slack_bolt/lazy_listener/runner.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener.runner API documentation @@ -121,7 +121,7 @@

    Class variables

    Methods

    -def run(self, function: Callable[..., NoneType], request: BoltRequest) ‑> NoneType +def run(self, function: Callable[..., None], request: BoltRequest) ‑> None

    Synchronously runs the function with a given request data.

    @@ -151,7 +151,7 @@

    Args

    -def start(self, function: Callable[..., NoneType], request: BoltRequest) ‑> NoneType +def start(self, function: Callable[..., None], request: BoltRequest) ‑> None

    Starts a new lazy listener execution.

    @@ -209,7 +209,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/lazy_listener/thread_runner.html b/docs/api-docs/slack_bolt/lazy_listener/thread_runner.html index 9505ade3d..8644037b2 100644 --- a/docs/api-docs/slack_bolt/lazy_listener/thread_runner.html +++ b/docs/api-docs/slack_bolt/lazy_listener/thread_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener.thread_runner API documentation @@ -26,7 +26,7 @@

    Module slack_bolt.lazy_listener.thread_runner

    Expand source code -
    from concurrent.futures.thread import ThreadPoolExecutor
    +
    from concurrent.futures import Executor
     from logging import Logger
     from typing import Callable
     
    @@ -41,7 +41,7 @@ 

    Module slack_bolt.lazy_listener.thread_runner

    Classes

    class ThreadLazyListenerRunner -(logger: logging.Logger, executor: concurrent.futures.thread.ThreadPoolExecutor) +(logger: logging.Logger, executor: concurrent.futures._base.Executor)
    @@ -81,7 +81,7 @@

    Classes

    def __init__( self, logger: Logger, - executor: ThreadPoolExecutor, + executor: Executor, ): self.logger = logger self.executor = executor @@ -148,7 +148,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/async_builtins.html b/docs/api-docs/slack_bolt/listener/async_builtins.html index f04b99904..e589069bc 100644 --- a/docs/api-docs/slack_bolt/listener/async_builtins.html +++ b/docs/api-docs/slack_bolt/listener/async_builtins.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.async_builtins API documentation @@ -126,7 +126,7 @@

    Class variables

    Methods

    -async def handle_app_uninstalled_events(self, context: AsyncBoltContext) ‑> NoneType +async def handle_app_uninstalled_events(self, context: AsyncBoltContext) ‑> None
    @@ -142,7 +142,7 @@

    Methods

    -async def handle_tokens_revoked_events(self, event: dict, context: AsyncBoltContext) ‑> NoneType +async def handle_tokens_revoked_events(self, event: dict, context: AsyncBoltContext) ‑> None
    @@ -201,7 +201,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/async_listener.html b/docs/api-docs/slack_bolt/listener/async_listener.html index de0cb4c4e..b36ef4c5e 100644 --- a/docs/api-docs/slack_bolt/listener/async_listener.html +++ b/docs/api-docs/slack_bolt/listener/async_listener.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.async_listener API documentation @@ -174,7 +174,7 @@

    Classes

    class AsyncCustomListener -(*, app_name: str, ack_function: Callable[..., Awaitable[Optional[BoltResponse]]], lazy_functions: Sequence[Callable[..., Awaitable[NoneType]]], matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False) +(*, app_name: str, ack_function: Callable[..., Awaitable[Optional[BoltResponse]]], lazy_functions: Sequence[Callable[..., Awaitable[None]]], matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False)
    @@ -249,7 +249,7 @@

    Class variables

    -
    var lazy_functions : Sequence[Callable[..., Awaitable[NoneType]]]
    +
    var lazy_functions : Sequence[Callable[..., Awaitable[None]]]
    @@ -307,7 +307,7 @@

    Returns

    class cls -(*, app_name: str, ack_function: Callable[..., Awaitable[Optional[BoltResponse]]], lazy_functions: Sequence[Callable[..., Awaitable[NoneType]]], matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False) +(*, app_name: str, ack_function: Callable[..., Awaitable[Optional[BoltResponse]]], lazy_functions: Sequence[Callable[..., Awaitable[None]]], matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False)
    @@ -382,7 +382,7 @@

    Class variables

    -
    var lazy_functions : Sequence[Callable[..., Awaitable[NoneType]]]
    +
    var lazy_functions : Sequence[Callable[..., Awaitable[None]]]
    @@ -494,7 +494,7 @@

    Class variables

    -
    var lazy_functions : Sequence[Callable[..., Awaitable[NoneType]]]
    +
    var lazy_functions : Sequence[Callable[..., Awaitable[None]]]
    @@ -676,7 +676,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html b/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html index 7152f6e44..e123987db 100644 --- a/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html +++ b/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.async_listener_completion_handler API documentation @@ -97,7 +97,7 @@

    Classes

    class AsyncCustomListenerCompletionHandler -(logger: logging.Logger, func: Callable[..., Awaitable[NoneType]]) +(logger: logging.Logger, func: Callable[..., Awaitable[None]])
    @@ -205,7 +205,7 @@

    Subclasses

    Methods

    -async def handle(self, request: AsyncBoltRequest, response: Optional[BoltResponse]) ‑> NoneType +async def handle(self, request: AsyncBoltRequest, response: Optional[BoltResponse]) ‑> None

    Handles an unhandled exception.

    @@ -274,7 +274,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/async_listener_error_handler.html b/docs/api-docs/slack_bolt/listener/async_listener_error_handler.html index 26e30d4fd..0aceea7cf 100644 --- a/docs/api-docs/slack_bolt/listener/async_listener_error_handler.html +++ b/docs/api-docs/slack_bolt/listener/async_listener_error_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.async_listener_error_handler API documentation @@ -231,7 +231,7 @@

    Subclasses

    Methods

    -async def handle(self, error: Exception, request: AsyncBoltRequest, response: Optional[BoltResponse]) ‑> NoneType +async def handle(self, error: Exception, request: AsyncBoltRequest, response: Optional[BoltResponse]) ‑> None

    Handles an unhandled exception.

    @@ -301,7 +301,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/asyncio_runner.html b/docs/api-docs/slack_bolt/listener/asyncio_runner.html index 20071b098..55b1969fa 100644 --- a/docs/api-docs/slack_bolt/listener/asyncio_runner.html +++ b/docs/api-docs/slack_bolt/listener/asyncio_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.asyncio_runner API documentation @@ -624,7 +624,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/builtins.html b/docs/api-docs/slack_bolt/listener/builtins.html index bc0783730..205e85c58 100644 --- a/docs/api-docs/slack_bolt/listener/builtins.html +++ b/docs/api-docs/slack_bolt/listener/builtins.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.builtins API documentation @@ -122,7 +122,7 @@

    Class variables

    Methods

    -def handle_app_uninstalled_events(self, context: BoltContext) ‑> NoneType +def handle_app_uninstalled_events(self, context: BoltContext) ‑> None
    @@ -138,7 +138,7 @@

    Methods

    -def handle_tokens_revoked_events(self, event: dict, context: BoltContext) ‑> NoneType +def handle_tokens_revoked_events(self, event: dict, context: BoltContext) ‑> None
    @@ -195,7 +195,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/custom_listener.html b/docs/api-docs/slack_bolt/listener/custom_listener.html index 9cf8f1a44..a0fc17138 100644 --- a/docs/api-docs/slack_bolt/listener/custom_listener.html +++ b/docs/api-docs/slack_bolt/listener/custom_listener.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.custom_listener API documentation @@ -96,7 +96,7 @@

    Classes

    class CustomListener -(*, app_name: str, ack_function: Callable[..., Optional[BoltResponse]], lazy_functions: Sequence[Callable[..., NoneType]], matchers: Sequence[ListenerMatcher], middleware: Sequence[Middleware], auto_acknowledgement: bool = False) +(*, app_name: str, ack_function: Callable[..., Optional[BoltResponse]], lazy_functions: Sequence[Callable[..., None]], matchers: Sequence[ListenerMatcher], middleware: Sequence[Middleware], auto_acknowledgement: bool = False)
    @@ -171,7 +171,7 @@

    Class variables

    -
    var lazy_functions : Sequence[Callable[..., NoneType]]
    +
    var lazy_functions : Sequence[Callable[..., None]]
    @@ -233,7 +233,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/index.html b/docs/api-docs/slack_bolt/listener/index.html index 3b639d9b3..f0deb9b00 100644 --- a/docs/api-docs/slack_bolt/listener/index.html +++ b/docs/api-docs/slack_bolt/listener/index.html @@ -3,7 +3,7 @@ - + slack_bolt.listener API documentation @@ -132,7 +132,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/listener.html b/docs/api-docs/slack_bolt/listener/listener.html index 1b27ecfe3..069235bd8 100644 --- a/docs/api-docs/slack_bolt/listener/listener.html +++ b/docs/api-docs/slack_bolt/listener/listener.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.listener API documentation @@ -192,7 +192,7 @@

    Class variables

    -
    var lazy_functions : Sequence[Callable[..., NoneType]]
    +
    var lazy_functions : Sequence[Callable[..., None]]
    @@ -347,7 +347,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/listener_completion_handler.html b/docs/api-docs/slack_bolt/listener/listener_completion_handler.html index bac54d8dd..f5269d4e3 100644 --- a/docs/api-docs/slack_bolt/listener/listener_completion_handler.html +++ b/docs/api-docs/slack_bolt/listener/listener_completion_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.listener_completion_handler API documentation @@ -96,7 +96,7 @@

    Classes

    class CustomListenerCompletionHandler -(logger: logging.Logger, func: Callable[..., NoneType]) +(logger: logging.Logger, func: Callable[..., None])
    @@ -204,7 +204,7 @@

    Subclasses

    Methods

    -def handle(self, request: BoltRequest, response: Optional[BoltResponse]) ‑> NoneType +def handle(self, request: BoltRequest, response: Optional[BoltResponse]) ‑> None

    Handles an unhandled exception.

    @@ -270,7 +270,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/listener_error_handler.html b/docs/api-docs/slack_bolt/listener/listener_error_handler.html index c43127b7f..de3fe2aac 100644 --- a/docs/api-docs/slack_bolt/listener/listener_error_handler.html +++ b/docs/api-docs/slack_bolt/listener/listener_error_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.listener_error_handler API documentation @@ -227,7 +227,7 @@

    Subclasses

    Methods

    -def handle(self, error: Exception, request: BoltRequest, response: Optional[BoltResponse]) ‑> NoneType +def handle(self, error: Exception, request: BoltRequest, response: Optional[BoltResponse]) ‑> None

    Handles an unhandled exception.

    @@ -297,7 +297,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/thread_runner.html b/docs/api-docs/slack_bolt/listener/thread_runner.html index d3dee70a9..0105a89f7 100644 --- a/docs/api-docs/slack_bolt/listener/thread_runner.html +++ b/docs/api-docs/slack_bolt/listener/thread_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.thread_runner API documentation @@ -27,7 +27,7 @@

    Module slack_bolt.listener.thread_runner

    Expand source code
    import time
    -from concurrent.futures.thread import ThreadPoolExecutor
    +from concurrent.futures import Executor
     from logging import Logger
     from typing import Optional, Callable
     
    @@ -50,7 +50,7 @@ 

    Module slack_bolt.listener.thread_runner

    process_before_response: bool listener_error_handler: ListenerErrorHandler listener_completion_handler: ListenerCompletionHandler - listener_executor: ThreadPoolExecutor + listener_executor: Executor lazy_listener_runner: LazyListenerRunner def __init__( @@ -59,7 +59,7 @@

    Module slack_bolt.listener.thread_runner

    process_before_response: bool, listener_error_handler: ListenerErrorHandler, listener_completion_handler: ListenerCompletionHandler, - listener_executor: ThreadPoolExecutor, + listener_executor: Executor, lazy_listener_runner: LazyListenerRunner, ): self.logger = logger @@ -235,7 +235,7 @@

    Classes

    class ThreadListenerRunner -(logger: logging.Logger, process_before_response: bool, listener_error_handler: ListenerErrorHandler, listener_completion_handler: ListenerCompletionHandler, listener_executor: concurrent.futures.thread.ThreadPoolExecutor, lazy_listener_runner: LazyListenerRunner) +(logger: logging.Logger, process_before_response: bool, listener_error_handler: ListenerErrorHandler, listener_completion_handler: ListenerCompletionHandler, listener_executor: concurrent.futures._base.Executor, lazy_listener_runner: LazyListenerRunner)
    @@ -248,7 +248,7 @@

    Classes

    process_before_response: bool listener_error_handler: ListenerErrorHandler listener_completion_handler: ListenerCompletionHandler - listener_executor: ThreadPoolExecutor + listener_executor: Executor lazy_listener_runner: LazyListenerRunner def __init__( @@ -257,7 +257,7 @@

    Classes

    process_before_response: bool, listener_error_handler: ListenerErrorHandler, listener_completion_handler: ListenerCompletionHandler, - listener_executor: ThreadPoolExecutor, + listener_executor: Executor, lazy_listener_runner: LazyListenerRunner, ): self.logger = logger @@ -435,7 +435,7 @@

    Class variables

    -
    var listener_executor : concurrent.futures.thread.ThreadPoolExecutor
    +
    var listener_executor : concurrent.futures._base.Executor
    @@ -627,7 +627,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener_matcher/async_builtins.html b/docs/api-docs/slack_bolt/listener_matcher/async_builtins.html index f27e58877..2e6d19576 100644 --- a/docs/api-docs/slack_bolt/listener_matcher/async_builtins.html +++ b/docs/api-docs/slack_bolt/listener_matcher/async_builtins.html @@ -3,7 +3,7 @@ - + slack_bolt.listener_matcher.async_builtins API documentation @@ -123,7 +123,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener_matcher/async_listener_matcher.html b/docs/api-docs/slack_bolt/listener_matcher/async_listener_matcher.html index 7475c9121..3287a9b40 100644 --- a/docs/api-docs/slack_bolt/listener_matcher/async_listener_matcher.html +++ b/docs/api-docs/slack_bolt/listener_matcher/async_listener_matcher.html @@ -3,7 +3,7 @@ - + slack_bolt.listener_matcher.async_listener_matcher API documentation @@ -363,7 +363,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener_matcher/builtins.html b/docs/api-docs/slack_bolt/listener_matcher/builtins.html index fec2817de..238285f36 100644 --- a/docs/api-docs/slack_bolt/listener_matcher/builtins.html +++ b/docs/api-docs/slack_bolt/listener_matcher/builtins.html @@ -3,7 +3,7 @@ - + slack_bolt.listener_matcher.builtins API documentation @@ -742,7 +742,7 @@

    Functions

    -def event(constraints: Union[str, re.Pattern, Dict[str, Union[str, Sequence[Union[str, re.Pattern, NoneType]]]]], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def event(constraints: Union[str, re.Pattern, Dict[str, Union[str, Sequence[Union[str, re.Pattern, ForwardRef(None)]]]]], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -1142,7 +1142,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener_matcher/custom_listener_matcher.html b/docs/api-docs/slack_bolt/listener_matcher/custom_listener_matcher.html index dc703cb18..e9c8b0d5f 100644 --- a/docs/api-docs/slack_bolt/listener_matcher/custom_listener_matcher.html +++ b/docs/api-docs/slack_bolt/listener_matcher/custom_listener_matcher.html @@ -3,7 +3,7 @@ - + slack_bolt.listener_matcher.custom_listener_matcher API documentation @@ -166,7 +166,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener_matcher/index.html b/docs/api-docs/slack_bolt/listener_matcher/index.html index e0e36b234..12f5ed614 100644 --- a/docs/api-docs/slack_bolt/listener_matcher/index.html +++ b/docs/api-docs/slack_bolt/listener_matcher/index.html @@ -3,7 +3,7 @@ - + slack_bolt.listener_matcher API documentation @@ -101,7 +101,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener_matcher/listener_matcher.html b/docs/api-docs/slack_bolt/listener_matcher/listener_matcher.html index 0a82d26b1..c9ed6ee8b 100644 --- a/docs/api-docs/slack_bolt/listener_matcher/listener_matcher.html +++ b/docs/api-docs/slack_bolt/listener_matcher/listener_matcher.html @@ -3,7 +3,7 @@ - + slack_bolt.listener_matcher.listener_matcher API documentation @@ -148,7 +148,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/logger/index.html b/docs/api-docs/slack_bolt/logger/index.html index 3797b62e5..42ad13c85 100644 --- a/docs/api-docs/slack_bolt/logger/index.html +++ b/docs/api-docs/slack_bolt/logger/index.html @@ -3,7 +3,7 @@ - + slack_bolt.logger API documentation @@ -137,7 +137,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/logger/messages.html b/docs/api-docs/slack_bolt/logger/messages.html index a8897a065..a29a8e9d0 100644 --- a/docs/api-docs/slack_bolt/logger/messages.html +++ b/docs/api-docs/slack_bolt/logger/messages.html @@ -3,7 +3,7 @@ - + slack_bolt.logger.messages API documentation @@ -912,7 +912,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/async_builtins.html b/docs/api-docs/slack_bolt/middleware/async_builtins.html index dccd18ccf..987ff15fc 100644 --- a/docs/api-docs/slack_bolt/middleware/async_builtins.html +++ b/docs/api-docs/slack_bolt/middleware/async_builtins.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.async_builtins API documentation @@ -63,7 +63,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/async_custom_middleware.html b/docs/api-docs/slack_bolt/middleware/async_custom_middleware.html index 88fd3dab0..f4f454203 100644 --- a/docs/api-docs/slack_bolt/middleware/async_custom_middleware.html +++ b/docs/api-docs/slack_bolt/middleware/async_custom_middleware.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.async_custom_middleware API documentation @@ -204,7 +204,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/async_middleware.html b/docs/api-docs/slack_bolt/middleware/async_middleware.html index d7edd2ceb..300ab3c02 100644 --- a/docs/api-docs/slack_bolt/middleware/async_middleware.html +++ b/docs/api-docs/slack_bolt/middleware/async_middleware.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.async_middleware API documentation @@ -273,7 +273,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/async_middleware_error_handler.html b/docs/api-docs/slack_bolt/middleware/async_middleware_error_handler.html index 9738cc16e..0ca090052 100644 --- a/docs/api-docs/slack_bolt/middleware/async_middleware_error_handler.html +++ b/docs/api-docs/slack_bolt/middleware/async_middleware_error_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.async_middleware_error_handler API documentation @@ -231,7 +231,7 @@

    Subclasses

    Methods

    -async def handle(self, error: Exception, request: AsyncBoltRequest, response: Optional[BoltResponse]) ‑> NoneType +async def handle(self, error: Exception, request: AsyncBoltRequest, response: Optional[BoltResponse]) ‑> None

    Handles an unhandled exception.

    @@ -301,7 +301,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/async_authorization.html index 3fb9544af..40ca48ece 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/async_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_authorization.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.async_authorization API documentation @@ -101,7 +101,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_internals.html b/docs/api-docs/slack_bolt/middleware/authorization/async_internals.html index 452e6f147..db3304745 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/async_internals.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_internals.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.async_internals API documentation @@ -80,7 +80,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html index 60ef76a9d..19c95ca30 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.async_multi_teams_authorization API documentation @@ -234,7 +234,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html index 15307fedf..65fac44b0 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.async_single_team_authorization API documentation @@ -200,7 +200,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/authorization/authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/authorization.html index 1b99b315c..91cf574df 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/authorization.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.authorization API documentation @@ -98,7 +98,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/authorization/index.html b/docs/api-docs/slack_bolt/middleware/authorization/index.html index d2160f4f8..30c22c10b 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/index.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization API documentation @@ -103,7 +103,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/authorization/internals.html b/docs/api-docs/slack_bolt/middleware/authorization/internals.html index 295b3928f..ce91e1b48 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/internals.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.internals API documentation @@ -130,7 +130,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html index 51ec9bf56..1a208e9a1 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.multi_teams_authorization API documentation @@ -239,7 +239,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html index f588554de..c59b62d7d 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.single_team_authorization API documentation @@ -217,7 +217,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/custom_middleware.html b/docs/api-docs/slack_bolt/middleware/custom_middleware.html index 39ad5cd79..901f1b928 100644 --- a/docs/api-docs/slack_bolt/middleware/custom_middleware.html +++ b/docs/api-docs/slack_bolt/middleware/custom_middleware.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.custom_middleware API documentation @@ -196,7 +196,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.html b/docs/api-docs/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.html index 2d26cdcfe..b04e95361 100644 --- a/docs/api-docs/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.html +++ b/docs/api-docs/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.ignoring_self_events.async_ignoring_self_events API documentation @@ -130,7 +130,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.html b/docs/api-docs/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.html index 59094c1fa..e7abb14fc 100644 --- a/docs/api-docs/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.html +++ b/docs/api-docs/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.ignoring_self_events.ignoring_self_events API documentation @@ -195,7 +195,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/ignoring_self_events/index.html b/docs/api-docs/slack_bolt/middleware/ignoring_self_events/index.html index 7ff74ced3..7a02e5fcd 100644 --- a/docs/api-docs/slack_bolt/middleware/ignoring_self_events/index.html +++ b/docs/api-docs/slack_bolt/middleware/ignoring_self_events/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.ignoring_self_events API documentation @@ -70,7 +70,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/index.html b/docs/api-docs/slack_bolt/middleware/index.html index e19b89063..d134d6366 100644 --- a/docs/api-docs/slack_bolt/middleware/index.html +++ b/docs/api-docs/slack_bolt/middleware/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware API documentation @@ -155,7 +155,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.html b/docs/api-docs/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.html index 266574386..aa9a5cd93 100644 --- a/docs/api-docs/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.html +++ b/docs/api-docs/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.message_listener_matches.async_message_listener_matches API documentation @@ -152,7 +152,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/message_listener_matches/index.html b/docs/api-docs/slack_bolt/middleware/message_listener_matches/index.html index d8d306552..f7d2f875c 100644 --- a/docs/api-docs/slack_bolt/middleware/message_listener_matches/index.html +++ b/docs/api-docs/slack_bolt/middleware/message_listener_matches/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.message_listener_matches API documentation @@ -70,7 +70,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/message_listener_matches/message_listener_matches.html b/docs/api-docs/slack_bolt/middleware/message_listener_matches/message_listener_matches.html index 60dc26b23..9f294802d 100644 --- a/docs/api-docs/slack_bolt/middleware/message_listener_matches/message_listener_matches.html +++ b/docs/api-docs/slack_bolt/middleware/message_listener_matches/message_listener_matches.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.message_listener_matches.message_listener_matches API documentation @@ -152,7 +152,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/middleware.html b/docs/api-docs/slack_bolt/middleware/middleware.html index 7c07cfb1b..31559706d 100644 --- a/docs/api-docs/slack_bolt/middleware/middleware.html +++ b/docs/api-docs/slack_bolt/middleware/middleware.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.middleware API documentation @@ -273,7 +273,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/middleware_error_handler.html b/docs/api-docs/slack_bolt/middleware/middleware_error_handler.html index ff2d0b318..1771c3740 100644 --- a/docs/api-docs/slack_bolt/middleware/middleware_error_handler.html +++ b/docs/api-docs/slack_bolt/middleware/middleware_error_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.middleware_error_handler API documentation @@ -227,7 +227,7 @@

    Subclasses

    Methods

    -def handle(self, error: Exception, request: BoltRequest, response: Optional[BoltResponse]) ‑> NoneType +def handle(self, error: Exception, request: BoltRequest, response: Optional[BoltResponse]) ‑> None

    Handles an unhandled exception.

    @@ -297,7 +297,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html b/docs/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html index 5426e8beb..853eaee48 100644 --- a/docs/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html +++ b/docs/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.request_verification.async_request_verification API documentation @@ -168,7 +168,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/request_verification/index.html b/docs/api-docs/slack_bolt/middleware/request_verification/index.html index 5b5e4b74b..ecf4ccbc3 100644 --- a/docs/api-docs/slack_bolt/middleware/request_verification/index.html +++ b/docs/api-docs/slack_bolt/middleware/request_verification/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.request_verification API documentation @@ -70,7 +70,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/request_verification/request_verification.html b/docs/api-docs/slack_bolt/middleware/request_verification/request_verification.html index c6a70ec2e..81e8e9952 100644 --- a/docs/api-docs/slack_bolt/middleware/request_verification/request_verification.html +++ b/docs/api-docs/slack_bolt/middleware/request_verification/request_verification.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.request_verification.request_verification API documentation @@ -213,7 +213,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html b/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html index e29cb2a1e..76e2501a0 100644 --- a/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html +++ b/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.ssl_check.async_ssl_check API documentation @@ -159,7 +159,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/ssl_check/index.html b/docs/api-docs/slack_bolt/middleware/ssl_check/index.html index 1e882b71e..c40c6f89a 100644 --- a/docs/api-docs/slack_bolt/middleware/ssl_check/index.html +++ b/docs/api-docs/slack_bolt/middleware/ssl_check/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.ssl_check API documentation @@ -70,7 +70,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html b/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html index cc328c988..76dcd8647 100644 --- a/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html +++ b/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.ssl_check.ssl_check API documentation @@ -219,7 +219,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/url_verification/async_url_verification.html b/docs/api-docs/slack_bolt/middleware/url_verification/async_url_verification.html index fefdb0e2b..8a862b3c8 100644 --- a/docs/api-docs/slack_bolt/middleware/url_verification/async_url_verification.html +++ b/docs/api-docs/slack_bolt/middleware/url_verification/async_url_verification.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.url_verification.async_url_verification API documentation @@ -134,7 +134,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/url_verification/index.html b/docs/api-docs/slack_bolt/middleware/url_verification/index.html index c05507fa0..418edf7fc 100644 --- a/docs/api-docs/slack_bolt/middleware/url_verification/index.html +++ b/docs/api-docs/slack_bolt/middleware/url_verification/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.url_verification API documentation @@ -70,7 +70,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/middleware/url_verification/url_verification.html b/docs/api-docs/slack_bolt/middleware/url_verification/url_verification.html index 82789210b..e1f45172d 100644 --- a/docs/api-docs/slack_bolt/middleware/url_verification/url_verification.html +++ b/docs/api-docs/slack_bolt/middleware/url_verification/url_verification.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.url_verification.url_verification API documentation @@ -164,7 +164,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/oauth/async_callback_options.html b/docs/api-docs/slack_bolt/oauth/async_callback_options.html index c09e724ae..7bf7a9274 100644 --- a/docs/api-docs/slack_bolt/oauth/async_callback_options.html +++ b/docs/api-docs/slack_bolt/oauth/async_callback_options.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.async_callback_options API documentation @@ -393,7 +393,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/oauth/async_internals.html b/docs/api-docs/slack_bolt/oauth/async_internals.html index f45a65941..f779ee254 100644 --- a/docs/api-docs/slack_bolt/oauth/async_internals.html +++ b/docs/api-docs/slack_bolt/oauth/async_internals.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.async_internals API documentation @@ -157,7 +157,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/oauth/async_oauth_flow.html b/docs/api-docs/slack_bolt/oauth/async_oauth_flow.html index 3730a317f..6aad14534 100644 --- a/docs/api-docs/slack_bolt/oauth/async_oauth_flow.html +++ b/docs/api-docs/slack_bolt/oauth/async_oauth_flow.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.async_oauth_flow API documentation @@ -189,30 +189,32 @@

    Module slack_bolt.oauth.async_oauth_flow

    # ----------------------------- async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse: - state = await self.issue_new_state(request) - url = await self.build_authorize_url(state, request) - set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( - state - ) + set_cookie_value: Optional[str] = None + url = await self.build_authorize_url("", request) + if self.settings.state_validation_enabled is True: + state = await self.issue_new_state(request) + url = await self.build_authorize_url(state, request) + set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( + state + ) if self.settings.install_page_rendering_enabled: html = await self.build_install_page_html(url, request) return BoltResponse( status=200, body=html, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Set-Cookie": [set_cookie_value], - }, + headers=await self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8"}, + set_cookie_value, + ), ) else: return BoltResponse( status=302, body="", - headers={ - "Content-Type": "text/html; charset=utf-8", - "Location": url, - "Set-Cookie": [set_cookie_value], - }, + headers=await self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8", "Location": url}, + set_cookie_value, + ), ) # ---------------------- @@ -227,6 +229,13 @@

    Module slack_bolt.oauth.async_oauth_flow

    async def build_install_page_html(self, url: str, request: AsyncBoltRequest) -> str: return _build_default_install_page_html(url) + async def append_set_cookie_headers( + self, headers: dict, set_cookie_value: Optional[str] + ): + if set_cookie_value is not None: + headers["Set-Cookie"] = [set_cookie_value] + return headers + # ----------------------------- # Callback # ----------------------------- @@ -247,29 +256,30 @@

    Module slack_bolt.oauth.async_oauth_flow

    ) # state parameter verification - state: Optional[str] = request.query.get("state", [None])[0] - if not self.settings.state_utils.is_valid_browser(state, request.headers): - return await self.failure_handler( - AsyncFailureArgs( - request=request, - reason="invalid_browser", - suggested_status_code=400, - settings=self.settings, - default=self.default_callback_options, + if self.settings.state_validation_enabled is True: + state: Optional[str] = request.query.get("state", [None])[0] + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) - valid_state_consumed = await self.settings.state_store.async_consume(state) - if not valid_state_consumed: - return await self.failure_handler( - AsyncFailureArgs( - request=request, - reason="invalid_state", - suggested_status_code=401, - settings=self.settings, - default=self.default_callback_options, + valid_state_consumed = await self.settings.state_store.async_consume(state) + if not valid_state_consumed: + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_state", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) # run installation code = request.query.get("code", [None])[0] @@ -560,30 +570,32 @@

    Args

    # ----------------------------- async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse: - state = await self.issue_new_state(request) - url = await self.build_authorize_url(state, request) - set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( - state - ) + set_cookie_value: Optional[str] = None + url = await self.build_authorize_url("", request) + if self.settings.state_validation_enabled is True: + state = await self.issue_new_state(request) + url = await self.build_authorize_url(state, request) + set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( + state + ) if self.settings.install_page_rendering_enabled: html = await self.build_install_page_html(url, request) return BoltResponse( status=200, body=html, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Set-Cookie": [set_cookie_value], - }, + headers=await self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8"}, + set_cookie_value, + ), ) else: return BoltResponse( status=302, body="", - headers={ - "Content-Type": "text/html; charset=utf-8", - "Location": url, - "Set-Cookie": [set_cookie_value], - }, + headers=await self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8", "Location": url}, + set_cookie_value, + ), ) # ---------------------- @@ -598,6 +610,13 @@

    Args

    async def build_install_page_html(self, url: str, request: AsyncBoltRequest) -> str: return _build_default_install_page_html(url) + async def append_set_cookie_headers( + self, headers: dict, set_cookie_value: Optional[str] + ): + if set_cookie_value is not None: + headers["Set-Cookie"] = [set_cookie_value] + return headers + # ----------------------------- # Callback # ----------------------------- @@ -618,29 +637,30 @@

    Args

    ) # state parameter verification - state: Optional[str] = request.query.get("state", [None])[0] - if not self.settings.state_utils.is_valid_browser(state, request.headers): - return await self.failure_handler( - AsyncFailureArgs( - request=request, - reason="invalid_browser", - suggested_status_code=400, - settings=self.settings, - default=self.default_callback_options, + if self.settings.state_validation_enabled is True: + state: Optional[str] = request.query.get("state", [None])[0] + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) - valid_state_consumed = await self.settings.state_store.async_consume(state) - if not valid_state_consumed: - return await self.failure_handler( - AsyncFailureArgs( - request=request, - reason="invalid_state", - suggested_status_code=401, - settings=self.settings, - default=self.default_callback_options, + valid_state_consumed = await self.settings.state_store.async_consume(state) + if not valid_state_consumed: + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_state", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) # run installation code = request.query.get("code", [None])[0] @@ -910,6 +930,23 @@

    Instance variables

    Methods

    + +
    +
    +
    + +Expand source code + +
    async def append_set_cookie_headers(
    +    self, headers: dict, set_cookie_value: Optional[str]
    +):
    +    if set_cookie_value is not None:
    +        headers["Set-Cookie"] = [set_cookie_value]
    +    return headers
    +
    +
    async def build_authorize_url(self, state: str, request: AsyncBoltRequest) ‑> str
    @@ -961,29 +998,30 @@

    Methods

    ) # state parameter verification - state: Optional[str] = request.query.get("state", [None])[0] - if not self.settings.state_utils.is_valid_browser(state, request.headers): - return await self.failure_handler( - AsyncFailureArgs( - request=request, - reason="invalid_browser", - suggested_status_code=400, - settings=self.settings, - default=self.default_callback_options, + if self.settings.state_validation_enabled is True: + state: Optional[str] = request.query.get("state", [None])[0] + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) - valid_state_consumed = await self.settings.state_store.async_consume(state) - if not valid_state_consumed: - return await self.failure_handler( - AsyncFailureArgs( - request=request, - reason="invalid_state", - suggested_status_code=401, - settings=self.settings, - default=self.default_callback_options, + valid_state_consumed = await self.settings.state_store.async_consume(state) + if not valid_state_consumed: + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_state", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) # run installation code = request.query.get("code", [None])[0] @@ -1047,30 +1085,32 @@

    Methods

    Expand source code
    async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse:
    -    state = await self.issue_new_state(request)
    -    url = await self.build_authorize_url(state, request)
    -    set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state(
    -        state
    -    )
    +    set_cookie_value: Optional[str] = None
    +    url = await self.build_authorize_url("", request)
    +    if self.settings.state_validation_enabled is True:
    +        state = await self.issue_new_state(request)
    +        url = await self.build_authorize_url(state, request)
    +        set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state(
    +            state
    +        )
         if self.settings.install_page_rendering_enabled:
             html = await self.build_install_page_html(url, request)
             return BoltResponse(
                 status=200,
                 body=html,
    -            headers={
    -                "Content-Type": "text/html; charset=utf-8",
    -                "Set-Cookie": [set_cookie_value],
    -            },
    +            headers=await self.append_set_cookie_headers(
    +                {"Content-Type": "text/html; charset=utf-8"},
    +                set_cookie_value,
    +            ),
             )
         else:
             return BoltResponse(
                 status=302,
                 body="",
    -            headers={
    -                "Content-Type": "text/html; charset=utf-8",
    -                "Location": url,
    -                "Set-Cookie": [set_cookie_value],
    -            },
    +            headers=await self.append_set_cookie_headers(
    +                {"Content-Type": "text/html; charset=utf-8", "Location": url},
    +                set_cookie_value,
    +            ),
             )

    @@ -1199,6 +1239,7 @@

    Index

  • AsyncOAuthFlow

      +
    • append_set_cookie_headers
    • build_authorize_url
    • build_install_page_html
    • client
    • @@ -1224,7 +1265,7 @@

      -

      Generated by pdoc 0.9.2.

      +

      Generated by pdoc 0.10.0.

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html b/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html index 29e0eaf01..33a984dcb 100644 --- a/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html +++ b/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.async_oauth_settings API documentation @@ -72,6 +72,7 @@

      Module slack_bolt.oauth.async_oauth_settings

      token_rotation_expiration_minutes: int authorize: AsyncAuthorize # state parameter related configurations + state_validation_enabled: bool state_store: AsyncOAuthStateStore state_cookie_name: str state_expiration_seconds: int @@ -104,6 +105,7 @@

      Module slack_bolt.oauth.async_oauth_settings

      installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, # state parameter related configurations + state_validation_enabled: bool = True, state_store: Optional[AsyncOAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, @@ -128,6 +130,7 @@

      Module slack_bolt.oauth.async_oauth_settings

      installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) @@ -181,6 +184,7 @@

      Module slack_bolt.oauth.async_oauth_settings

      bot_only=self.installation_store_bot_only, ) # state parameter related configurations + self.state_validation_enabled = state_validation_enabled self.state_store = state_store or FileOAuthStateStore( expiration_seconds=state_expiration_seconds, client_id=client_id, @@ -218,7 +222,7 @@

      Classes

      class AsyncOAuthSettings -(*, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Union[str, Sequence[str], NoneType] = None, user_scopes: Union[str, Sequence[str], NoneType] = None, redirect_uri: Optional[str] = None, install_path: str = '/slack/install', install_page_rendering_enabled: bool = True, redirect_uri_path: str = '/slack/oauth_redirect', callback_options: Optional[AsyncCallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, state_store: Optional[slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, logger: logging.Logger = <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>) +(*, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Union[str, Sequence[str], ForwardRef(None)] = None, user_scopes: Union[str, Sequence[str], ForwardRef(None)] = None, redirect_uri: Optional[str] = None, install_path: str = '/slack/install', install_page_rendering_enabled: bool = True, redirect_uri_path: str = '/slack/oauth_redirect', callback_options: Optional[AsyncCallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, state_validation_enabled: bool = True, state_store: Optional[slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, logger: logging.Logger = <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>)

      The settings for Slack App installation (OAuth flow).

      @@ -254,6 +258,8 @@

      Args

      Use InstallationStore#find_bot() if True (Default: False)
      token_rotation_expiration_minutes
      Minutes before refreshing tokens (Default: 2 hours)
      +
      state_validation_enabled
      +
      Set False if your OAuth flow omits the state parameter validation (Default: True)
      state_store
      Specify the instance of InstallationStore (Default: FileOAuthStateStore)
      state_cookie_name
      @@ -288,6 +294,7 @@

      Args

      token_rotation_expiration_minutes: int authorize: AsyncAuthorize # state parameter related configurations + state_validation_enabled: bool state_store: AsyncOAuthStateStore state_cookie_name: str state_expiration_seconds: int @@ -320,6 +327,7 @@

      Args

      installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, # state parameter related configurations + state_validation_enabled: bool = True, state_store: Optional[AsyncOAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, @@ -344,6 +352,7 @@

      Args

      installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) @@ -397,6 +406,7 @@

      Args

      bot_only=self.installation_store_bot_only, ) # state parameter related configurations + self.state_validation_enabled = state_validation_enabled self.state_store = state_store or FileOAuthStateStore( expiration_seconds=state_expiration_seconds, client_id=client_id, @@ -504,6 +514,10 @@

      Class variables

      +
      var state_validation_enabled : bool
      +
      +
      +
      var success_url : Optional[str]
      @@ -557,6 +571,7 @@

      state_expiration_seconds
    • state_store
    • state_utils
    • +
    • state_validation_enabled
    • success_url
    • token_rotation_expiration_minutes
    • user_scopes
    • @@ -568,7 +583,7 @@

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/oauth/callback_options.html b/docs/api-docs/slack_bolt/oauth/callback_options.html index f0d2ccac4..f320fcba8 100644 --- a/docs/api-docs/slack_bolt/oauth/callback_options.html +++ b/docs/api-docs/slack_bolt/oauth/callback_options.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.callback_options API documentation @@ -415,7 +415,7 @@

      -

      Generated by pdoc 0.9.2.

      +

      Generated by pdoc 0.10.0.

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/oauth/index.html b/docs/api-docs/slack_bolt/oauth/index.html index 7ee2eab58..74db2f17b 100644 --- a/docs/api-docs/slack_bolt/oauth/index.html +++ b/docs/api-docs/slack_bolt/oauth/index.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth API documentation @@ -108,7 +108,7 @@

      Index

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/oauth/internals.html b/docs/api-docs/slack_bolt/oauth/internals.html index 68a5ce7bc..90934dc3a 100644 --- a/docs/api-docs/slack_bolt/oauth/internals.html +++ b/docs/api-docs/slack_bolt/oauth/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.internals API documentation @@ -319,7 +319,7 @@

      -

      Generated by pdoc 0.9.2.

      +

      Generated by pdoc 0.10.0.

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/oauth/oauth_flow.html b/docs/api-docs/slack_bolt/oauth/oauth_flow.html index 4d345e60b..d08d228be 100644 --- a/docs/api-docs/slack_bolt/oauth/oauth_flow.html +++ b/docs/api-docs/slack_bolt/oauth/oauth_flow.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.oauth_flow API documentation @@ -186,30 +186,33 @@

      Module slack_bolt.oauth.oauth_flow

      # ----------------------------- def handle_installation(self, request: BoltRequest) -> BoltResponse: - state = self.issue_new_state(request) - url = self.build_authorize_url(state, request) - set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( - state - ) + set_cookie_value: Optional[str] = None + url = self.build_authorize_url("", request) + if self.settings.state_validation_enabled is True: + state = self.issue_new_state(request) + url = self.build_authorize_url(state, request) + set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( + state + ) + if self.settings.install_page_rendering_enabled: html = self.build_install_page_html(url, request) return BoltResponse( status=200, body=html, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Set-Cookie": [set_cookie_value], - }, + headers=self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8"}, + set_cookie_value, + ), ) else: return BoltResponse( status=302, body="", - headers={ - "Content-Type": "text/html; charset=utf-8", - "Location": url, - "Set-Cookie": [set_cookie_value], - }, + headers=self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8", "Location": url}, + set_cookie_value, + ), ) # ---------------------- @@ -224,6 +227,11 @@

      Module slack_bolt.oauth.oauth_flow

      def build_install_page_html(self, url: str, request: BoltRequest) -> str: return _build_default_install_page_html(url) + def append_set_cookie_headers(self, headers: dict, set_cookie_value: Optional[str]): + if set_cookie_value is not None: + headers["Set-Cookie"] = [set_cookie_value] + return headers + # ----------------------------- # Callback # ----------------------------- @@ -244,29 +252,30 @@

      Module slack_bolt.oauth.oauth_flow

      ) # state parameter verification - state = request.query.get("state", [None])[0] - if not self.settings.state_utils.is_valid_browser(state, request.headers): - return self.failure_handler( - FailureArgs( - request=request, - reason="invalid_browser", - suggested_status_code=400, - settings=self.settings, - default=self.default_callback_options, + if self.settings.state_validation_enabled is True: + state = request.query.get("state", [None])[0] + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) - valid_state_consumed = self.settings.state_store.consume(state) - if not valid_state_consumed: - return self.failure_handler( - FailureArgs( - request=request, - reason="invalid_state", - suggested_status_code=401, - settings=self.settings, - default=self.default_callback_options, + valid_state_consumed = self.settings.state_store.consume(state) + if not valid_state_consumed: + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_state", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) # run installation code = request.query.get("code", [None])[0] @@ -553,30 +562,33 @@

      Args

      # ----------------------------- def handle_installation(self, request: BoltRequest) -> BoltResponse: - state = self.issue_new_state(request) - url = self.build_authorize_url(state, request) - set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( - state - ) + set_cookie_value: Optional[str] = None + url = self.build_authorize_url("", request) + if self.settings.state_validation_enabled is True: + state = self.issue_new_state(request) + url = self.build_authorize_url(state, request) + set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state( + state + ) + if self.settings.install_page_rendering_enabled: html = self.build_install_page_html(url, request) return BoltResponse( status=200, body=html, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Set-Cookie": [set_cookie_value], - }, + headers=self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8"}, + set_cookie_value, + ), ) else: return BoltResponse( status=302, body="", - headers={ - "Content-Type": "text/html; charset=utf-8", - "Location": url, - "Set-Cookie": [set_cookie_value], - }, + headers=self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8", "Location": url}, + set_cookie_value, + ), ) # ---------------------- @@ -591,6 +603,11 @@

      Args

      def build_install_page_html(self, url: str, request: BoltRequest) -> str: return _build_default_install_page_html(url) + def append_set_cookie_headers(self, headers: dict, set_cookie_value: Optional[str]): + if set_cookie_value is not None: + headers["Set-Cookie"] = [set_cookie_value] + return headers + # ----------------------------- # Callback # ----------------------------- @@ -611,29 +628,30 @@

      Args

      ) # state parameter verification - state = request.query.get("state", [None])[0] - if not self.settings.state_utils.is_valid_browser(state, request.headers): - return self.failure_handler( - FailureArgs( - request=request, - reason="invalid_browser", - suggested_status_code=400, - settings=self.settings, - default=self.default_callback_options, + if self.settings.state_validation_enabled is True: + state = request.query.get("state", [None])[0] + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) - valid_state_consumed = self.settings.state_store.consume(state) - if not valid_state_consumed: - return self.failure_handler( - FailureArgs( - request=request, - reason="invalid_state", - suggested_status_code=401, - settings=self.settings, - default=self.default_callback_options, + valid_state_consumed = self.settings.state_store.consume(state) + if not valid_state_consumed: + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_state", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) # run installation code = request.query.get("code", [None])[0] @@ -907,6 +925,21 @@

      Instance variables

      Methods

      + +
      +
      +
      + +Expand source code + +
      def append_set_cookie_headers(self, headers: dict, set_cookie_value: Optional[str]):
      +    if set_cookie_value is not None:
      +        headers["Set-Cookie"] = [set_cookie_value]
      +    return headers
      +
      +
      def build_authorize_url(self, state: str, request: BoltRequest) ‑> str
      @@ -958,29 +991,30 @@

      Methods

      ) # state parameter verification - state = request.query.get("state", [None])[0] - if not self.settings.state_utils.is_valid_browser(state, request.headers): - return self.failure_handler( - FailureArgs( - request=request, - reason="invalid_browser", - suggested_status_code=400, - settings=self.settings, - default=self.default_callback_options, + if self.settings.state_validation_enabled is True: + state = request.query.get("state", [None])[0] + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) - valid_state_consumed = self.settings.state_store.consume(state) - if not valid_state_consumed: - return self.failure_handler( - FailureArgs( - request=request, - reason="invalid_state", - suggested_status_code=401, - settings=self.settings, - default=self.default_callback_options, + valid_state_consumed = self.settings.state_store.consume(state) + if not valid_state_consumed: + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_state", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) # run installation code = request.query.get("code", [None])[0] @@ -1044,30 +1078,33 @@

      Methods

      Expand source code
      def handle_installation(self, request: BoltRequest) -> BoltResponse:
      -    state = self.issue_new_state(request)
      -    url = self.build_authorize_url(state, request)
      -    set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state(
      -        state
      -    )
      +    set_cookie_value: Optional[str] = None
      +    url = self.build_authorize_url("", request)
      +    if self.settings.state_validation_enabled is True:
      +        state = self.issue_new_state(request)
      +        url = self.build_authorize_url(state, request)
      +        set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state(
      +            state
      +        )
      +
           if self.settings.install_page_rendering_enabled:
               html = self.build_install_page_html(url, request)
               return BoltResponse(
                   status=200,
                   body=html,
      -            headers={
      -                "Content-Type": "text/html; charset=utf-8",
      -                "Set-Cookie": [set_cookie_value],
      -            },
      +            headers=self.append_set_cookie_headers(
      +                {"Content-Type": "text/html; charset=utf-8"},
      +                set_cookie_value,
      +            ),
               )
           else:
               return BoltResponse(
                   status=302,
                   body="",
      -            headers={
      -                "Content-Type": "text/html; charset=utf-8",
      -                "Location": url,
      -                "Set-Cookie": [set_cookie_value],
      -            },
      +            headers=self.append_set_cookie_headers(
      +                {"Content-Type": "text/html; charset=utf-8", "Location": url},
      +                set_cookie_value,
      +            ),
               )

  • @@ -1194,6 +1231,7 @@

    Index

  • OAuthFlow

      +
    • append_set_cookie_headers
    • build_authorize_url
    • build_install_page_html
    • client
    • @@ -1219,7 +1257,7 @@

      -

      Generated by pdoc 0.9.2.

      +

      Generated by pdoc 0.10.0.

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/oauth/oauth_settings.html b/docs/api-docs/slack_bolt/oauth/oauth_settings.html index 83cee181b..a75cdbf60 100644 --- a/docs/api-docs/slack_bolt/oauth/oauth_settings.html +++ b/docs/api-docs/slack_bolt/oauth/oauth_settings.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.oauth_settings API documentation @@ -67,6 +67,7 @@

      Module slack_bolt.oauth.oauth_settings

      token_rotation_expiration_minutes: int authorize: Authorize # state parameter related configurations + state_validation_enabled: bool state_store: OAuthStateStore state_cookie_name: str state_expiration_seconds: int @@ -99,6 +100,7 @@

      Module slack_bolt.oauth.oauth_settings

      installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, # state parameter related configurations + state_validation_enabled: bool = True, state_store: Optional[OAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, @@ -123,6 +125,7 @@

      Module slack_bolt.oauth.oauth_settings

      installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) @@ -175,6 +178,7 @@

      Module slack_bolt.oauth.oauth_settings

      bot_only=self.installation_store_bot_only, ) # state parameter related configurations + self.state_validation_enabled = state_validation_enabled self.state_store = state_store or FileOAuthStateStore( expiration_seconds=state_expiration_seconds, client_id=client_id, @@ -212,7 +216,7 @@

      Classes

      class OAuthSettings -(*, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Union[str, Sequence[str], NoneType] = None, user_scopes: Union[str, Sequence[str], NoneType] = None, redirect_uri: Optional[str] = None, install_path: str = '/slack/install', install_page_rendering_enabled: bool = True, redirect_uri_path: str = '/slack/oauth_redirect', callback_options: Optional[CallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, state_store: Optional[slack_sdk.oauth.state_store.state_store.OAuthStateStore] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, logger: logging.Logger = <Logger slack_bolt.oauth.oauth_settings (WARNING)>) +(*, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Union[str, Sequence[str], ForwardRef(None)] = None, user_scopes: Union[str, Sequence[str], ForwardRef(None)] = None, redirect_uri: Optional[str] = None, install_path: str = '/slack/install', install_page_rendering_enabled: bool = True, redirect_uri_path: str = '/slack/oauth_redirect', callback_options: Optional[CallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, state_validation_enabled: bool = True, state_store: Optional[slack_sdk.oauth.state_store.state_store.OAuthStateStore] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, logger: logging.Logger = <Logger slack_bolt.oauth.oauth_settings (WARNING)>)

      The settings for Slack App installation (OAuth flow).

      @@ -248,6 +252,8 @@

      Args

      Use InstallationStore#find_bot() if True (Default: False)
      token_rotation_expiration_minutes
      Minutes before refreshing tokens (Default: 2 hours)
      +
      state_validation_enabled
      +
      Set False if your OAuth flow omits the state parameter validation (Default: True)
      state_store
      Specify the instance of InstallationStore (Default: FileOAuthStateStore)
      state_cookie_name
      @@ -282,6 +288,7 @@

      Args

      token_rotation_expiration_minutes: int authorize: Authorize # state parameter related configurations + state_validation_enabled: bool state_store: OAuthStateStore state_cookie_name: str state_expiration_seconds: int @@ -314,6 +321,7 @@

      Args

      installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, # state parameter related configurations + state_validation_enabled: bool = True, state_store: Optional[OAuthStateStore] = None, state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, @@ -338,6 +346,7 @@

      Args

      installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) @@ -390,6 +399,7 @@

      Args

      bot_only=self.installation_store_bot_only, ) # state parameter related configurations + self.state_validation_enabled = state_validation_enabled self.state_store = state_store or FileOAuthStateStore( expiration_seconds=state_expiration_seconds, client_id=client_id, @@ -497,6 +507,10 @@

      Class variables

      +
      var state_validation_enabled : bool
      +
      +
      +
      var success_url : Optional[str]
      @@ -550,6 +564,7 @@

      state_expiration_seconds
    • state_store
    • state_utils
    • +
    • state_validation_enabled
    • success_url
    • token_rotation_expiration_minutes
    • user_scopes
    • @@ -561,7 +576,7 @@

      -

      Generated by pdoc 0.9.2.

      +

      Generated by pdoc 0.10.0.

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/request/async_internals.html b/docs/api-docs/slack_bolt/request/async_internals.html index d8dec5586..516d8209b 100644 --- a/docs/api-docs/slack_bolt/request/async_internals.html +++ b/docs/api-docs/slack_bolt/request/async_internals.html @@ -3,7 +3,7 @@ - + slack_bolt.request.async_internals API documentation @@ -137,7 +137,7 @@

      Index

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/request/async_request.html b/docs/api-docs/slack_bolt/request/async_request.html index 63aa9a079..2106594cd 100644 --- a/docs/api-docs/slack_bolt/request/async_request.html +++ b/docs/api-docs/slack_bolt/request/async_request.html @@ -3,7 +3,7 @@ - + slack_bolt.request.async_request API documentation @@ -116,7 +116,7 @@

      Classes

      class AsyncBoltRequest -(*, body: Union[str, dict], query: Union[str, Dict[str, str], Dict[str, Sequence[str]], NoneType] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, str]] = None, mode: str = 'http') +(*, body: Union[str, dict], query: Union[str, Dict[str, str], Dict[str, Sequence[str]], ForwardRef(None)] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, str]] = None, mode: str = 'http')

      Request to a Bolt app.

      @@ -277,7 +277,7 @@

      -

      Generated by pdoc 0.9.2.

      +

      Generated by pdoc 0.10.0.

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/request/index.html b/docs/api-docs/slack_bolt/request/index.html index e61b0a42e..20cee996a 100644 --- a/docs/api-docs/slack_bolt/request/index.html +++ b/docs/api-docs/slack_bolt/request/index.html @@ -3,7 +3,7 @@ - + slack_bolt.request API documentation @@ -94,7 +94,7 @@

      Index

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/request/internals.html b/docs/api-docs/slack_bolt/request/internals.html index e74c966a6..2df2c8794 100644 --- a/docs/api-docs/slack_bolt/request/internals.html +++ b/docs/api-docs/slack_bolt/request/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.request.internals API documentation @@ -489,7 +489,7 @@

      Functions

      -def parse_query(query: Union[str, Dict[str, str], Dict[str, Sequence[str]], NoneType]) ‑> Dict[str, Sequence[str]] +def parse_query(query: Union[str, Dict[str, str], Dict[str, Sequence[str]], ForwardRef(None)]) ‑> Dict[str, Sequence[str]]
      @@ -556,7 +556,7 @@

      Index

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/request/payload_utils.html b/docs/api-docs/slack_bolt/request/payload_utils.html index fcf365fb2..6bd31242b 100644 --- a/docs/api-docs/slack_bolt/request/payload_utils.html +++ b/docs/api-docs/slack_bolt/request/payload_utils.html @@ -3,7 +3,7 @@ - + slack_bolt.request.payload_utils API documentation @@ -760,7 +760,7 @@

      Index

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/request/request.html b/docs/api-docs/slack_bolt/request/request.html index 10ddce01d..deec3891a 100644 --- a/docs/api-docs/slack_bolt/request/request.html +++ b/docs/api-docs/slack_bolt/request/request.html @@ -3,7 +3,7 @@ - + slack_bolt.request.request API documentation @@ -113,7 +113,7 @@

      Classes

      class BoltRequest -(*, body: Union[str, dict], query: Union[str, Dict[str, str], Dict[str, Sequence[str]], NoneType] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, str]] = None, mode: str = 'http') +(*, body: Union[str, dict], query: Union[str, Dict[str, str], Dict[str, Sequence[str]], ForwardRef(None)] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, str]] = None, mode: str = 'http')

      Request to a Bolt app.

      @@ -271,7 +271,7 @@

      -

      Generated by pdoc 0.9.2.

      +

      Generated by pdoc 0.10.0.

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/response/index.html b/docs/api-docs/slack_bolt/response/index.html index 4c028367a..93ad52e73 100644 --- a/docs/api-docs/slack_bolt/response/index.html +++ b/docs/api-docs/slack_bolt/response/index.html @@ -3,7 +3,7 @@ - + slack_bolt.response API documentation @@ -77,7 +77,7 @@

      Index

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/response/response.html b/docs/api-docs/slack_bolt/response/response.html index 6d6967c47..e95128bf9 100644 --- a/docs/api-docs/slack_bolt/response/response.html +++ b/docs/api-docs/slack_bolt/response/response.html @@ -3,7 +3,7 @@ - + slack_bolt.response.response API documentation @@ -262,7 +262,7 @@

      -

      Generated by pdoc 0.9.2.

      +

      Generated by pdoc 0.10.0.

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/util/async_utils.html b/docs/api-docs/slack_bolt/util/async_utils.html index bd0bc19e8..c6b6d683e 100644 --- a/docs/api-docs/slack_bolt/util/async_utils.html +++ b/docs/api-docs/slack_bolt/util/async_utils.html @@ -3,7 +3,7 @@ - + slack_bolt.util.async_utils API documentation @@ -95,7 +95,7 @@

      Index

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/util/index.html b/docs/api-docs/slack_bolt/util/index.html index de3468e3f..ec43aa5a6 100644 --- a/docs/api-docs/slack_bolt/util/index.html +++ b/docs/api-docs/slack_bolt/util/index.html @@ -3,7 +3,7 @@ - + slack_bolt.util API documentation @@ -71,7 +71,7 @@

      Index

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/util/utils.html b/docs/api-docs/slack_bolt/util/utils.html index 2a18a57c3..400668827 100644 --- a/docs/api-docs/slack_bolt/util/utils.html +++ b/docs/api-docs/slack_bolt/util/utils.html @@ -3,7 +3,7 @@ - + slack_bolt.util.utils API documentation @@ -290,7 +290,7 @@

      Index

      \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index d82713ad6..a6b6553e4 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -3,7 +3,7 @@ - + slack_bolt.version API documentation @@ -28,7 +28,7 @@

      Module slack_bolt.version

      Expand source code
      """Check the latest version at https://pypi.org/project/slack-bolt/"""
      -__version__ = "1.8.1"
      +__version__ = "1.9.0"

  • @@ -55,7 +55,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/index.html b/docs/api-docs/slack_bolt/workflows/index.html index 547d52085..ac7f67988 100644 --- a/docs/api-docs/slack_bolt/workflows/index.html +++ b/docs/api-docs/slack_bolt/workflows/index.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows API documentation @@ -82,7 +82,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/async_step.html b/docs/api-docs/slack_bolt/workflows/step/async_step.html index ce9a05f5d..c0a8c39d6 100644 --- a/docs/api-docs/slack_bolt/workflows/step/async_step.html +++ b/docs/api-docs/slack_bolt/workflows/step/async_step.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.async_step API documentation @@ -1096,7 +1096,7 @@

    Returns

    -def edit(self, *args, matchers: Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher, NoneType] = None, middleware: Union[Callable, AsyncMiddleware, NoneType] = None, lazy: Optional[List[Callable[..., Awaitable[NoneType]]]] = None) +def edit(self, *args, matchers: Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, AsyncMiddleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None)

    Registers a new edit listener with details. @@ -1179,7 +1179,7 @@

    Args

    -def execute(self, *args, matchers: Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher, NoneType] = None, middleware: Union[Callable, AsyncMiddleware, NoneType] = None, lazy: Optional[List[Callable[..., Awaitable[NoneType]]]] = None) +def execute(self, *args, matchers: Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, AsyncMiddleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None)

    Registers a new execute listener with details. @@ -1264,7 +1264,7 @@

    Args

    -def save(self, *args, matchers: Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher, NoneType] = None, middleware: Union[Callable, AsyncMiddleware, NoneType] = None, lazy: Optional[List[Callable[..., Awaitable[NoneType]]]] = None) +def save(self, *args, matchers: Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, AsyncMiddleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None)

    Registers a new save listener with details. @@ -1393,7 +1393,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/async_step_middleware.html b/docs/api-docs/slack_bolt/workflows/step/async_step_middleware.html index 4b6583b47..19a929810 100644 --- a/docs/api-docs/slack_bolt/workflows/step/async_step_middleware.html +++ b/docs/api-docs/slack_bolt/workflows/step/async_step_middleware.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.async_step_middleware API documentation @@ -194,7 +194,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/index.html b/docs/api-docs/slack_bolt/workflows/step/index.html index 775e948c8..3f4057cb1 100644 --- a/docs/api-docs/slack_bolt/workflows/step/index.html +++ b/docs/api-docs/slack_bolt/workflows/step/index.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step API documentation @@ -95,7 +95,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/internals.html b/docs/api-docs/slack_bolt/workflows/step/internals.html index 0cc20f847..ad20571a6 100644 --- a/docs/api-docs/slack_bolt/workflows/step/internals.html +++ b/docs/api-docs/slack_bolt/workflows/step/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.internals API documentation @@ -62,7 +62,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/step.html b/docs/api-docs/slack_bolt/workflows/step/step.html index f913db2b1..d65f4f9d1 100644 --- a/docs/api-docs/slack_bolt/workflows/step/step.html +++ b/docs/api-docs/slack_bolt/workflows/step/step.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.step API documentation @@ -1061,7 +1061,7 @@

    Returns

    -def edit(self, *args, matchers: Union[Callable[..., bool], ListenerMatcher, NoneType] = None, middleware: Union[Callable, Middleware, NoneType] = None, lazy: Optional[List[Callable[..., NoneType]]] = None) +def edit(self, *args, matchers: Union[Callable[..., bool], ListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, Middleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., None]]] = None)

    Registers a new edit listener with details. @@ -1143,7 +1143,7 @@

    Args

    -def execute(self, *args, matchers: Union[Callable[..., bool], ListenerMatcher, NoneType] = None, middleware: Union[Callable, Middleware, NoneType] = None, lazy: Optional[List[Callable[..., NoneType]]] = None) +def execute(self, *args, matchers: Union[Callable[..., bool], ListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, Middleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., None]]] = None)

    Registers a new execute listener with details. @@ -1226,7 +1226,7 @@

    Args

    -def save(self, *args, matchers: Union[Callable[..., bool], ListenerMatcher, NoneType] = None, middleware: Union[Callable, Middleware, NoneType] = None, lazy: Optional[List[Callable[..., NoneType]]] = None) +def save(self, *args, matchers: Union[Callable[..., bool], ListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, Middleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., None]]] = None)

    Registers a new save listener with details. @@ -1353,7 +1353,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/step_middleware.html b/docs/api-docs/slack_bolt/workflows/step/step_middleware.html index a80d4d335..191009385 100644 --- a/docs/api-docs/slack_bolt/workflows/step/step_middleware.html +++ b/docs/api-docs/slack_bolt/workflows/step/step_middleware.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.step_middleware API documentation @@ -196,7 +196,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/async_complete.html b/docs/api-docs/slack_bolt/workflows/step/utilities/async_complete.html index f75f2d8aa..f1b53e2bf 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/async_complete.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/async_complete.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.async_complete API documentation @@ -166,7 +166,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/async_configure.html b/docs/api-docs/slack_bolt/workflows/step/utilities/async_configure.html index e6436faef..54d89a110 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/async_configure.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/async_configure.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.async_configure API documentation @@ -204,7 +204,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/async_fail.html b/docs/api-docs/slack_bolt/workflows/step/utilities/async_fail.html index 56488dcd6..2b8a6616b 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/async_fail.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/async_fail.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.async_fail API documentation @@ -165,7 +165,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/async_update.html b/docs/api-docs/slack_bolt/workflows/step/utilities/async_update.html index ca8d703f9..bce9199a0 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/async_update.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/async_update.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.async_update API documentation @@ -210,7 +210,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/complete.html b/docs/api-docs/slack_bolt/workflows/step/utilities/complete.html index 23023ee67..04fda2a77 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/complete.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/complete.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.complete API documentation @@ -166,7 +166,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/configure.html b/docs/api-docs/slack_bolt/workflows/step/utilities/configure.html index 92601779b..2d2e5a2ee 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/configure.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/configure.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.configure API documentation @@ -202,7 +202,7 @@

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/fail.html b/docs/api-docs/slack_bolt/workflows/step/utilities/fail.html index a71efa254..dabf15b1d 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/fail.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/fail.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.fail API documentation @@ -165,7 +165,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/index.html b/docs/api-docs/slack_bolt/workflows/step/utilities/index.html index b91db253f..da8e077ec 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/index.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/index.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities API documentation @@ -138,7 +138,7 @@

    Index

    \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/workflows/step/utilities/update.html b/docs/api-docs/slack_bolt/workflows/step/utilities/update.html index c14e8cc22..51e87b126 100644 --- a/docs/api-docs/slack_bolt/workflows/step/utilities/update.html +++ b/docs/api-docs/slack_bolt/workflows/step/utilities/update.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.update API documentation @@ -210,7 +210,7 @@

    -

    Generated by pdoc 0.9.2.

    +

    Generated by pdoc 0.10.0.

    \ No newline at end of file From a3885469b198ee58e8d3f519f7c70f43586c410c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 7 Sep 2021 18:49:06 +0900 Subject: [PATCH 386/865] Fix #459 Invalid type hints in App / AsyncApp (#460) --- slack_bolt/app/app.py | 36 ++++++++++++++++++------------------ slack_bolt/app/async_app.py | 34 +++++++++++++++++----------------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index ef52b7c02..c71afc18c 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -684,7 +684,7 @@ def step( def error( self, func: Callable[..., Optional[BoltResponse]] - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[BoltResponse]]: """Updates the global error handler. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -722,7 +722,7 @@ def event( ], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new event listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -763,7 +763,7 @@ def message( keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new message event listener. This method can be used as either a decorator or a method. Check the `App#event` method's docstring for details. @@ -811,7 +811,7 @@ def command( command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new slash command listener. This method can be used as either a decorator or a method. @@ -854,7 +854,7 @@ def shortcut( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new shortcut listener. This method can be used as either a decorator or a method. @@ -900,7 +900,7 @@ def global_shortcut( callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new global shortcut listener.""" def __call__(*args, **kwargs): @@ -917,7 +917,7 @@ def message_shortcut( callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new message shortcut listener.""" def __call__(*args, **kwargs): @@ -937,7 +937,7 @@ def action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new action listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -976,7 +976,7 @@ def block_action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ @@ -995,7 +995,7 @@ def attachment_action( callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. Refer to https://api.slack.com/legacy/message-buttons for details.""" @@ -1013,7 +1013,7 @@ def dialog_submission( callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -1031,7 +1031,7 @@ def dialog_cancellation( callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -1052,7 +1052,7 @@ def view( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission`/`view_closed` event listener. This method can be used as either a decorator or a method. @@ -1102,7 +1102,7 @@ def view_submission( constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" @@ -1120,7 +1120,7 @@ def view_closed( constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" @@ -1141,7 +1141,7 @@ def options( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new options listener. This method can be used as either a decorator or a method. @@ -1191,7 +1191,7 @@ def block_suggestion( action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_suggestion` listener.""" def __call__(*args, **kwargs): @@ -1208,7 +1208,7 @@ def dialog_suggestion( callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. Refer to https://api.slack.com/dialogs for details.""" diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 718955c08..bbf4be64f 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -770,7 +770,7 @@ def event( ], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new event listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -811,7 +811,7 @@ def message( keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new message event listener. This method can be used as either a decorator or a method. Check the `App#event` method's docstring for details. @@ -861,7 +861,7 @@ def command( command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new slash command listener. This method can be used as either a decorator or a method. @@ -904,7 +904,7 @@ def shortcut( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new shortcut listener. This method can be used as either a decorator or a method. @@ -950,7 +950,7 @@ def global_shortcut( callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new global shortcut listener.""" def __call__(*args, **kwargs): @@ -967,7 +967,7 @@ def message_shortcut( callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new message shortcut listener.""" def __call__(*args, **kwargs): @@ -987,7 +987,7 @@ def action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new action listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -1026,7 +1026,7 @@ def block_action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_actions` action listener. Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ @@ -1045,7 +1045,7 @@ def attachment_action( callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `interactive_message` action listener. Refer to https://api.slack.com/legacy/message-buttons for details.""" @@ -1063,7 +1063,7 @@ def dialog_submission( callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -1081,7 +1081,7 @@ def dialog_cancellation( callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -1102,7 +1102,7 @@ def view( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission`/`view_closed` event listener. This method can be used as either a decorator or a method. @@ -1152,7 +1152,7 @@ def view_submission( constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" @@ -1170,7 +1170,7 @@ def view_closed( constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_closed` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" @@ -1191,7 +1191,7 @@ def options( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new options listener. This method can be used as either a decorator or a method. @@ -1241,7 +1241,7 @@ def block_suggestion( action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_suggestion` listener.""" def __call__(*args, **kwargs): @@ -1258,7 +1258,7 @@ def dialog_suggestion( callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_suggestion` listener. Refer to https://api.slack.com/dialogs for details.""" From 70398a7ca7572797f805952ab7b74c12d0d8cb88 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 7 Sep 2021 18:51:11 +0900 Subject: [PATCH 387/865] version 1.9.1 --- docs/api-docs/slack_bolt/app/app.html | 144 ++++++++++---------- docs/api-docs/slack_bolt/app/async_app.html | 136 +++++++++--------- docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 4 files changed, 142 insertions(+), 142 deletions(-) diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index 9db31df0c..e14ef09c2 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -712,7 +712,7 @@

    Module slack_bolt.app.app

    def error( self, func: Callable[..., Optional[BoltResponse]] - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[BoltResponse]]: """Updates the global error handler. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -750,7 +750,7 @@

    Module slack_bolt.app.app

    ], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new event listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -791,7 +791,7 @@

    Module slack_bolt.app.app

    keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new message event listener. This method can be used as either a decorator or a method. Check the `App#event` method's docstring for details. @@ -839,7 +839,7 @@

    Module slack_bolt.app.app

    command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new slash command listener. This method can be used as either a decorator or a method. @@ -882,7 +882,7 @@

    Module slack_bolt.app.app

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new shortcut listener. This method can be used as either a decorator or a method. @@ -928,7 +928,7 @@

    Module slack_bolt.app.app

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new global shortcut listener.""" def __call__(*args, **kwargs): @@ -945,7 +945,7 @@

    Module slack_bolt.app.app

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new message shortcut listener.""" def __call__(*args, **kwargs): @@ -965,7 +965,7 @@

    Module slack_bolt.app.app

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new action listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -1004,7 +1004,7 @@

    Module slack_bolt.app.app

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ @@ -1023,7 +1023,7 @@

    Module slack_bolt.app.app

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. Refer to https://api.slack.com/legacy/message-buttons for details.""" @@ -1041,7 +1041,7 @@

    Module slack_bolt.app.app

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -1059,7 +1059,7 @@

    Module slack_bolt.app.app

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -1080,7 +1080,7 @@

    Module slack_bolt.app.app

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission`/`view_closed` event listener. This method can be used as either a decorator or a method. @@ -1130,7 +1130,7 @@

    Module slack_bolt.app.app

    constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" @@ -1148,7 +1148,7 @@

    Module slack_bolt.app.app

    constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" @@ -1169,7 +1169,7 @@

    Module slack_bolt.app.app

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new options listener. This method can be used as either a decorator or a method. @@ -1219,7 +1219,7 @@

    Module slack_bolt.app.app

    action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_suggestion` listener.""" def __call__(*args, **kwargs): @@ -1236,7 +1236,7 @@

    Module slack_bolt.app.app

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -2163,7 +2163,7 @@

    Args

    def error( self, func: Callable[..., Optional[BoltResponse]] - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[BoltResponse]]: """Updates the global error handler. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -2201,7 +2201,7 @@

    Args

    ], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new event listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -2242,7 +2242,7 @@

    Args

    keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new message event listener. This method can be used as either a decorator or a method. Check the `App#event` method's docstring for details. @@ -2290,7 +2290,7 @@

    Args

    command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new slash command listener. This method can be used as either a decorator or a method. @@ -2333,7 +2333,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new shortcut listener. This method can be used as either a decorator or a method. @@ -2379,7 +2379,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new global shortcut listener.""" def __call__(*args, **kwargs): @@ -2396,7 +2396,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new message shortcut listener.""" def __call__(*args, **kwargs): @@ -2416,7 +2416,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new action listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -2455,7 +2455,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ @@ -2474,7 +2474,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. Refer to https://api.slack.com/legacy/message-buttons for details.""" @@ -2492,7 +2492,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -2510,7 +2510,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -2531,7 +2531,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission`/`view_closed` event listener. This method can be used as either a decorator or a method. @@ -2581,7 +2581,7 @@

    Args

    constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" @@ -2599,7 +2599,7 @@

    Args

    constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" @@ -2620,7 +2620,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new options listener. This method can be used as either a decorator or a method. @@ -2670,7 +2670,7 @@

    Args

    action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_suggestion` listener.""" def __call__(*args, **kwargs): @@ -2687,7 +2687,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -2890,7 +2890,7 @@

    Instance variables

    Methods

    -def action(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def action(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new action listener. This method can be used as either a decorator or a method.

    @@ -2928,7 +2928,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new action listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -2964,7 +2964,7 @@

    Args

    -def attachment_action(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def attachment_action(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new interactive_message action listener. @@ -2978,7 +2978,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. Refer to https://api.slack.com/legacy/message-buttons for details.""" @@ -2993,7 +2993,7 @@

    Args

    -def block_action(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def block_action(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new block_actions action listener. @@ -3007,7 +3007,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ @@ -3023,7 +3023,7 @@

    Args

    -def block_suggestion(self, action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def block_suggestion(self, action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new block_suggestion listener.

    @@ -3036,7 +3036,7 @@

    Args

    action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_suggestion` listener.""" def __call__(*args, **kwargs): @@ -3050,7 +3050,7 @@

    Args

    -def command(self, command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def command(self, command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new slash command listener. @@ -3087,7 +3087,7 @@

    Args

    command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new slash command listener. This method can be used as either a decorator or a method. @@ -3158,7 +3158,7 @@

    Args

    -def dialog_cancellation(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def dialog_cancellation(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new dialog_cancellation listener. @@ -3172,7 +3172,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -3187,7 +3187,7 @@

    Args

    -def dialog_submission(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def dialog_submission(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new dialog_submission listener. @@ -3201,7 +3201,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -3216,7 +3216,7 @@

    Args

    -def dialog_suggestion(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def dialog_suggestion(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new dialog_suggestion listener. @@ -3230,7 +3230,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -3388,7 +3388,7 @@

    Returns

    -def error(self, func: Callable[..., Optional[BoltResponse]]) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def error(self, func: Callable[..., Optional[BoltResponse]]) ‑> Callable[..., Optional[BoltResponse]]

    Updates the global error handler. This method can be used as either a decorator or a method.

    @@ -3414,7 +3414,7 @@

    Args

    def error(
         self, func: Callable[..., Optional[BoltResponse]]
    -) -> Optional[Callable[..., Optional[BoltResponse]]]:
    +) -> Callable[..., Optional[BoltResponse]]:
         """Updates the global error handler. This method can be used as either a decorator or a method.
     
             # Use this method as a decorator
    @@ -3444,7 +3444,7 @@ 

    Args

    -def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, ForwardRef(None)]]]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, ForwardRef(None)]]]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new event listener. This method can be used as either a decorator or a method.

    @@ -3484,7 +3484,7 @@

    Args

    ], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new event listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -3522,7 +3522,7 @@

    Args

    -def global_shortcut(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def global_shortcut(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new global shortcut listener.

    @@ -3535,7 +3535,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new global shortcut listener.""" def __call__(*args, **kwargs): @@ -3549,7 +3549,7 @@

    Args

    -def message(self, keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def message(self, keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new message event listener. This method can be used as either a decorator or a method. @@ -3585,7 +3585,7 @@

    Args

    keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new message event listener. This method can be used as either a decorator or a method. Check the `App#event` method's docstring for details. @@ -3627,7 +3627,7 @@

    Args

    -def message_shortcut(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def message_shortcut(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new message shortcut listener.

    @@ -3640,7 +3640,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new message shortcut listener.""" def __call__(*args, **kwargs): @@ -3716,7 +3716,7 @@

    Args

    -def options(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def options(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new options listener. @@ -3763,7 +3763,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new options listener. This method can be used as either a decorator or a method. @@ -3810,7 +3810,7 @@

    Args

    -def shortcut(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def shortcut(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new shortcut listener. @@ -3853,7 +3853,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new shortcut listener. This method can be used as either a decorator or a method. @@ -4063,7 +4063,7 @@

    Args

    -def view(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def view(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new view_submission/view_closed event listener. @@ -4110,7 +4110,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission`/`view_closed` event listener. This method can be used as either a decorator or a method. @@ -4157,7 +4157,7 @@

    Args

    -def view_closed(self, constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def view_closed(self, constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new view_closed listener. @@ -4171,7 +4171,7 @@

    Args

    constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" @@ -4186,7 +4186,7 @@

    Args

    -def view_submission(self, constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Optional[Callable[..., Optional[BoltResponse]]] +def view_submission(self, constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new view_submission listener. @@ -4200,7 +4200,7 @@

    Args

    constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, -) -> Optional[Callable[..., Optional[BoltResponse]]]: +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index d6424b071..20653dbba 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -798,7 +798,7 @@

    Module slack_bolt.app.async_app

    ], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new event listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -839,7 +839,7 @@

    Module slack_bolt.app.async_app

    keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new message event listener. This method can be used as either a decorator or a method. Check the `App#event` method's docstring for details. @@ -889,7 +889,7 @@

    Module slack_bolt.app.async_app

    command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new slash command listener. This method can be used as either a decorator or a method. @@ -932,7 +932,7 @@

    Module slack_bolt.app.async_app

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new shortcut listener. This method can be used as either a decorator or a method. @@ -978,7 +978,7 @@

    Module slack_bolt.app.async_app

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new global shortcut listener.""" def __call__(*args, **kwargs): @@ -995,7 +995,7 @@

    Module slack_bolt.app.async_app

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new message shortcut listener.""" def __call__(*args, **kwargs): @@ -1015,7 +1015,7 @@

    Module slack_bolt.app.async_app

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new action listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -1054,7 +1054,7 @@

    Module slack_bolt.app.async_app

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_actions` action listener. Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ @@ -1073,7 +1073,7 @@

    Module slack_bolt.app.async_app

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `interactive_message` action listener. Refer to https://api.slack.com/legacy/message-buttons for details.""" @@ -1091,7 +1091,7 @@

    Module slack_bolt.app.async_app

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -1109,7 +1109,7 @@

    Module slack_bolt.app.async_app

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -1130,7 +1130,7 @@

    Module slack_bolt.app.async_app

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission`/`view_closed` event listener. This method can be used as either a decorator or a method. @@ -1180,7 +1180,7 @@

    Module slack_bolt.app.async_app

    constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" @@ -1198,7 +1198,7 @@

    Module slack_bolt.app.async_app

    constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_closed` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" @@ -1219,7 +1219,7 @@

    Module slack_bolt.app.async_app

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new options listener. This method can be used as either a decorator or a method. @@ -1269,7 +1269,7 @@

    Module slack_bolt.app.async_app

    action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_suggestion` listener.""" def __call__(*args, **kwargs): @@ -1286,7 +1286,7 @@

    Module slack_bolt.app.async_app

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_suggestion` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -2175,7 +2175,7 @@

    Args

    ], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new event listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -2216,7 +2216,7 @@

    Args

    keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new message event listener. This method can be used as either a decorator or a method. Check the `App#event` method's docstring for details. @@ -2266,7 +2266,7 @@

    Args

    command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new slash command listener. This method can be used as either a decorator or a method. @@ -2309,7 +2309,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new shortcut listener. This method can be used as either a decorator or a method. @@ -2355,7 +2355,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new global shortcut listener.""" def __call__(*args, **kwargs): @@ -2372,7 +2372,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new message shortcut listener.""" def __call__(*args, **kwargs): @@ -2392,7 +2392,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new action listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -2431,7 +2431,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_actions` action listener. Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ @@ -2450,7 +2450,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `interactive_message` action listener. Refer to https://api.slack.com/legacy/message-buttons for details.""" @@ -2468,7 +2468,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -2486,7 +2486,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -2507,7 +2507,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission`/`view_closed` event listener. This method can be used as either a decorator or a method. @@ -2557,7 +2557,7 @@

    Args

    constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" @@ -2575,7 +2575,7 @@

    Args

    constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_closed` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" @@ -2596,7 +2596,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new options listener. This method can be used as either a decorator or a method. @@ -2646,7 +2646,7 @@

    Args

    action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_suggestion` listener.""" def __call__(*args, **kwargs): @@ -2663,7 +2663,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_suggestion` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -2884,7 +2884,7 @@

    Instance variables

    Methods

    -def action(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def action(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new action listener. This method can be used as either a decorator or a method.

    @@ -2922,7 +2922,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new action listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -3089,7 +3089,7 @@

    Returns

    -def attachment_action(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def attachment_action(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new interactive_message action listener. @@ -3103,7 +3103,7 @@

    Returns

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `interactive_message` action listener. Refer to https://api.slack.com/legacy/message-buttons for details.""" @@ -3118,7 +3118,7 @@

    Returns

    -def block_action(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def block_action(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new block_actions action listener. @@ -3132,7 +3132,7 @@

    Returns

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_actions` action listener. Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ @@ -3148,7 +3148,7 @@

    Returns

    -def block_suggestion(self, action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def block_suggestion(self, action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new block_suggestion listener.

    @@ -3161,7 +3161,7 @@

    Returns

    action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_suggestion` listener.""" def __call__(*args, **kwargs): @@ -3175,7 +3175,7 @@

    Returns

    -def command(self, command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def command(self, command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new slash command listener. @@ -3212,7 +3212,7 @@

    Args

    command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new slash command listener. This method can be used as either a decorator or a method. @@ -3283,7 +3283,7 @@

    Args

    -def dialog_cancellation(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def dialog_cancellation(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new dialog_submission listener. @@ -3297,7 +3297,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -3312,7 +3312,7 @@

    Args

    -def dialog_submission(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def dialog_submission(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new dialog_submission listener. @@ -3326,7 +3326,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -3341,7 +3341,7 @@

    Args

    -def dialog_suggestion(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def dialog_suggestion(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new dialog_suggestion listener. @@ -3355,7 +3355,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_suggestion` listener. Refer to https://api.slack.com/dialogs for details.""" @@ -3445,7 +3445,7 @@

    Args

    -def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, ForwardRef(None)]]]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, ForwardRef(None)]]]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new event listener. This method can be used as either a decorator or a method.

    @@ -3485,7 +3485,7 @@

    Args

    ], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new event listener. This method can be used as either a decorator or a method. # Use this method as a decorator @@ -3523,7 +3523,7 @@

    Args

    -def global_shortcut(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def global_shortcut(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new global shortcut listener.

    @@ -3536,7 +3536,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new global shortcut listener.""" def __call__(*args, **kwargs): @@ -3550,7 +3550,7 @@

    Args

    -def message(self, keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def message(self, keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new message event listener. This method can be used as either a decorator or a method. @@ -3586,7 +3586,7 @@

    Args

    keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new message event listener. This method can be used as either a decorator or a method. Check the `App#event` method's docstring for details. @@ -3630,7 +3630,7 @@

    Args

    -def message_shortcut(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def message_shortcut(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new message shortcut listener.

    @@ -3643,7 +3643,7 @@

    Args

    callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new message shortcut listener.""" def __call__(*args, **kwargs): @@ -3718,7 +3718,7 @@

    Args

    -def options(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def options(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new options listener. @@ -3765,7 +3765,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new options listener. This method can be used as either a decorator or a method. @@ -3851,7 +3851,7 @@

    Args

    -def shortcut(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def shortcut(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new shortcut listener. @@ -3894,7 +3894,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new shortcut listener. This method can be used as either a decorator or a method. @@ -4080,7 +4080,7 @@

    Args

    -def view(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def view(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new view_submission/view_closed event listener. @@ -4127,7 +4127,7 @@

    Args

    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission`/`view_closed` event listener. This method can be used as either a decorator or a method. @@ -4174,7 +4174,7 @@

    Args

    -def view_closed(self, constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def view_closed(self, constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new view_closed listener. @@ -4188,7 +4188,7 @@

    Args

    constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_closed` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" @@ -4203,7 +4203,7 @@

    Args

    -def view_submission(self, constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]] +def view_submission(self, constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new view_submission listener. @@ -4217,7 +4217,7 @@

    Args

    constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, -) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index a6b6553e4..aaf3c299b 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.9.0"
    +__version__ = "1.9.1"

    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 2e6e949f5..021e7cfaf 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.9.0" +__version__ = "1.9.1" From 66141d80dd69449c224a23c63ad518b2d52904fa Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 21 Sep 2021 10:25:31 +0900 Subject: [PATCH 388/865] Fix erros in Japanese documents --- docs/_basic/ja_listening_messages.md | 2 +- docs/_basic/ja_responding_actions.md | 2 +- docs/_basic/ja_socket_mode.md | 8 ++++---- docs/_steps/ja_adding_editing_workflow_step.md | 2 +- docs/_steps/ja_saving_workflow_step.md | 2 +- docs/_steps/ja_workflow_steps_overview.md | 2 +- scripts/run_jekyll.sh | 8 ++++++++ 7 files changed, 17 insertions(+), 9 deletions(-) create mode 100755 scripts/run_jekyll.sh diff --git a/docs/_basic/ja_listening_messages.md b/docs/_basic/ja_listening_messages.md index 12df49493..f27fbec9c 100644 --- a/docs/_basic/ja_listening_messages.md +++ b/docs/_basic/ja_listening_messages.md @@ -25,7 +25,7 @@ def say_hello(message, say):
    -

    正規表現パターンの使用

    +

    正規表現パターンの利用

    diff --git a/docs/_basic/ja_responding_actions.md b/docs/_basic/ja_responding_actions.md index fb6a50033..c1cb5f357 100644 --- a/docs/_basic/ja_responding_actions.md +++ b/docs/_basic/ja_responding_actions.md @@ -26,7 +26,7 @@ def approve_request(ack, say):
    -

    respond() の使用

    +

    respond() の利用

    diff --git a/docs/_basic/ja_socket_mode.md b/docs/_basic/ja_socket_mode.md index 7e202add0..2a027882e 100644 --- a/docs/_basic/ja_socket_mode.md +++ b/docs/_basic/ja_socket_mode.md @@ -1,5 +1,5 @@ --- -title: ソケットモードの使用 +title: ソケットモードの利用 lang: ja-jp slug: socket-mode order: 16 @@ -8,13 +8,13 @@ order: 16
    [ソケットモード](https://api.slack.com/apis/connections/socket)は、アプリに WebSocket での接続と、そのコネクション経由でのデータ受信を可能とします。Bolt for Python は、バージョン 1.2.0 からこれに対応しています。 -ソケットモードでは、Slack からのペイロード送信を受け付けるエンドポイントをホストする HTTP サーバーを起動する代わりに WebSocket で Slack に接続し、そのコネクション経由でデータを受信します。ソケットモードを使う前に、アプリの管理画面でソケットモードの機能が有効になっているコオを確認しておいてください。 +ソケットモードでは、Slack からのペイロード送信を受け付けるエンドポイントをホストする HTTP サーバーを起動する代わりに WebSocket で Slack に接続し、そのコネクション経由でデータを受信します。ソケットモードを使う前に、アプリの管理画面でソケットモードの機能が有効になっていることを確認しておいてください。 ソケットモードを使用するには、環境変数に `SLACK_APP_TOKEN` を追加します。アプリのトークン(App-Level Token)は、アプリの設定の「**Basic Information**」セクションで確認できます。 [組み込みのソケットモードアダプター](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/builtin)を使用するのがおすすめですが、サードパーティ製ライブラリを使ったアダプターの実装もいくつか存在しています。利用可能なアダプターの一覧です。 -|PyPI プロジェクト|Bolt アダプター| +|内部的に利用する PyPI プロジェクト名|Bolt アダプター| |-|-| |[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)| @@ -48,7 +48,7 @@ if __name__ == "__main__":
    aiohttp のような asyncio をベースとしたアダプターを使う場合、アプリケーション全体が asyncio の async/await プログラミングモデルで実装されている必要があります。`AsyncApp` を動作させるためには `AsyncSocketModeHandler` とその async なミドルウェアやリスナーを利用します。 -`AsyncApp` の使い方についての詳細は、[Usin async (asyncio)](https://slack.dev/bolt-python/concepts#async)トや、関連する[サンプルコード例](https://github.com/slackapi/bolt-python/tree/main/examples)を参考にしてください。 +`AsyncApp` の使い方についての詳細は、[Async (asyncio) の利用](https://slack.dev/bolt-python/ja-jp/concepts#async)や、関連する[サンプルコード例](https://github.com/slackapi/bolt-python/tree/main/examples)を参考にしてください。
    ```python diff --git a/docs/_steps/ja_adding_editing_workflow_step.md b/docs/_steps/ja_adding_editing_workflow_step.md index 7f3c9d44e..fed6d9740 100644 --- a/docs/_steps/ja_adding_editing_workflow_step.md +++ b/docs/_steps/ja_adding_editing_workflow_step.md @@ -13,7 +13,7 @@ order: 3 `edit` コールバック内で `configure()` ユーティリティを使用すると、対応する `blocks` 引数にビューのblocks 部分だけを渡して、ステップの設定モーダルを簡単に表示させることができます。必要な入力内容が揃うまで設定の保存を無効にするには、`True` の値をセットした `submit_disabled` を渡します。 -設定モーダルの開き方について詳しくは、[ドキュメントを参照してください](https://api.slack.com/workflows/steps#handle_config_view)。 +設定モーダルの開き方に関する詳細は、[こちらのドキュメント](https://api.slack.com/workflows/steps#handle_config_view)を参照してください。
    diff --git a/docs/_steps/ja_saving_workflow_step.md b/docs/_steps/ja_saving_workflow_step.md index b6f3f3076..c95f45efb 100644 --- a/docs/_steps/ja_saving_workflow_step.md +++ b/docs/_steps/ja_saving_workflow_step.md @@ -16,7 +16,7 @@ order: 4 - `step_name` : ステップのデフォルトの名前をオーバーライドします。 - `step_image_url` : ステップのデフォルトの画像をオーバーライドします。 -これらのパラメータの構成方法について詳しくは、[ドキュメントを参照してください](https://api.slack.com/reference/workflows/workflow_step)。 +これらのパラメータの構成方法に関する詳細は、[こちらのドキュメント](https://api.slack.com/reference/workflows/workflow_step)を参照してください。
    diff --git a/docs/_steps/ja_workflow_steps_overview.md b/docs/_steps/ja_workflow_steps_overview.md index 6905d82fa..4787948ee 100644 --- a/docs/_steps/ja_workflow_steps_overview.md +++ b/docs/_steps/ja_workflow_steps_overview.md @@ -16,6 +16,6 @@ order: 1 ワークフローステップを機能させるためには、これら 3 つのイベントすべてに対応する必要があります。 -アプリを使ったワークフローステップについて詳しくは、[API ドキュメント](https://api.slack.com/workflows/steps)を参照してください。 +アプリを使ったワークフローステップに関する詳細は、[API ドキュメント](https://api.slack.com/workflows/steps)を参照してください。
    diff --git a/scripts/run_jekyll.sh b/scripts/run_jekyll.sh new file mode 100755 index 000000000..34dbecf72 --- /dev/null +++ b/scripts/run_jekyll.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +script_dir=`dirname $0` +cd ${script_dir}/../docs +gem install bundler +bundle install +rm -rf _site/ +bundle exec jekyll serve -It From ba2c7e4ba2f79652060153d50e7bdc926e92fb7a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 21 Sep 2021 15:17:31 +0900 Subject: [PATCH 389/865] Add more .venv directory patterns to .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f4aaf5133..93b86de53 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ __pycache__/ # virtualenv env*/ venv/ -.venv/ +.venv* .env/ # codecov / coverage From 7173ef76d2f7ff9e20ec194d941bb8a2d8dedb17 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 21 Sep 2021 16:27:20 +0900 Subject: [PATCH 390/865] Add type: ignore for pytype 2021.9.9 --- slack_bolt/oauth/async_oauth_settings.py | 18 ++++++++++++++---- slack_bolt/oauth/oauth_settings.py | 17 +++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index dc6f112c5..89abbf462 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -118,14 +118,24 @@ def __init__( self.client_id = client_id self.client_secret = client_secret - self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + # NOTE: pytype says that self.scopes can be str, not Sequence[str]. + # That's true but we will check the pattern in the following if statement. + # Thus, we ignore the warnings here. This is the same for user_scopes too. + self.scopes = ( # type: ignore + scopes # type: ignore + if scopes is not None + else os.environ.get("SLACK_SCOPES", "").split(",") # type: ignore + ) # type: ignore if isinstance(self.scopes, str): self.scopes = self.scopes.split(",") - self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( - "," - ) + self.user_scopes = ( # type: ignore + user_scopes + if user_scopes is not None + else os.environ.get("SLACK_USER_SCOPES", "").split(",") # type: ignore + ) # type: ignore if isinstance(self.user_scopes, str): self.user_scopes = self.user_scopes.split(",") + self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") # Handler configuration self.install_path = install_path or os.environ.get( diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index 88ac04567..b6bb2b92a 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -112,12 +112,21 @@ def __init__( self.client_id = client_id self.client_secret = client_secret - self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + # NOTE: pytype says that self.scopes can be str, not Sequence[str]. + # That's true but we will check the pattern in the following if statement. + # Thus, we ignore the warnings here. This is the same for user_scopes too. + self.scopes = ( # type: ignore + scopes # type: ignore + if scopes is not None + else os.environ.get("SLACK_SCOPES", "").split(",") # type: ignore + ) # type: ignore if isinstance(self.scopes, str): self.scopes = self.scopes.split(",") - self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( - "," - ) + self.user_scopes = ( # type: ignore + user_scopes + if user_scopes is not None + else os.environ.get("SLACK_USER_SCOPES", "").split(",") # type: ignore + ) # type: ignore if isinstance(self.user_scopes, str): self.user_scopes = self.user_scopes.split(",") self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") From da7da61b0c56719599a376e38911a1775b2a5a75 Mon Sep 17 00:00:00 2001 From: Dan Bode Date: Thu, 23 Sep 2021 15:17:29 -0700 Subject: [PATCH 391/865] update block id from listening modals (#472) current, the serial order in which a user reads the documentation sets the opening_modals example as a pre-requisite that the listening_modals builds upon. Currently, an attempt to walk through the examples in order yeilds a python `keyError: 'block_c'` This is because block_c does not match the name of the previous modal examle `input_c`. This patch updates the variable to be the same so that users can step through the documentation without the above exception. --- docs/_basic/listening_modals.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md index db9dc8a2c..8a429acb2 100644 --- a/docs/_basic/listening_modals.md +++ b/docs/_basic/listening_modals.md @@ -21,13 +21,13 @@ Read more about view submissions in our 0: ack(response_action="errors", errors=errors) return From 4e0709f0578080833f9aeab984a778be81a30178 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 24 Sep 2021 07:17:35 +0900 Subject: [PATCH 392/865] Apply #472 changes to Japanese documents --- docs/_basic/ja_listening_modals.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/_basic/ja_listening_modals.md b/docs/_basic/ja_listening_modals.md index d169faeb5..27362a32f 100644 --- a/docs/_basic/ja_listening_modals.md +++ b/docs/_basic/ja_listening_modals.md @@ -21,13 +21,13 @@ order: 12 # view_submission リクエストを処理 @app.view("view_1") def handle_submission(ack, body, client, view, logger): - # `block_c`という block_id に `dreamy_input` を持つ input ブロックがある場合 - hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"] + # `input_c`という block_id に `dreamy_input` を持つ input ブロックがある場合 + hopes_and_dreams = view["state"]["values"]["input_c"]["dreamy_input"] user = body["user"]["id"] # 入力値を検証 errors = {} if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5: - errors["block_c"] = "The value must be longer than 5 characters" + errors["input_c"] = "The value must be longer than 5 characters" if len(errors) > 0: ack(response_action="errors", errors=errors) return @@ -52,4 +52,4 @@ def handle_submission(ack, body, client, view, logger): logger.exception(f"Failed to post a message {e}") ``` - \ No newline at end of file + From 977875ed7e8a485b1c985202ee0e8357d5ce087a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Sep 2021 06:16:48 +0900 Subject: [PATCH 393/865] Improve the error message in the case where AuthorizeResult is not found (#476) --- slack_bolt/authorization/async_authorize.py | 12 ++++++++++++ slack_bolt/authorization/authorize.py | 13 ++++++++++++- .../async_multi_teams_authorization.py | 9 +++++++-- .../authorization/multi_teams_authorization.py | 9 +++++++-- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index 09db629de..d7cc19a50 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -16,6 +16,9 @@ class AsyncAuthorize: + """This provides authorize function that returns AuthorizeResult + for an incoming request from Slack.""" + def __init__(self): pass @@ -31,6 +34,10 @@ async def __call__( class AsyncCallableAuthorize(AsyncAuthorize): + """When you pass the authorize argument in AsyncApp constructor, + This authorize implementation will be used. + """ + def __init__( self, *, logger: Logger, func: Callable[..., Awaitable[AuthorizeResult]] ): @@ -93,6 +100,11 @@ async def __call__( class AsyncInstallationStoreAuthorize(AsyncAuthorize): + """If you use the OAuth flow settings, this authorize implementation will be used. + As long as your own InstallationStore (or the built-in ones) works as you expect, + you can expect that the authorize layer should work for you without any customization. + """ + authorize_result_cache: Dict[str, AuthorizeResult] find_installation_available: Optional[bool] find_bot_available: Optional[bool] diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 43646b6b3..aaa4d2ed4 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -1,5 +1,4 @@ import inspect -import os from logging import Logger from typing import Optional, Callable, Dict, Any @@ -16,6 +15,9 @@ class Authorize: + """This provides authorize function that returns AuthorizeResult + for an incoming request from Slack.""" + def __init__(self): pass @@ -31,6 +33,10 @@ def __call__( class CallableAuthorize(Authorize): + """When you pass the authorize argument in AsyncApp constructor, + This authorize implementation will be used. + """ + def __init__( self, *, @@ -96,6 +102,11 @@ def __call__( class InstallationStoreAuthorize(Authorize): + """If you use the OAuth flow settings, this authorize implementation will be used. + As long as your own InstallationStore (or the built-in ones) works as you expect, + you can expect that the authorize layer should work for you without any customization. + """ + authorize_result_cache: Dict[str, AuthorizeResult] bot_only: bool find_installation_available: bool diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index 3d6f2e16b..85c56cc33 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -62,8 +62,13 @@ async def async_process( req.context.client.token = token return await next() else: - # Just in case - self.logger.error("auth.test API call result is unexpectedly None") + # This situation can arise if: + # * A developer installed the app from the "Install to Workspace" button in Slack app config page + # * The InstallationStore failed to save or deleted the installation for this workspace + self.logger.error( + "Although the app should be installed into this workspace, " + "the AuthorizeResult (returned value from authorize) for it was not found." + ) return _build_error_response() except SlackApiError as e: diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index 9f03e1cb7..7aeacf259 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -67,8 +67,13 @@ def process( req.context.client.token = token return next() else: - # Just in case - self.logger.error("auth.test API call result is unexpectedly None") + # This situation can arise if: + # * A developer installed the app from the "Install to Workspace" button in Slack app config page + # * The InstallationStore failed to save or deleted the installation for this workspace + self.logger.error( + "Although the app should be installed into this workspace, " + "the AuthorizeResult (returned value from authorize) for it was not found." + ) return _build_error_response() except SlackApiError as e: From 7daf5c8743f7eebfe4df1762e725334d025d3d9d Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Mon, 27 Sep 2021 14:20:31 -0700 Subject: [PATCH 394/865] Adds update view on submission docs (#479) --- docs/_basic/listening_modals.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md index 8a429acb2..a0146c453 100644 --- a/docs/_basic/listening_modals.md +++ b/docs/_basic/listening_modals.md @@ -11,6 +11,19 @@ If a view payload You can access the value of the `input` blocks by accessing the `state` object. `state` contains a `values` object that uses the `block_id` and unique `action_id` to store the input values. +--- + +##### Update views on submission + +To update a view in response to a `view_submission` event, you may pass a `response_action` of type `update` with a newly composed `view` to display in your acknowledgement. + +```python +# Update the view on submission +@app.view("view_1") +def handle_submission(ack, body): + ack(response_action="update", view=build_new_view(body)) +``` +Similarly, there are options for [displaying errors](https://api.slack.com/surfaces/modals/using#displaying_errors) in response to view submissions. Read more about view submissions in our API documentation. From 24a94b1720eedf613c76fc0917c846d1738cbab6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Sep 2021 06:39:45 +0900 Subject: [PATCH 395/865] Add more guide message in the HTML generated by the default failure handler (#477) * Add more guide message in the HTML generated by the default failure handler * Add more tests * Apply suggestions from code review Co-authored-by: Fil Maj Co-authored-by: Alissa Renz --- slack_bolt/oauth/internals.py | 22 ++++++++++++++++- tests/slack_bolt/oauth/test_internals.py | 31 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/slack_bolt/oauth/test_internals.py diff --git a/slack_bolt/oauth/internals.py b/slack_bolt/oauth/internals.py index 87f4e167b..9fedc6e8f 100644 --- a/slack_bolt/oauth/internals.py +++ b/slack_bolt/oauth/internals.py @@ -60,7 +60,10 @@ def _build_callback_failure_response( # type: ignore ) self._logger.debug(debug_message) - html = self._redirect_uri_page_renderer.render_failure_page(reason) + # Adding a bit more details to the error code to help installers understand what's happening. + # This modification in the HTML page works only when developers use this built-in failure handler. + detailed_error = build_detailed_error(reason) + html = self._redirect_uri_page_renderer.render_failure_page(detailed_error) return BoltResponse( status=status, headers={ @@ -126,3 +129,20 @@ def select_consistent_installation_store( else: # only oauth_flow_store is available return oauth_flow_store + + +def build_detailed_error(reason: str) -> str: + if reason == "invalid_browser": + return ( + f"{reason}: This can occur due to page reload, " + "not beginning the OAuth flow from the valid starting URL, or " + "the /slack/install URL not using https://" + ) + elif reason == "invalid_state": + return f"{reason}: The state parameter is no longer valid." + elif reason == "missing_code": + return f"{reason}: The code parameter is missing in this redirection." + elif reason == "storage_error": + return f"{reason}: The app's server encountered an issue. Contact the app developer." + else: + return f"{reason}: This error code is returned from Slack. Refer to the documents for details." diff --git a/tests/slack_bolt/oauth/test_internals.py b/tests/slack_bolt/oauth/test_internals.py new file mode 100644 index 000000000..16a0d133c --- /dev/null +++ b/tests/slack_bolt/oauth/test_internals.py @@ -0,0 +1,31 @@ +from slack_bolt.oauth.internals import build_detailed_error + + +class TestOAuthInternals: + def test_build_detailed_error_invalid_browser(self): + result = build_detailed_error("invalid_browser") + assert result.startswith("invalid_browser: This can occur due to page reload, ") + + def test_build_detailed_error_invalid_state(self): + result = build_detailed_error("invalid_state") + assert result.startswith( + "invalid_state: The state parameter is no longer valid." + ) + + def test_build_detailed_error_missing_code(self): + result = build_detailed_error("missing_code") + assert result.startswith( + "missing_code: The code parameter is missing in this redirection." + ) + + def test_build_detailed_error_storage_error(self): + result = build_detailed_error("storage_error") + assert result.startswith( + "storage_error: The app's server encountered an issue. Contact the app developer." + ) + + def test_build_detailed_error_others(self): + result = build_detailed_error("access_denied") + assert result.startswith( + "access_denied: This error code is returned from Slack. Refer to the documents for details." + ) From 302fe3583819cf6efcfa40219be4ddd0c7fdaa94 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Sep 2021 15:17:40 +0900 Subject: [PATCH 396/865] Upgrade black code formatter --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index fd8a09e2a..8d5e65851 100755 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ "aiohttp>=3,<4", # for async "Flask-Sockets>=0.2,<1", "Werkzeug<2", # TODO: support Flask 2.x - "black==21.7b0", + "black==21.9b0", ] setuptools.setup( From 4c8b33fc3d61a9aa909638a28fe89dd0c942296c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 28 Sep 2021 15:18:29 +0900 Subject: [PATCH 397/865] version 1.9.2 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 021e7cfaf..8800b5e8c 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.9.1" +__version__ = "1.9.2" From 7b692e89f150316dcb0bba76fa74531a4294493a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 29 Sep 2021 14:41:00 +0900 Subject: [PATCH 398/865] Update API documents --- .../authorization/async_authorize.html | 34 ++++++++++- .../slack_bolt/authorization/authorize.html | 35 ++++++++++-- .../async_multi_teams_authorization.html | 18 ++++-- .../multi_teams_authorization.html | 18 ++++-- .../oauth/async_oauth_settings.html | 36 +++++++++--- docs/api-docs/slack_bolt/oauth/internals.html | 56 ++++++++++++++++++- .../slack_bolt/oauth/oauth_settings.html | 34 ++++++++--- docs/api-docs/slack_bolt/version.html | 2 +- 8 files changed, 198 insertions(+), 35 deletions(-) diff --git a/docs/api-docs/slack_bolt/authorization/async_authorize.html b/docs/api-docs/slack_bolt/authorization/async_authorize.html index 1f5c32de8..1f9413b83 100644 --- a/docs/api-docs/slack_bolt/authorization/async_authorize.html +++ b/docs/api-docs/slack_bolt/authorization/async_authorize.html @@ -44,6 +44,9 @@

    Module slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeClasses

    class AsyncAuthorize
    -
    +

    This provides authorize function that returns AuthorizeResult +for an incoming request from Slack.

    Expand source code
    class AsyncAuthorize:
    +    """This provides authorize function that returns AuthorizeResult
    +    for an incoming request from Slack."""
    +
         def __init__(self):
             pass
     
    @@ -355,12 +371,17 @@ 

    Subclasses

    (*, logger: logging.Logger, func: Callable[..., Awaitable[AuthorizeResult]])
    -
    +

    When you pass the authorize argument in AsyncApp constructor, +This authorize implementation will be used.

    Expand source code
    class AsyncCallableAuthorize(AsyncAuthorize):
    +    """When you pass the authorize argument in AsyncApp constructor,
    +    This authorize implementation will be used.
    +    """
    +
         def __init__(
             self, *, logger: Logger, func: Callable[..., Awaitable[AuthorizeResult]]
         ):
    @@ -431,12 +452,19 @@ 

    Ancestors

    (*, logger: logging.Logger, installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore, client_id: Optional[str] = None, client_secret: Optional[str] = None, token_rotation_expiration_minutes: Optional[int] = None, bot_only: bool = False, cache_enabled: bool = False)
    -
    +

    If you use the OAuth flow settings, this authorize implementation will be used. +As long as your own InstallationStore (or the built-in ones) works as you expect, +you can expect that the authorize layer should work for you without any customization.

    Expand source code
    class AsyncInstallationStoreAuthorize(AsyncAuthorize):
    +    """If you use the OAuth flow settings, this authorize implementation will be used.
    +    As long as your own InstallationStore (or the built-in ones) works as you expect,
    +    you can expect that the authorize layer should work for you without any customization.
    +    """
    +
         authorize_result_cache: Dict[str, AuthorizeResult]
         find_installation_available: Optional[bool]
         find_bot_available: Optional[bool]
    diff --git a/docs/api-docs/slack_bolt/authorization/authorize.html b/docs/api-docs/slack_bolt/authorization/authorize.html
    index e24b8c04a..197913bce 100644
    --- a/docs/api-docs/slack_bolt/authorization/authorize.html
    +++ b/docs/api-docs/slack_bolt/authorization/authorize.html
    @@ -27,7 +27,6 @@ 

    Module slack_bolt.authorization.authorize

    Expand source code
    import inspect
    -import os
     from logging import Logger
     from typing import Optional, Callable, Dict, Any
     
    @@ -44,6 +43,9 @@ 

    Module slack_bolt.authorization.authorize

    class Authorize: + """This provides authorize function that returns AuthorizeResult + for an incoming request from Slack.""" + def __init__(self): pass @@ -59,6 +61,10 @@

    Module slack_bolt.authorization.authorize

    class CallableAuthorize(Authorize): + """When you pass the authorize argument in AsyncApp constructor, + This authorize implementation will be used. + """ + def __init__( self, *, @@ -124,6 +130,11 @@

    Module slack_bolt.authorization.authorize

    class InstallationStoreAuthorize(Authorize): + """If you use the OAuth flow settings, this authorize implementation will be used. + As long as your own InstallationStore (or the built-in ones) works as you expect, + you can expect that the authorize layer should work for you without any customization. + """ + authorize_result_cache: Dict[str, AuthorizeResult] bot_only: bool find_installation_available: bool @@ -322,12 +333,16 @@

    Classes

    class Authorize
    -
    +

    This provides authorize function that returns AuthorizeResult +for an incoming request from Slack.

    Expand source code
    class Authorize:
    +    """This provides authorize function that returns AuthorizeResult
    +    for an incoming request from Slack."""
    +
         def __init__(self):
             pass
     
    @@ -352,12 +367,17 @@ 

    Subclasses

    (*, logger: logging.Logger, func: Callable[..., AuthorizeResult])
    -
    +

    When you pass the authorize argument in AsyncApp constructor, +This authorize implementation will be used.

    Expand source code
    class CallableAuthorize(Authorize):
    +    """When you pass the authorize argument in AsyncApp constructor,
    +    This authorize implementation will be used.
    +    """
    +
         def __init__(
             self,
             *,
    @@ -431,12 +451,19 @@ 

    Ancestors

    (*, logger: logging.Logger, installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore, client_id: Optional[str] = None, client_secret: Optional[str] = None, token_rotation_expiration_minutes: Optional[int] = None, bot_only: bool = False, cache_enabled: bool = False)
    -
    +

    If you use the OAuth flow settings, this authorize implementation will be used. +As long as your own InstallationStore (or the built-in ones) works as you expect, +you can expect that the authorize layer should work for you without any customization.

    Expand source code
    class InstallationStoreAuthorize(Authorize):
    +    """If you use the OAuth flow settings, this authorize implementation will be used.
    +    As long as your own InstallationStore (or the built-in ones) works as you expect,
    +    you can expect that the authorize layer should work for you without any customization.
    +    """
    +
         authorize_result_cache: Dict[str, AuthorizeResult]
         bot_only: bool
         find_installation_available: bool
    diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html
    index 19c95ca30..5c6528140 100644
    --- a/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html
    +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html
    @@ -90,8 +90,13 @@ 

    Module slack_bolt.middleware.authorization.async_multi_t req.context.client.token = token return await next() else: - # Just in case - self.logger.error("auth.test API call result is unexpectedly None") + # This situation can arise if: + # * A developer installed the app from the "Install to Workspace" button in Slack app config page + # * The InstallationStore failed to save or deleted the installation for this workspace + self.logger.error( + "Although the app should be installed into this workspace, " + "the AuthorizeResult (returned value from authorize) for it was not found." + ) return _build_error_response() except SlackApiError as e: @@ -175,8 +180,13 @@

    Args

    req.context.client.token = token return await next() else: - # Just in case - self.logger.error("auth.test API call result is unexpectedly None") + # This situation can arise if: + # * A developer installed the app from the "Install to Workspace" button in Slack app config page + # * The InstallationStore failed to save or deleted the installation for this workspace + self.logger.error( + "Although the app should be installed into this workspace, " + "the AuthorizeResult (returned value from authorize) for it was not found." + ) return _build_error_response() except SlackApiError as e: diff --git a/docs/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html index 1a208e9a1..31d6c1621 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html @@ -95,8 +95,13 @@

    Module slack_bolt.middleware.authorization.multi_teams_a req.context.client.token = token return next() else: - # Just in case - self.logger.error("auth.test API call result is unexpectedly None") + # This situation can arise if: + # * A developer installed the app from the "Install to Workspace" button in Slack app config page + # * The InstallationStore failed to save or deleted the installation for this workspace + self.logger.error( + "Although the app should be installed into this workspace, " + "the AuthorizeResult (returned value from authorize) for it was not found." + ) return _build_error_response() except SlackApiError as e: @@ -181,8 +186,13 @@

    Args

    req.context.client.token = token return next() else: - # Just in case - self.logger.error("auth.test API call result is unexpectedly None") + # This situation can arise if: + # * A developer installed the app from the "Install to Workspace" button in Slack app config page + # * The InstallationStore failed to save or deleted the installation for this workspace + self.logger.error( + "Although the app should be installed into this workspace, " + "the AuthorizeResult (returned value from authorize) for it was not found." + ) return _build_error_response() except SlackApiError as e: diff --git a/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html b/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html index 33a984dcb..998743ae3 100644 --- a/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html +++ b/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html @@ -146,14 +146,24 @@

    Module slack_bolt.oauth.async_oauth_settings

    self.client_id = client_id self.client_secret = client_secret - self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + # NOTE: pytype says that self.scopes can be str, not Sequence[str]. + # That's true but we will check the pattern in the following if statement. + # Thus, we ignore the warnings here. This is the same for user_scopes too. + self.scopes = ( # type: ignore + scopes # type: ignore + if scopes is not None + else os.environ.get("SLACK_SCOPES", "").split(",") # type: ignore + ) # type: ignore if isinstance(self.scopes, str): self.scopes = self.scopes.split(",") - self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( - "," - ) + self.user_scopes = ( # type: ignore + user_scopes + if user_scopes is not None + else os.environ.get("SLACK_USER_SCOPES", "").split(",") # type: ignore + ) # type: ignore if isinstance(self.user_scopes, str): self.user_scopes = self.user_scopes.split(",") + self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") # Handler configuration self.install_path = install_path or os.environ.get( @@ -368,14 +378,24 @@

    Args

    self.client_id = client_id self.client_secret = client_secret - self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + # NOTE: pytype says that self.scopes can be str, not Sequence[str]. + # That's true but we will check the pattern in the following if statement. + # Thus, we ignore the warnings here. This is the same for user_scopes too. + self.scopes = ( # type: ignore + scopes # type: ignore + if scopes is not None + else os.environ.get("SLACK_SCOPES", "").split(",") # type: ignore + ) # type: ignore if isinstance(self.scopes, str): self.scopes = self.scopes.split(",") - self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( - "," - ) + self.user_scopes = ( # type: ignore + user_scopes + if user_scopes is not None + else os.environ.get("SLACK_USER_SCOPES", "").split(",") # type: ignore + ) # type: ignore if isinstance(self.user_scopes, str): self.user_scopes = self.user_scopes.split(",") + self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") # Handler configuration self.install_path = install_path or os.environ.get( diff --git a/docs/api-docs/slack_bolt/oauth/internals.html b/docs/api-docs/slack_bolt/oauth/internals.html index 90934dc3a..23db0f934 100644 --- a/docs/api-docs/slack_bolt/oauth/internals.html +++ b/docs/api-docs/slack_bolt/oauth/internals.html @@ -88,7 +88,10 @@

    Module slack_bolt.oauth.internals

    ) self._logger.debug(debug_message) - html = self._redirect_uri_page_renderer.render_failure_page(reason) + # Adding a bit more details to the error code to help installers understand what's happening. + # This modification in the HTML page works only when developers use this built-in failure handler. + detailed_error = build_detailed_error(reason) + html = self._redirect_uri_page_renderer.render_failure_page(detailed_error) return BoltResponse( status=status, headers={ @@ -153,7 +156,24 @@

    Module slack_bolt.oauth.internals

    return app_store else: # only oauth_flow_store is available - return oauth_flow_store

    + return oauth_flow_store + + +def build_detailed_error(reason: str) -> str: + if reason == "invalid_browser": + return ( + f"{reason}: This can occur due to page reload, " + "not beginning the OAuth flow from the valid starting URL, or " + "the /slack/install URL not using https://" + ) + elif reason == "invalid_state": + return f"{reason}: The state parameter is no longer valid." + elif reason == "missing_code": + return f"{reason}: The code parameter is missing in this redirection." + elif reason == "storage_error": + return f"{reason}: The app's server encountered an issue. Contact the app developer." + else: + return f"{reason}: This error code is returned from Slack. Refer to the documents for details."
    @@ -163,6 +183,32 @@

    Module slack_bolt.oauth.internals

    Functions

    +
    +def build_detailed_error(reason: str) ‑> str +
    +
    +
    +
    + +Expand source code + +
    def build_detailed_error(reason: str) -> str:
    +    if reason == "invalid_browser":
    +        return (
    +            f"{reason}: This can occur due to page reload, "
    +            "not beginning the OAuth flow from the valid starting URL, or "
    +            "the /slack/install URL not using https://"
    +        )
    +    elif reason == "invalid_state":
    +        return f"{reason}: The state parameter is no longer valid."
    +    elif reason == "missing_code":
    +        return f"{reason}: The code parameter is missing in this redirection."
    +    elif reason == "storage_error":
    +        return f"{reason}: The app's server encountered an issue. Contact the app developer."
    +    else:
    +        return f"{reason}: This error code is returned from Slack. Refer to the documents for details."
    +
    +
    def get_or_create_default_installation_store(client_id: str) ‑> slack_sdk.oauth.installation_store.installation_store.InstallationStore
    @@ -277,7 +323,10 @@

    Classes

    ) self._logger.debug(debug_message) - html = self._redirect_uri_page_renderer.render_failure_page(reason) + # Adding a bit more details to the error code to help installers understand what's happening. + # This modification in the HTML page works only when developers use this built-in failure handler. + detailed_error = build_detailed_error(reason) + html = self._redirect_uri_page_renderer.render_failure_page(detailed_error) return BoltResponse( status=status, headers={ @@ -304,6 +353,7 @@

    Index

  • Functions

    diff --git a/docs/api-docs/slack_bolt/oauth/oauth_settings.html b/docs/api-docs/slack_bolt/oauth/oauth_settings.html index a75cdbf60..fc7b07414 100644 --- a/docs/api-docs/slack_bolt/oauth/oauth_settings.html +++ b/docs/api-docs/slack_bolt/oauth/oauth_settings.html @@ -140,12 +140,21 @@

    Module slack_bolt.oauth.oauth_settings

    self.client_id = client_id self.client_secret = client_secret - self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + # NOTE: pytype says that self.scopes can be str, not Sequence[str]. + # That's true but we will check the pattern in the following if statement. + # Thus, we ignore the warnings here. This is the same for user_scopes too. + self.scopes = ( # type: ignore + scopes # type: ignore + if scopes is not None + else os.environ.get("SLACK_SCOPES", "").split(",") # type: ignore + ) # type: ignore if isinstance(self.scopes, str): self.scopes = self.scopes.split(",") - self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( - "," - ) + self.user_scopes = ( # type: ignore + user_scopes + if user_scopes is not None + else os.environ.get("SLACK_USER_SCOPES", "").split(",") # type: ignore + ) # type: ignore if isinstance(self.user_scopes, str): self.user_scopes = self.user_scopes.split(",") self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") @@ -361,12 +370,21 @@

    Args

    self.client_id = client_id self.client_secret = client_secret - self.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + # NOTE: pytype says that self.scopes can be str, not Sequence[str]. + # That's true but we will check the pattern in the following if statement. + # Thus, we ignore the warnings here. This is the same for user_scopes too. + self.scopes = ( # type: ignore + scopes # type: ignore + if scopes is not None + else os.environ.get("SLACK_SCOPES", "").split(",") # type: ignore + ) # type: ignore if isinstance(self.scopes, str): self.scopes = self.scopes.split(",") - self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( - "," - ) + self.user_scopes = ( # type: ignore + user_scopes + if user_scopes is not None + else os.environ.get("SLACK_USER_SCOPES", "").split(",") # type: ignore + ) # type: ignore if isinstance(self.user_scopes, str): self.user_scopes = self.user_scopes.split(",") self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index aaf3c299b..ab23705b3 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.9.1"
    +__version__ = "1.9.2"
  • From 16ea0bd9a32e62fa04e6e894cea9734a7a134ba8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 13 Oct 2021 16:45:28 +0900 Subject: [PATCH 399/865] Lock the pytype version for now --- .github/workflows/ci-build.yml | 3 ++- scripts/run_pytype.sh | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index dae01f300..44463ccb3 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -74,7 +74,8 @@ jobs: if [ ${python_version:7:3} == "3.8" ]; then pip install -e ".[async]" pip install -e ".[adapter]" - pip install "pytype" && pytype slack_bolt/ + # TODO: upgrade pytype + pip install "pytype==2021.9.27" && pytype slack_bolt/ fi - name: Run all tests for codecov (3.9 only) run: | diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index bfdf066be..b76aea800 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -4,5 +4,6 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ pip install -e ".[adapter]" && \ - pip install -U pytype && \ + # TODO: upgrade pytype + pip install "pytype==2021.9.27" && \ pytype slack_bolt/ From 01ec9b6c695ade68080e6d65ad2f35c34700df1d Mon Sep 17 00:00:00 2001 From: Jason Wong Date: Wed, 13 Oct 2021 17:16:39 +0900 Subject: [PATCH 400/865] Fix #480 Adds updating views on submission for Japanese docs (#494) * fix #480 #479 for Japanese docs * add in changes from Kazs comments * Update docs/_basic/ja_listening_modals.md Co-authored-by: Kazuhiro Sera --- docs/_basic/ja_listening_modals.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/_basic/ja_listening_modals.md b/docs/_basic/ja_listening_modals.md index 27362a32f..fb469eea1 100644 --- a/docs/_basic/ja_listening_modals.md +++ b/docs/_basic/ja_listening_modals.md @@ -7,10 +7,24 @@ order: 12
    -モーダルのペイロードに input ブロックを含める場合、その入力値を受け取るために`view_submission` リクエストをリッスンする必要があります。`view_submission` リクエストのリッスンには、組み込みの`view()` メソッドを利用することができます。`view()` の引数には、`str` 型または `re.Pattern` 型の `callback_id` を指定します。 +モーダルのペイロードに `input` ブロックを含める場合、その入力値を受け取るために`view_submission` リクエストをリッスンする必要があります。`view_submission` リクエストのリッスンには、組み込みの`view()` メソッドを利用することができます。`view()` の引数には、`str` 型または `re.Pattern` 型の `callback_id` を指定します。 `input` ブロックの値にアクセスするには `state` オブジェクトを参照します。`state` 内には `values` というオブジェクトがあり、`block_id` と一意の `action_id` に紐づける形で入力値を保持しています。 +--- + +##### モーダル送信でのビューの更新 + +`view_submission` リクエストに対してモーダルを更新するには、リクエストの確認の中で `update` という `response_action` と新しく作成した `view` を指定します。 + +```python +# モーダル送信でのビューの更新 +@app.view("view_1") +def handle_submission(ack, body): + ack(response_action="update", view=build_new_view(body)) +``` +この例と同様に、モーダルでの送信リクエストに対して、エラーを表示するためのオプションもあります。 + モーダルの送信について詳しくは、API ドキュメントを参照してください。
    From d2d9ffdc6433f56c54d525faa8e6d8f0b2503613 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 13 Oct 2021 17:17:03 +0900 Subject: [PATCH 401/865] Fix #488 app.message listeners do not catch messages with subtype: thread_broadcast (#489) * Fix #488 app.message listeners do not catch messages with subtype: thread_broadcast * Add more comments * Update slack_bolt/app/async_app.py --- slack_bolt/app/app.py | 16 ++- slack_bolt/app/async_app.py | 16 ++- .../test_message_thread_broadcast.py | 123 +++++++++++++++++ .../test_message_thread_broadcast.py | 129 ++++++++++++++++++ 4 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 tests/scenario_tests/test_message_thread_broadcast.py create mode 100644 tests/scenario_tests_async/test_message_thread_broadcast.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index c71afc18c..2a2defda8 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -792,9 +792,19 @@ def say_hello(message, say): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - # As of Jan 2021, most bot messages no longer have the subtype bot_message. - # By contrast, messages posted using classic app's bot token still have the subtype. - constraints = {"type": "message", "subtype": (None, "bot_message")} + constraints = { + "type": "message", + "subtype": ( + # In most cases, new message events come with no subtype. + None, + # As of Jan 2021, most bot messages no longer have the subtype bot_message. + # By contrast, messages posted using classic app's bot token still have the subtype. + "bot_message", + # If an end-user posts a message with "Also send to #channel" checked, + # the message event comes with this subtype. + "thread_broadcast", + ), + } primary_matcher = builtin_matchers.event(constraints=constraints) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener( diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index bbf4be64f..ee2f84783 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -840,9 +840,19 @@ async def say_hello(message, say): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - # As of Jan 2021, most bot messages no longer have the subtype bot_message. - # By contrast, messages posted using classic app's bot token still have the subtype. - constraints = {"type": "message", "subtype": (None, "bot_message")} + constraints = { + "type": "message", + "subtype": ( + # In most cases, new message events come with no subtype. + None, + # As of Jan 2021, most bot messages no longer have the subtype bot_message. + # By contrast, messages posted using classic app's bot token still have the subtype. + "bot_message", + # If an end-user posts a message with "Also send to #channel" checked, + # the message event comes with this subtype. + "thread_broadcast", + ), + } primary_matcher = builtin_matchers.event( constraints=constraints, asyncio=True ) diff --git a/tests/scenario_tests/test_message_thread_broadcast.py b/tests/scenario_tests/test_message_thread_broadcast.py new file mode 100644 index 000000000..a69147782 --- /dev/null +++ b/tests/scenario_tests/test_message_thread_broadcast.py @@ -0,0 +1,123 @@ +import json +import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestMessageThreadBroadcast: + 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_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, event_payload: dict) -> BoltRequest: + timestamp, body = str(int(time.time())), json.dumps(event_payload) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_message_handler(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + result = {"call_count": 0} + + @app.message("Hi there!") + def handle_messages(event, logger): + logger.info(event) + result["call_count"] = result["call_count"] + 1 + + request = self.build_request(event_payload) + response = app.dispatch(request) + assert response.status == 200 + + request = self.build_request(event_payload) + response = app.dispatch(request) + assert response.status == 200 + + assert_auth_test_count(self, 1) + time.sleep(1) # wait a bit after auto ack() + assert result["call_count"] == 2 + + +event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "thread_broadcast", + "text": "Hi there!", + "user": "U111", + "ts": "1633670813.007500", + "thread_ts": "1633663824.000500", + "root": { + "client_msg_id": "111-222-333-444-555", + "type": "message", + "text": "Write in the thread :bow:", + "user": "U111", + "ts": "1633663824.000500", + "team": "T111", + "thread_ts": "1633663824.000500", + "reply_count": 17, + "reply_users_count": 1, + "latest_reply": "1633670813.007500", + "reply_users": ["U111"], + "is_locked": False, + }, + "client_msg_id": "111-222-333-444-666", + "channel": "C111", + "event_ts": "1633670813.007500", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610261659, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} diff --git a/tests/scenario_tests_async/test_message_thread_broadcast.py b/tests/scenario_tests_async/test_message_thread_broadcast.py new file mode 100644 index 000000000..84ae3ea93 --- /dev/null +++ b/tests/scenario_tests_async/test_message_thread_broadcast.py @@ -0,0 +1,129 @@ +import asyncio +import json +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +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, + assert_auth_test_count_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncMessageThreadBroadcast: + 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, + ) + + @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) + + 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, event_payload: dict) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(event_payload) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_string_keyword(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + result = {"call_count": 0} + + @app.message("Hi there!") + async def handle_messages(event, logger): + logger.info(event) + result["call_count"] = result["call_count"] + 1 + + request = self.build_request(event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + request = self.build_request(event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(1) # wait a bit after auto ack() + assert result["call_count"] == 2 + + +event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "thread_broadcast", + "text": "Hi there!", + "user": "U111", + "ts": "1633670813.007500", + "thread_ts": "1633663824.000500", + "root": { + "client_msg_id": "111-222-333-444-555", + "type": "message", + "text": "Write in the thread :bow:", + "user": "U111", + "ts": "1633663824.000500", + "team": "T111", + "thread_ts": "1633663824.000500", + "reply_count": 17, + "reply_users_count": 1, + "latest_reply": "1633670813.007500", + "reply_users": ["U111"], + "is_locked": False, + }, + "client_msg_id": "111-222-333-444-666", + "channel": "C111", + "event_ts": "1633670813.007500", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610261659, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} From 81d0e7a8b7199e675faf7e4038d8f3215ad98735 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 13 Oct 2021 17:20:52 +0900 Subject: [PATCH 402/865] version 1.9.3 --- docs/api-docs/slack_bolt/app/app.html | 48 +++++++++++++++++---- docs/api-docs/slack_bolt/app/async_app.html | 48 +++++++++++++++++---- docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 4 files changed, 80 insertions(+), 20 deletions(-) diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index e14ef09c2..e192514d2 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -820,9 +820,19 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - # As of Jan 2021, most bot messages no longer have the subtype bot_message. - # By contrast, messages posted using classic app's bot token still have the subtype. - constraints = {"type": "message", "subtype": (None, "bot_message")} + constraints = { + "type": "message", + "subtype": ( + # In most cases, new message events come with no subtype. + None, + # As of Jan 2021, most bot messages no longer have the subtype bot_message. + # By contrast, messages posted using classic app's bot token still have the subtype. + "bot_message", + # If an end-user posts a message with "Also send to #channel" checked, + # the message event comes with this subtype. + "thread_broadcast", + ), + } primary_matcher = builtin_matchers.event(constraints=constraints) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener( @@ -2271,9 +2281,19 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - # As of Jan 2021, most bot messages no longer have the subtype bot_message. - # By contrast, messages posted using classic app's bot token still have the subtype. - constraints = {"type": "message", "subtype": (None, "bot_message")} + constraints = { + "type": "message", + "subtype": ( + # In most cases, new message events come with no subtype. + None, + # As of Jan 2021, most bot messages no longer have the subtype bot_message. + # By contrast, messages posted using classic app's bot token still have the subtype. + "bot_message", + # If an end-user posts a message with "Also send to #channel" checked, + # the message event comes with this subtype. + "thread_broadcast", + ), + } primary_matcher = builtin_matchers.event(constraints=constraints) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener( @@ -3614,9 +3634,19 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - # As of Jan 2021, most bot messages no longer have the subtype bot_message. - # By contrast, messages posted using classic app's bot token still have the subtype. - constraints = {"type": "message", "subtype": (None, "bot_message")} + constraints = { + "type": "message", + "subtype": ( + # In most cases, new message events come with no subtype. + None, + # As of Jan 2021, most bot messages no longer have the subtype bot_message. + # By contrast, messages posted using classic app's bot token still have the subtype. + "bot_message", + # If an end-user posts a message with "Also send to #channel" checked, + # the message event comes with this subtype. + "thread_broadcast", + ), + } primary_matcher = builtin_matchers.event(constraints=constraints) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener( diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index 20653dbba..dd8f1deaf 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -868,9 +868,19 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - # As of Jan 2021, most bot messages no longer have the subtype bot_message. - # By contrast, messages posted using classic app's bot token still have the subtype. - constraints = {"type": "message", "subtype": (None, "bot_message")} + constraints = { + "type": "message", + "subtype": ( + # In most cases, new message events come with no subtype. + None, + # As of Jan 2021, most bot messages no longer have the subtype bot_message. + # By contrast, messages posted using classic app's bot token still have the subtype. + "bot_message", + # If an end-user posts a message with "Also send to #channel" checked, + # the message event comes with this subtype. + "thread_broadcast", + ), + } primary_matcher = builtin_matchers.event( constraints=constraints, asyncio=True ) @@ -2245,9 +2255,19 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - # As of Jan 2021, most bot messages no longer have the subtype bot_message. - # By contrast, messages posted using classic app's bot token still have the subtype. - constraints = {"type": "message", "subtype": (None, "bot_message")} + constraints = { + "type": "message", + "subtype": ( + # In most cases, new message events come with no subtype. + None, + # As of Jan 2021, most bot messages no longer have the subtype bot_message. + # By contrast, messages posted using classic app's bot token still have the subtype. + "bot_message", + # If an end-user posts a message with "Also send to #channel" checked, + # the message event comes with this subtype. + "thread_broadcast", + ), + } primary_matcher = builtin_matchers.event( constraints=constraints, asyncio=True ) @@ -3615,9 +3635,19 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - # As of Jan 2021, most bot messages no longer have the subtype bot_message. - # By contrast, messages posted using classic app's bot token still have the subtype. - constraints = {"type": "message", "subtype": (None, "bot_message")} + constraints = { + "type": "message", + "subtype": ( + # In most cases, new message events come with no subtype. + None, + # As of Jan 2021, most bot messages no longer have the subtype bot_message. + # By contrast, messages posted using classic app's bot token still have the subtype. + "bot_message", + # If an end-user posts a message with "Also send to #channel" checked, + # the message event comes with this subtype. + "thread_broadcast", + ), + } primary_matcher = builtin_matchers.event( constraints=constraints, asyncio=True ) diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index ab23705b3..b8cdbd7f2 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.9.2"
    +__version__ = "1.9.3"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 8800b5e8c..8c955e58c 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.9.2" +__version__ = "1.9.3" From bfe6216e1292e7c2e8dfd41cdc7231960e2625eb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 18 Oct 2021 09:21:00 +0900 Subject: [PATCH 403/865] Upgrade pytype to the latest (#496) --- .github/workflows/ci-build.yml | 5 +---- scripts/run_pytype.sh | 4 ++-- slack_bolt/app/app.py | 9 +++++---- slack_bolt/app/async_app.py | 9 +++++---- slack_bolt/app/async_server.py | 2 +- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 44463ccb3..7dfeed51f 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -72,10 +72,7 @@ jobs: run: | python_version=`python -V` if [ ${python_version:7:3} == "3.8" ]; then - pip install -e ".[async]" - pip install -e ".[adapter]" - # TODO: upgrade pytype - pip install "pytype==2021.9.27" && pytype slack_bolt/ + ./scripts/run_pytype.sh fi - name: Run all tests for codecov (3.9 only) run: | diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index b76aea800..88ad68ac4 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -3,7 +3,7 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ + pip install -e ".[async]" && \ pip install -e ".[adapter]" && \ - # TODO: upgrade pytype - pip install "pytype==2021.9.27" && \ + pip install "pytype==2021.10.11" && \ pytype slack_bolt/ diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 2a2defda8..dc10cfc53 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -179,7 +179,7 @@ def message_hello(message, say): listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will be used. """ - signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") + signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", "") token = token or os.environ.get("SLACK_BOT_TOKEN") self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] @@ -304,7 +304,7 @@ def message_hello(message, say): # Middleware Initialization # -------------------------------------- - self._middleware_list: List[Union[Callable, Middleware]] = [] + self._middleware_list: List[Middleware] = [] self._listeners: List[Listener] = [] if listener_executor is None: @@ -469,7 +469,7 @@ def dispatch(self, req: BoltRequest) -> BoltResponse: starting_time = time.time() self._init_context(req) - resp: BoltResponse = BoltResponse(status=200, body="") + resp: Optional[BoltResponse] = BoltResponse(status=200, body="") middleware_state = {"next_called": False} def middleware_next(): @@ -607,7 +607,8 @@ def middleware_func(logger, body, next): if len(args) > 0: middleware_or_callable = args[0] if isinstance(middleware_or_callable, Middleware): - self._middleware_list.append(middleware_or_callable) + middleware: Middleware = middleware_or_callable + self._middleware_list.append(middleware) elif isinstance(middleware_or_callable, Callable): self._middleware_list.append( CustomMiddleware(app_name=self.name, func=middleware_or_callable) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index ee2f84783..449a1daac 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -183,7 +183,7 @@ async def message_hello(message, say): # async function oauth_flow: Instantiated `slack_bolt.oauth.AsyncOAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. """ - signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") + signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", "") token = token or os.environ.get("SLACK_BOT_TOKEN") self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] @@ -327,7 +327,7 @@ async def message_hello(message, say): # async function # Middleware Initialization # -------------------------------------- - self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] + self._async_middleware_list: List[AsyncMiddleware] = [] self._async_listeners: List[AsyncListener] = [] self._process_before_response = process_before_response @@ -506,7 +506,7 @@ async def async_dispatch(self, req: AsyncBoltRequest) -> BoltResponse: starting_time = time.time() self._init_context(req) - resp: BoltResponse = BoltResponse(status=200, body="") + resp: Optional[BoltResponse] = BoltResponse(status=200, body="") middleware_state = {"next_called": False} async def async_middleware_next(): @@ -642,7 +642,8 @@ async def middleware_func(logger, body, next): if len(args) > 0: middleware_or_callable = args[0] if isinstance(middleware_or_callable, AsyncMiddleware): - self._async_middleware_list.append(middleware_or_callable) + middleware: AsyncMiddleware = middleware_or_callable + self._async_middleware_list.append(middleware) elif isinstance(middleware_or_callable, Callable): self._async_middleware_list.append( AsyncCustomMiddleware( diff --git a/slack_bolt/app/async_server.py b/slack_bolt/app/async_server.py index 6bb05588e..e5176fab2 100644 --- a/slack_bolt/app/async_server.py +++ b/slack_bolt/app/async_server.py @@ -29,7 +29,7 @@ def __init__( # type:ignore """ self.port = port self.path = path - self.bolt_app: "AsyncApp" = app + self.bolt_app: "AsyncApp" = app # type: ignore self.web_app = web.Application() self._bolt_oauth_flow = self.bolt_app.oauth_flow if self._bolt_oauth_flow: From e671691807c7845c1049c7455016870cb145c1ab Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 18 Oct 2021 09:21:20 +0900 Subject: [PATCH 404/865] Add Python 3.10 to the supported versions (#497) * Add Python 3.10 to the supported versions * Upgrade pytest to run tests in Python 3.10 * refactor --- .github/workflows/ci-build.yml | 4 ++-- setup.py | 16 +++++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 7dfeed51f..e2f362e5f 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -13,7 +13,7 @@ jobs: timeout-minutes: 15 strategy: matrix: - python-version: ['3.6', '3.7', '3.8', '3.9'] + python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] env: # default: multiprocessing # threading is more stable on GitHub Actions @@ -28,7 +28,7 @@ jobs: run: | python setup.py install pip install -U pip - pip install "pytest>=5,<6" "pytest-cov>=2,<3" "flask_sockets>0.2,<1" + pip install -e ".[testing_without_asyncio]" - name: Run tests without aiohttp run: | pytest tests/slack_bolt/ diff --git a/setup.py b/setup.py index 8d5e65851..0ea7278ee 100755 --- a/setup.py +++ b/setup.py @@ -13,15 +13,18 @@ long_description = fh.read() test_dependencies = [ - "pytest>=5,<6", + "pytest>=6.2.5,<7", "pytest-cov>=2,<3", - "pytest-asyncio<1", # for async - "aiohttp>=3,<4", # for async "Flask-Sockets>=0.2,<1", "Werkzeug<2", # TODO: support Flask 2.x "black==21.9b0", ] +async_test_dependencies = test_dependencies + [ + "pytest-asyncio<1", # for async + "aiohttp>=3,<4", # for async +] + setuptools.setup( name="slack_bolt", version=__version__, @@ -45,7 +48,7 @@ "slack_sdk>=3.9.0,<4", ], setup_requires=["pytest-runner==5.2"], - tests_require=test_dependencies, + tests_require=async_test_dependencies, test_suite="tests", extras_require={ # pip install -e ".[async]" @@ -84,14 +87,17 @@ # Socket Mode 3rd party implementation "websocket_client>=1,<2", ], + # pip install -e ".[testing_without_asyncio]" + "testing_without_asyncio": test_dependencies, # pip install -e ".[testing]" - "testing": test_dependencies, + "testing": async_test_dependencies, }, classifiers=[ "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: Implementation :: CPython", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", From c98710ebe6d38b8d9655a792a7ed0c45e3e2d8f2 Mon Sep 17 00:00:00 2001 From: Jason Wong Date: Fri, 22 Oct 2021 12:33:40 +0900 Subject: [PATCH 405/865] update link to point to jp docs instead of en (#500) --- docs/_advanced/ja_token_rotation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_advanced/ja_token_rotation.md b/docs/_advanced/ja_token_rotation.md index 6a733a87f..c8eb1cc56 100644 --- a/docs/_advanced/ja_token_rotation.md +++ b/docs/_advanced/ja_token_rotation.md @@ -10,7 +10,7 @@ Bolt for Python [v1.7.0](https://github.com/slackapi/bolt-python/releases/tag/v1 既存の Slack アプリではアクセストークンが無期限に存在し続けるのに対して、トークンローテーションを有効にしたアプリではアクセストークンが失効するようになります。リフレッシュトークンを利用して、アクセストークンを長期間にわたって更新し続けることができます。 -[Bolt for Python の組み込みの OAuth 機能](https://slack.dev/bolt-python/concepts#authenticating-oauth) を使用していれば、Bolt for Python が自動的にトークンローテーションの処理をハンドリングします。 +[Bolt for Python の組み込みの OAuth 機能](https://slack.dev/bolt-python/ja-jp/concepts#authenticating-oauth) を使用していれば、Bolt for Python が自動的にトークンローテーションの処理をハンドリングします。 トークンローテーションに関する詳細は [API ドキュメント](https://api.slack.com/authentication/rotation)を参照してください。 From 292e9c7ec2855612c33fa3192dc721c32ea5ae2d Mon Sep 17 00:00:00 2001 From: D Rutter <47083572+dkzk22@users.noreply.github.com> Date: Fri, 29 Oct 2021 15:30:26 +0100 Subject: [PATCH 406/865] fix perform_token_rotation kwarg (#503) --- slack_bolt/authorization/async_authorize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index d7cc19a50..09aa9c179 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -209,7 +209,7 @@ async def __call__( raise BoltError(self._config_error_message) refreshed = await self.token_rotator.perform_token_rotation( installation=installation, - token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, + minutes_before_expiration=self.token_rotation_expiration_minutes, ) if refreshed is not None: await self.installation_store.async_save(refreshed) From f9c28198b3385311e31aec58e2842d33abafd6df Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 30 Oct 2021 00:11:32 +0900 Subject: [PATCH 407/865] Bump optional dependencies for v1.9.4 release (#504) --- examples/aws_lambda/.gitignore | 2 ++ setup.py | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/examples/aws_lambda/.gitignore b/examples/aws_lambda/.gitignore index 2ab4a5771..dac781e7f 100644 --- a/examples/aws_lambda/.gitignore +++ b/examples/aws_lambda/.gitignore @@ -1,2 +1,4 @@ slack-bolt/ +slack_bolt/ +vendor/ .env diff --git a/setup.py b/setup.py index 0ea7278ee..f2925072f 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ test_dependencies = [ "pytest>=6.2.5,<7", - "pytest-cov>=2,<3", + "pytest-cov>=3,<4", "Flask-Sockets>=0.2,<1", "Werkzeug<2", # TODO: support Flask 2.x "black==21.9b0", @@ -67,18 +67,18 @@ "moto<2", # For AWS tests "bottle>=0.12,<1", "boddle>=0.2,<0.3", # For Bottle app tests - "chalice>=1.22.4,<2", + "chalice>=1.26.1,<2", "click>=7,<8", # for chalice "CherryPy>=18,<19", "Django>=3,<4", "falcon>=2,<3", - "fastapi<1", + "fastapi>=0.70.0,<1", "Flask>=1,<2", "Werkzeug<2", # TODO: support Flask 2.x "pyramid>=1,<2", "sanic>=21,<22" if sys.version_info.minor > 6 else "sanic>=20,<21", - "sanic-testing>=0.6" if sys.version_info.minor > 6 else "", - "starlette>=0.13,<1", + "sanic-testing>=0.7" if sys.version_info.minor > 6 else "", + "starlette>=0.14,<1", "requests>=2,<3", # For starlette's TestClient "tornado>=6,<7", # server From e112c7e75f743b2cef619946903237a4f526ce23 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 30 Oct 2021 00:13:25 +0900 Subject: [PATCH 408/865] version 1.9.4 --- docs/api-docs/slack_bolt/app/app.html | 23 +++++++++++-------- docs/api-docs/slack_bolt/app/async_app.html | 23 +++++++++++-------- .../api-docs/slack_bolt/app/async_server.html | 4 ++-- .../authorization/async_authorize.html | 4 ++-- docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 6 files changed, 32 insertions(+), 26 deletions(-) diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index e192514d2..70faf2c9d 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -207,7 +207,7 @@

    Module slack_bolt.app.app

    listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will be used. """ - signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") + signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", "") token = token or os.environ.get("SLACK_BOT_TOKEN") self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] @@ -332,7 +332,7 @@

    Module slack_bolt.app.app

    # Middleware Initialization # -------------------------------------- - self._middleware_list: List[Union[Callable, Middleware]] = [] + self._middleware_list: List[Middleware] = [] self._listeners: List[Listener] = [] if listener_executor is None: @@ -497,7 +497,7 @@

    Module slack_bolt.app.app

    starting_time = time.time() self._init_context(req) - resp: BoltResponse = BoltResponse(status=200, body="") + resp: Optional[BoltResponse] = BoltResponse(status=200, body="") middleware_state = {"next_called": False} def middleware_next(): @@ -635,7 +635,8 @@

    Module slack_bolt.app.app

    if len(args) > 0: middleware_or_callable = args[0] if isinstance(middleware_or_callable, Middleware): - self._middleware_list.append(middleware_or_callable) + middleware: Middleware = middleware_or_callable + self._middleware_list.append(middleware) elif isinstance(middleware_or_callable, Callable): self._middleware_list.append( CustomMiddleware(app_name=self.name, func=middleware_or_callable) @@ -1668,7 +1669,7 @@

    Args

    listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will be used. """ - signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") + signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", "") token = token or os.environ.get("SLACK_BOT_TOKEN") self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] @@ -1793,7 +1794,7 @@

    Args

    # Middleware Initialization # -------------------------------------- - self._middleware_list: List[Union[Callable, Middleware]] = [] + self._middleware_list: List[Middleware] = [] self._listeners: List[Listener] = [] if listener_executor is None: @@ -1958,7 +1959,7 @@

    Args

    starting_time = time.time() self._init_context(req) - resp: BoltResponse = BoltResponse(status=200, body="") + resp: Optional[BoltResponse] = BoltResponse(status=200, body="") middleware_state = {"next_called": False} def middleware_next(): @@ -2096,7 +2097,8 @@

    Args

    if len(args) > 0: middleware_or_callable = args[0] if isinstance(middleware_or_callable, Middleware): - self._middleware_list.append(middleware_or_callable) + middleware: Middleware = middleware_or_callable + self._middleware_list.append(middleware) elif isinstance(middleware_or_callable, Callable): self._middleware_list.append( CustomMiddleware(app_name=self.name, func=middleware_or_callable) @@ -3292,7 +3294,7 @@

    Returns

    starting_time = time.time() self._init_context(req) - resp: BoltResponse = BoltResponse(status=200, body="") + resp: Optional[BoltResponse] = BoltResponse(status=200, body="") middleware_state = {"next_called": False} def middleware_next(): @@ -3732,7 +3734,8 @@

    Args

    if len(args) > 0: middleware_or_callable = args[0] if isinstance(middleware_or_callable, Middleware): - self._middleware_list.append(middleware_or_callable) + middleware: Middleware = middleware_or_callable + self._middleware_list.append(middleware) elif isinstance(middleware_or_callable, Callable): self._middleware_list.append( CustomMiddleware(app_name=self.name, func=middleware_or_callable) diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index dd8f1deaf..0296dd263 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -211,7 +211,7 @@

    Module slack_bolt.app.async_app

    oauth_flow: Instantiated `slack_bolt.oauth.AsyncOAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. """ - signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") + signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", "") token = token or os.environ.get("SLACK_BOT_TOKEN") self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] @@ -355,7 +355,7 @@

    Module slack_bolt.app.async_app

    # Middleware Initialization # -------------------------------------- - self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] + self._async_middleware_list: List[AsyncMiddleware] = [] self._async_listeners: List[AsyncListener] = [] self._process_before_response = process_before_response @@ -534,7 +534,7 @@

    Module slack_bolt.app.async_app

    starting_time = time.time() self._init_context(req) - resp: BoltResponse = BoltResponse(status=200, body="") + resp: Optional[BoltResponse] = BoltResponse(status=200, body="") middleware_state = {"next_called": False} async def async_middleware_next(): @@ -670,7 +670,8 @@

    Module slack_bolt.app.async_app

    if len(args) > 0: middleware_or_callable = args[0] if isinstance(middleware_or_callable, AsyncMiddleware): - self._async_middleware_list.append(middleware_or_callable) + middleware: AsyncMiddleware = middleware_or_callable + self._async_middleware_list.append(middleware) elif isinstance(middleware_or_callable, Callable): self._async_middleware_list.append( AsyncCustomMiddleware( @@ -1598,7 +1599,7 @@

    Args

    oauth_flow: Instantiated `slack_bolt.oauth.AsyncOAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. """ - signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") + signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", "") token = token or os.environ.get("SLACK_BOT_TOKEN") self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] @@ -1742,7 +1743,7 @@

    Args

    # Middleware Initialization # -------------------------------------- - self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] + self._async_middleware_list: List[AsyncMiddleware] = [] self._async_listeners: List[AsyncListener] = [] self._process_before_response = process_before_response @@ -1921,7 +1922,7 @@

    Args

    starting_time = time.time() self._init_context(req) - resp: BoltResponse = BoltResponse(status=200, body="") + resp: Optional[BoltResponse] = BoltResponse(status=200, body="") middleware_state = {"next_called": False} async def async_middleware_next(): @@ -2057,7 +2058,8 @@

    Args

    if len(args) > 0: middleware_or_callable = args[0] if isinstance(middleware_or_callable, AsyncMiddleware): - self._async_middleware_list.append(middleware_or_callable) + middleware: AsyncMiddleware = middleware_or_callable + self._async_middleware_list.append(middleware) elif isinstance(middleware_or_callable, Callable): self._async_middleware_list.append( AsyncCustomMiddleware( @@ -3005,7 +3007,7 @@

    Returns

    starting_time = time.time() self._init_context(req) - resp: BoltResponse = BoltResponse(status=200, body="") + resp: Optional[BoltResponse] = BoltResponse(status=200, body="") middleware_state = {"next_called": False} async def async_middleware_next(): @@ -3732,7 +3734,8 @@

    Args

    if len(args) > 0: middleware_or_callable = args[0] if isinstance(middleware_or_callable, AsyncMiddleware): - self._async_middleware_list.append(middleware_or_callable) + middleware: AsyncMiddleware = middleware_or_callable + self._async_middleware_list.append(middleware) elif isinstance(middleware_or_callable, Callable): self._async_middleware_list.append( AsyncCustomMiddleware( diff --git a/docs/api-docs/slack_bolt/app/async_server.html b/docs/api-docs/slack_bolt/app/async_server.html index b816d91c6..24961f59a 100644 --- a/docs/api-docs/slack_bolt/app/async_server.html +++ b/docs/api-docs/slack_bolt/app/async_server.html @@ -57,7 +57,7 @@

    Module slack_bolt.app.async_server

    """ self.port = port self.path = path - self.bolt_app: "AsyncApp" = app + self.bolt_app: "AsyncApp" = app # type: ignore self.web_app = web.Application() self._bolt_oauth_flow = self.bolt_app.oauth_flow if self._bolt_oauth_flow: @@ -161,7 +161,7 @@

    Args

    """ self.port = port self.path = path - self.bolt_app: "AsyncApp" = app + self.bolt_app: "AsyncApp" = app # type: ignore self.web_app = web.Application() self._bolt_oauth_flow = self.bolt_app.oauth_flow if self._bolt_oauth_flow: diff --git a/docs/api-docs/slack_bolt/authorization/async_authorize.html b/docs/api-docs/slack_bolt/authorization/async_authorize.html index 1f9413b83..e919b6161 100644 --- a/docs/api-docs/slack_bolt/authorization/async_authorize.html +++ b/docs/api-docs/slack_bolt/authorization/async_authorize.html @@ -237,7 +237,7 @@

    Module slack_bolt.authorization.async_authorizeAncestors

    raise BoltError(self._config_error_message) refreshed = await self.token_rotator.perform_token_rotation( installation=installation, - token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, + minutes_before_expiration=self.token_rotation_expiration_minutes, ) if refreshed is not None: await self.installation_store.async_save(refreshed) diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index b8cdbd7f2..dfb472789 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.9.3"
    +__version__ = "1.9.4"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 8c955e58c..3f785e181 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.9.3" +__version__ = "1.9.4" From 8a0c87ee14a70cbeb462515cef1bbef68dcf0732 Mon Sep 17 00:00:00 2001 From: TORIFUKUKaiou Date: Sat, 30 Oct 2021 10:37:43 +0900 Subject: [PATCH 409/865] delete action_button_click(body, ack, say) (#507) The document of getting_started.md has not already been implemented action_button_click at this time. --- docs/_tutorials/ja_getting_started.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/_tutorials/ja_getting_started.md b/docs/_tutorials/ja_getting_started.md index 8151f82d0..669092550 100644 --- a/docs/_tutorials/ja_getting_started.md +++ b/docs/_tutorials/ja_getting_started.md @@ -228,12 +228,6 @@ def message_hello(message, say): text=f"Hey there <@{message['user']}>!" ) -@app.action("button_click") -def action_button_click(body, ack, say): - # アクションのリクエストを確認 - ack() - say(f"<@{body['user']['id']}> clicked the button") - # アプリを起動します if __name__ == "__main__": SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() From 8534905d378f8b7dddaaa24becad4a0a5c037cf2 Mon Sep 17 00:00:00 2001 From: TORIFUKUKaiou Date: Sat, 30 Oct 2021 10:43:48 +0900 Subject: [PATCH 410/865] app-level (xapp) token can be gotton from the Basic Information page (#508) --- docs/_tutorials/ja_getting_started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_tutorials/ja_getting_started.md b/docs/_tutorials/ja_getting_started.md index 669092550..4aa564b11 100644 --- a/docs/_tutorials/ja_getting_started.md +++ b/docs/_tutorials/ja_getting_started.md @@ -95,7 +95,7 @@ Bolt for Python のパッケージを新しいプロジェクトにインスト export SLACK_BOT_TOKEN=xoxb-<ボットトークン> ``` -2. **OAuth & Permissions ページのアプリレベルトークン(xapp)をコピー**して、別の環境変数に保存します。 +2. **Basic Information ページのアプリレベルトークン(xapp)をコピー**して、別の環境変数に保存します。 ```shell export SLACK_APP_TOKEN=<アプリレベルトークン> ``` From 4d6448462468fe8abee80b8ad3e94dda980803bb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 2 Nov 2021 18:23:15 +0900 Subject: [PATCH 411/865] Upgrade pytype version to 2021.10.25 (#511) --- scripts/run_pytype.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index 88ad68ac4..4b305f1b6 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -5,5 +5,5 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ pip install -e ".[async]" && \ pip install -e ".[adapter]" && \ - pip install "pytype==2021.10.11" && \ + pip install "pytype==2021.10.25" && \ pytype slack_bolt/ From cf3951e4eebe7f72daf235d285023fbda090d24a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 3 Nov 2021 06:28:00 +0900 Subject: [PATCH 412/865] Improve the Django DB connection management #509 (#512) --- slack_bolt/adapter/django/handler.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/slack_bolt/adapter/django/handler.py b/slack_bolt/adapter/django/handler.py index 2862a380b..2cb9754c0 100644 --- a/slack_bolt/adapter/django/handler.py +++ b/slack_bolt/adapter/django/handler.py @@ -54,15 +54,16 @@ def to_django_response(bolt_resp: BoltResponse) -> HttpResponse: return resp -from django.db import connections +from django.db import close_old_connections -def release_thread_local_connections(logger: Logger, execution_type: str): - connections.close_all() +def release_thread_local_connections(logger: Logger, execution_timing: str): + close_old_connections() if logger.level <= logging.DEBUG: current: Thread = current_thread() logger.debug( - f"Released thread-bound DB connections (thread name: {current.name}, execution type: {execution_type})" + "Released thread-bound old DB connections " + f"(thread name: {current.name}, execution timing: {execution_timing})" ) @@ -73,7 +74,7 @@ class DjangoListenerCompletionHandler(ListenerCompletionHandler): """ def handle(self, request: BoltRequest, response: Optional[BoltResponse]) -> None: - release_thread_local_connections(request.context.logger, "listener") + release_thread_local_connections(request.context.logger, "listener-completion") class DjangoThreadLazyListenerRunner(ThreadLazyListenerRunner): @@ -89,7 +90,7 @@ def wrapped_func(): func() finally: release_thread_local_connections( - request.context.logger, "lazy-listener" + request.context.logger, "lazy-listener-completion" ) self.executor.submit(wrapped_func) @@ -120,14 +121,12 @@ def __init__(self, app: App): # type: ignore if current_completion_handler is not None and not isinstance( current_completion_handler, DefaultListenerCompletionHandler ): + # As we run release_thread_local_connections() before listener executions, + # it's okay to skip calling the same connection clean-up method at the listener completion. message = """As you've already set app.listener_runner.listener_completion_handler to your own one, Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerCompletionHandler. - We strongly recommend having the following lines of code in your listener_completion_handler: - - from django.db import connections - connections.close_all() """ - self.app.logger.warning(message) + self.app.logger.info(message) return # for proper management of thread-local Django DB connections self.app.listener_runner.listener_completion_handler = ( @@ -146,6 +145,13 @@ def handle(self, req: HttpRequest) -> HttpResponse: bolt_resp = oauth_flow.handle_callback(to_bolt_request(req)) return to_django_response(bolt_resp) elif req.method == "POST": + # As bolt-python utilizes threads for async `ack()` method execution, + # we have to manually clean old/stale Django ORM connections bound to the "unmanaged" threads + # Refer to https://github.com/slackapi/bolt-python/issues/280 for more details. + release_thread_local_connections( + self.app.logger, "before-listener-invocation" + ) + # And then, run the App listener/lazy listener here bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req)) return to_django_response(bolt_resp) From fc7013b575fb7b54ea02b644c52472ed596a2b9d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 4 Nov 2021 06:39:23 +0900 Subject: [PATCH 413/865] Introduce ListenerStartHandler in the listener runner for better managing Django DB connections (#514) --- slack_bolt/adapter/django/handler.py | 47 +++++++++++--- slack_bolt/app/app.py | 4 ++ slack_bolt/app/async_app.py | 6 ++ .../async_listener_completion_handler.py | 3 +- .../listener/async_listener_start_handler.py | 57 +++++++++++++++++ slack_bolt/listener/asyncio_runner.py | 12 ++++ .../listener/listener_completion_handler.py | 2 +- slack_bolt/listener/listener_start_handler.py | 61 +++++++++++++++++++ slack_bolt/listener/thread_runner.py | 12 ++++ slack_bolt/version.py | 2 +- 10 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 slack_bolt/listener/async_listener_start_handler.py create mode 100644 slack_bolt/listener/listener_start_handler.py diff --git a/slack_bolt/adapter/django/handler.py b/slack_bolt/adapter/django/handler.py index 2cb9754c0..7d86adfbe 100644 --- a/slack_bolt/adapter/django/handler.py +++ b/slack_bolt/adapter/django/handler.py @@ -9,6 +9,10 @@ from slack_bolt.error import BoltError from slack_bolt.lazy_listener import ThreadLazyListenerRunner from slack_bolt.lazy_listener.internals import build_runnable_function +from slack_bolt.listener.listener_start_handler import ( + ListenerStartHandler, + DefaultListenerStartHandler, +) from slack_bolt.listener.listener_completion_handler import ( ListenerCompletionHandler, DefaultListenerCompletionHandler, @@ -67,6 +71,16 @@ def release_thread_local_connections(logger: Logger, execution_timing: str): ) +class DjangoListenerStartHandler(ListenerStartHandler): + """Django sets DB connections as a thread-local variable per thread. + If the thread is not managed on the Django app side, the connections won't be released by Django. + This handler releases the connections every time a ThreadListenerRunner execution completes. + """ + + def handle(self, request: BoltRequest, response: Optional[BoltResponse]) -> None: + release_thread_local_connections(request.context.logger, "listener-start") + + class DjangoListenerCompletionHandler(ListenerCompletionHandler): """Django sets DB connections as a thread-local variable per thread. If the thread is not managed on the Django app side, the connections won't be released by Django. @@ -86,6 +100,9 @@ def start(self, function: Callable[..., None], request: BoltRequest) -> None: ) def wrapped_func(): + release_thread_local_connections( + request.context.logger, "before-lazy-listener" + ) try: func() finally: @@ -117,6 +134,29 @@ def __init__(self, app: App): # type: ignore self.app.logger.debug("App.process_before_response is set to True") return + current_start_handler = listener_runner.listener_start_handler + if current_start_handler is not None and not isinstance( + current_start_handler, DefaultListenerStartHandler + ): + # As we run release_thread_local_connections() before listener executions, + # it's okay to skip calling the same connection clean-up method at the listener completion. + message = """As you've already set app.listener_runner.listener_start_handler to your own one, + Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerStartHandler. + + If you go with your own handler here, we highly recommend having the following lines of code + in your handle() method to clean up unmanaged stale/old database connections: + + from django.db import close_old_connections + close_old_connections() + """ + self.app.logger.info(message) + else: + # for proper management of thread-local Django DB connections + self.app.listener_runner.listener_start_handler = ( + DjangoListenerStartHandler() + ) + self.app.logger.debug("DjangoListenerStartHandler has been enabled") + current_completion_handler = listener_runner.listener_completion_handler if current_completion_handler is not None and not isinstance( current_completion_handler, DefaultListenerCompletionHandler @@ -145,13 +185,6 @@ def handle(self, req: HttpRequest) -> HttpResponse: bolt_resp = oauth_flow.handle_callback(to_bolt_request(req)) return to_django_response(bolt_resp) elif req.method == "POST": - # As bolt-python utilizes threads for async `ack()` method execution, - # we have to manually clean old/stale Django ORM connections bound to the "unmanaged" threads - # Refer to https://github.com/slackapi/bolt-python/issues/280 for more details. - release_thread_local_connections( - self.app.logger, "before-listener-invocation" - ) - # And then, run the App listener/lazy listener here bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req)) return to_django_response(bolt_resp) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index dc10cfc53..e3eea413f 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -23,6 +23,7 @@ from slack_bolt.listener.builtins import TokenRevocationListeners from slack_bolt.listener.custom_listener import CustomListener from slack_bolt.listener.listener import Listener +from slack_bolt.listener.listener_start_handler import DefaultListenerStartHandler from slack_bolt.listener.listener_completion_handler import ( DefaultListenerCompletionHandler, ) @@ -317,6 +318,9 @@ def message_hello(message, say): listener_error_handler=DefaultListenerErrorHandler( logger=self._framework_logger ), + listener_start_handler=DefaultListenerStartHandler( + logger=self._framework_logger + ), listener_completion_handler=DefaultListenerCompletionHandler( logger=self._framework_logger ), diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 449a1daac..e44d1d59d 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -8,6 +8,9 @@ from slack_bolt.app.async_server import AsyncSlackAppServer from slack_bolt.listener.async_builtins import AsyncTokenRevocationListeners +from slack_bolt.listener.async_listener_start_handler import ( + AsyncDefaultListenerStartHandler, +) from slack_bolt.listener.async_listener_completion_handler import ( AsyncDefaultListenerCompletionHandler, ) @@ -337,6 +340,9 @@ async def message_hello(message, say): # async function listener_error_handler=AsyncDefaultListenerErrorHandler( logger=self._framework_logger ), + listener_start_handler=AsyncDefaultListenerStartHandler( + logger=self._framework_logger + ), listener_completion_handler=AsyncDefaultListenerCompletionHandler( logger=self._framework_logger ), diff --git a/slack_bolt/listener/async_listener_completion_handler.py b/slack_bolt/listener/async_listener_completion_handler.py index 14a4d8e91..6f70fdc5d 100644 --- a/slack_bolt/listener/async_listener_completion_handler.py +++ b/slack_bolt/listener/async_listener_completion_handler.py @@ -15,10 +15,9 @@ async def handle( request: AsyncBoltRequest, response: Optional[BoltResponse], ) -> None: - """Handles an unhandled exception. + """Do something extra after the listener execution Args: - error: The raised exception. request: The request. response: The response. """ diff --git a/slack_bolt/listener/async_listener_start_handler.py b/slack_bolt/listener/async_listener_start_handler.py new file mode 100644 index 000000000..080c83b1a --- /dev/null +++ b/slack_bolt/listener/async_listener_start_handler.py @@ -0,0 +1,57 @@ +import inspect +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Awaitable, Optional + +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse + + +class AsyncListenerStartHandler(metaclass=ABCMeta): + @abstractmethod + async def handle( + self, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + """Do something extra before the listener execution + + Args: + request: The request. + response: The response. + """ + raise NotImplementedError() + + +class AsyncCustomListenerStartHandler(AsyncListenerStartHandler): + def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]): + self.func = func + self.logger = logger + self.arg_names = inspect.getfullargspec(func).args + + async def handle( + self, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + kwargs: Dict[str, Any] = build_async_required_kwargs( + required_arg_names=self.arg_names, + logger=self.logger, + request=request, + response=response, + next_keys_required=False, + ) + await self.func(**kwargs) + + +class AsyncDefaultListenerStartHandler(AsyncListenerStartHandler): + def __init__(self, logger: Logger): + self.logger = logger + + async def handle( + self, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ): + pass diff --git a/slack_bolt/listener/asyncio_runner.py b/slack_bolt/listener/asyncio_runner.py index 37a532f3c..ecd66568b 100644 --- a/slack_bolt/listener/asyncio_runner.py +++ b/slack_bolt/listener/asyncio_runner.py @@ -7,6 +7,9 @@ from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.lazy_listener.async_runner import AsyncLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener +from slack_bolt.listener.async_listener_start_handler import ( + AsyncListenerStartHandler, +) from slack_bolt.listener.async_listener_completion_handler import ( AsyncListenerCompletionHandler, ) @@ -25,6 +28,7 @@ class AsyncioListenerRunner: logger: Logger process_before_response: bool listener_error_handler: AsyncListenerErrorHandler + listener_start_handler: AsyncListenerStartHandler listener_completion_handler: AsyncListenerCompletionHandler lazy_listener_runner: AsyncLazyListenerRunner @@ -33,12 +37,14 @@ def __init__( logger: Logger, process_before_response: bool, listener_error_handler: AsyncListenerErrorHandler, + listener_start_handler: AsyncListenerStartHandler, listener_completion_handler: AsyncListenerCompletionHandler, lazy_listener_runner: AsyncLazyListenerRunner, ): self.logger = logger self.process_before_response = process_before_response self.listener_error_handler = listener_error_handler + self.listener_start_handler = listener_start_handler self.listener_completion_handler = listener_completion_handler self.lazy_listener_runner = lazy_listener_runner @@ -55,6 +61,9 @@ async def run( if self.process_before_response: if not request.lazy_only: try: + await self.listener_start_handler.handle( + request=request, response=response + ) returned_value = await listener.run_ack_function( request=request, response=response ) @@ -113,6 +122,9 @@ async def run_ack_function_asynchronously( response: BoltResponse, ): try: + await self.listener_start_handler.handle( + request=request, response=response + ) await listener.run_ack_function( request=request, response=response ) diff --git a/slack_bolt/listener/listener_completion_handler.py b/slack_bolt/listener/listener_completion_handler.py index 18a062d32..2aad6a9aa 100644 --- a/slack_bolt/listener/listener_completion_handler.py +++ b/slack_bolt/listener/listener_completion_handler.py @@ -15,7 +15,7 @@ def handle( request: BoltRequest, response: Optional[BoltResponse], ) -> None: - """Handles an unhandled exception. + """Do something extra after the listener execution Args: request: The request. diff --git a/slack_bolt/listener/listener_start_handler.py b/slack_bolt/listener/listener_start_handler.py new file mode 100644 index 000000000..b70609747 --- /dev/null +++ b/slack_bolt/listener/listener_start_handler.py @@ -0,0 +1,61 @@ +import inspect +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Optional + +from slack_bolt.kwargs_injection import build_required_kwargs +from slack_bolt.request.request import BoltRequest +from slack_bolt.response.response import BoltResponse + + +class ListenerStartHandler(metaclass=ABCMeta): + @abstractmethod + def handle( + self, + request: BoltRequest, + response: Optional[BoltResponse], + ) -> None: + """Do something extra before the listener execution. + + This handler is useful if a developer needs to maintain/clean up + thread-local resources such as Django ORM database connections + before a listener execution starts. + + Args: + request: The request. + response: The response. + """ + raise NotImplementedError() + + +class CustomListenerStartHandler(ListenerStartHandler): + def __init__(self, logger: Logger, func: Callable[..., None]): + self.func = func + self.logger = logger + self.arg_names = inspect.getfullargspec(func).args + + def handle( + self, + request: BoltRequest, + response: Optional[BoltResponse], + ): + kwargs: Dict[str, Any] = build_required_kwargs( + required_arg_names=self.arg_names, + logger=self.logger, + request=request, + response=response, + next_keys_required=False, + ) + self.func(**kwargs) + + +class DefaultListenerStartHandler(ListenerStartHandler): + def __init__(self, logger: Logger): + self.logger = logger + + def handle( + self, + request: BoltRequest, + response: Optional[BoltResponse], + ): + pass diff --git a/slack_bolt/listener/thread_runner.py b/slack_bolt/listener/thread_runner.py index 941926722..b6a6b6e11 100644 --- a/slack_bolt/listener/thread_runner.py +++ b/slack_bolt/listener/thread_runner.py @@ -5,6 +5,7 @@ from slack_bolt.lazy_listener import LazyListenerRunner from slack_bolt.listener import Listener +from slack_bolt.listener.listener_start_handler import ListenerStartHandler from slack_bolt.listener.listener_completion_handler import ListenerCompletionHandler from slack_bolt.listener.listener_error_handler import ListenerErrorHandler from slack_bolt.logger.messages import ( @@ -21,6 +22,7 @@ class ThreadListenerRunner: logger: Logger process_before_response: bool listener_error_handler: ListenerErrorHandler + listener_start_handler: ListenerStartHandler listener_completion_handler: ListenerCompletionHandler listener_executor: Executor lazy_listener_runner: LazyListenerRunner @@ -30,6 +32,7 @@ def __init__( logger: Logger, process_before_response: bool, listener_error_handler: ListenerErrorHandler, + listener_start_handler: ListenerStartHandler, listener_completion_handler: ListenerCompletionHandler, listener_executor: Executor, lazy_listener_runner: LazyListenerRunner, @@ -37,6 +40,7 @@ def __init__( self.logger = logger self.process_before_response = process_before_response self.listener_error_handler = listener_error_handler + self.listener_start_handler = listener_start_handler self.listener_completion_handler = listener_completion_handler self.listener_executor = listener_executor self.lazy_listener_runner = lazy_listener_runner @@ -54,6 +58,10 @@ def run( # type: ignore if self.process_before_response: if not request.lazy_only: try: + self.listener_start_handler.handle( + request=request, + response=response, + ) returned_value = listener.run_ack_function( request=request, response=response ) @@ -109,6 +117,10 @@ def run( # type: ignore def run_ack_function_asynchronously(): nonlocal ack, request, response try: + self.listener_start_handler.handle( + request=request, + response=response, + ) listener.run_ack_function(request=request, response=response) except Exception as e: # The default response status code is 500 in this case. diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 3f785e181..0144027c3 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.9.4" +__version__ = "1.10.0a" From d5de9f72cd925311281d5f6feb76b9cb456bed38 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 4 Nov 2021 18:09:40 +0900 Subject: [PATCH 414/865] Upgrade black code formatter --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f2925072f..db728656d 100755 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ "pytest-cov>=3,<4", "Flask-Sockets>=0.2,<1", "Werkzeug<2", # TODO: support Flask 2.x - "black==21.9b0", + "black==21.10b0", ] async_test_dependencies = test_dependencies + [ From 77db11f2e44f1d054df0f60b748d220f1f8e7d4b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 4 Nov 2021 18:29:51 +0900 Subject: [PATCH 415/865] Improve the CI build settings (#516) --- .github/workflows/codecov.yml | 34 +++++++++++++++++++ .github/workflows/pytype.yml | 23 +++++++++++++ .github/workflows/{ci-build.yml => tests.yml} | 22 ++---------- 3 files changed, 60 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/codecov.yml create mode 100644 .github/workflows/pytype.yml rename .github/workflows/{ci-build.yml => tests.yml} (74%) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 000000000..37b87fc4c --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,34 @@ +name: Run codecov + +on: + push: + branches: [ main ] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + python-version: ['3.9'] + env: + # default: multiprocessing + # threading is more stable on GitHub Actions + BOLT_PYTHON_MOCK_SERVER_MODE: threading + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python setup.py install + pip install -U pip + pip install -e ".[async]" + pip install -e ".[adapter]" + pip install -e ".[testing]" + - name: Run all tests for codecov + run: | + pytest --cov=slack_bolt/ && bash <(curl -s https://codecov.io/bash) diff --git a/.github/workflows/pytype.yml b/.github/workflows/pytype.yml new file mode 100644 index 000000000..6acf4b2b5 --- /dev/null +++ b/.github/workflows/pytype.yml @@ -0,0 +1,23 @@ +name: Run pytype validation + +on: + push: + branches: [ main ] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + matrix: + python-version: ['3.8'] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Run pytype verification + run: | + ./scripts/run_pytype.sh diff --git a/.github/workflows/ci-build.yml b/.github/workflows/tests.yml similarity index 74% rename from .github/workflows/ci-build.yml rename to .github/workflows/tests.yml index e2f362e5f..c32fd1154 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/tests.yml @@ -1,16 +1,14 @@ -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions -name: CI Build +name: Run all the unit tests on: push: branches: [ main ] pull_request: - branches: [ main ] jobs: build: - runs-on: ubuntu-20.04 - timeout-minutes: 15 + runs-on: ubuntu-latest + timeout-minutes: 10 strategy: matrix: python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] @@ -68,17 +66,3 @@ jobs: run: | pip install -e ".[async]" pytest tests/adapter_tests_async/ - - name: Run pytype verification (3.8 only) - run: | - python_version=`python -V` - if [ ${python_version:7:3} == "3.8" ]; then - ./scripts/run_pytype.sh - fi - - name: Run all tests for codecov (3.9 only) - run: | - python_version=`python -V` - if [ ${python_version:7:3} == "3.9" ]; then - pip install -e ".[async]" - pip install -e ".[testing]" - pytest --cov=slack_bolt/ && bash <(curl -s https://codecov.io/bash) - fi From e411385083f9daec5f62d23b62add800c21eb831 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 4 Nov 2021 18:31:53 +0900 Subject: [PATCH 416/865] version 1.10.0 --- .../slack_bolt/adapter/django/handler.html | 144 +++++++-- docs/api-docs/slack_bolt/app/app.html | 7 + docs/api-docs/slack_bolt/app/async_app.html | 9 + .../async_listener_completion_handler.html | 13 +- .../async_listener_start_handler.html | 275 +++++++++++++++++ .../slack_bolt/listener/asyncio_runner.html | 34 +- docs/api-docs/slack_bolt/listener/index.html | 10 + .../listener/listener_completion_handler.html | 8 +- .../listener/listener_start_handler.html | 291 ++++++++++++++++++ .../slack_bolt/listener/thread_runner.html | 38 ++- docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 12 files changed, 794 insertions(+), 39 deletions(-) create mode 100644 docs/api-docs/slack_bolt/listener/async_listener_start_handler.html create mode 100644 docs/api-docs/slack_bolt/listener/listener_start_handler.html diff --git a/docs/api-docs/slack_bolt/adapter/django/handler.html b/docs/api-docs/slack_bolt/adapter/django/handler.html index 24e8cdf4b..1b47e3e35 100644 --- a/docs/api-docs/slack_bolt/adapter/django/handler.html +++ b/docs/api-docs/slack_bolt/adapter/django/handler.html @@ -37,6 +37,10 @@

    Module slack_bolt.adapter.django.handler

    from slack_bolt.error import BoltError from slack_bolt.lazy_listener import ThreadLazyListenerRunner from slack_bolt.lazy_listener.internals import build_runnable_function +from slack_bolt.listener.listener_start_handler import ( + ListenerStartHandler, + DefaultListenerStartHandler, +) from slack_bolt.listener.listener_completion_handler import ( ListenerCompletionHandler, DefaultListenerCompletionHandler, @@ -82,18 +86,29 @@

    Module slack_bolt.adapter.django.handler

    return resp -from django.db import connections +from django.db import close_old_connections -def release_thread_local_connections(logger: Logger, execution_type: str): - connections.close_all() +def release_thread_local_connections(logger: Logger, execution_timing: str): + close_old_connections() if logger.level <= logging.DEBUG: current: Thread = current_thread() logger.debug( - f"Released thread-bound DB connections (thread name: {current.name}, execution type: {execution_type})" + "Released thread-bound old DB connections " + f"(thread name: {current.name}, execution timing: {execution_timing})" ) +class DjangoListenerStartHandler(ListenerStartHandler): + """Django sets DB connections as a thread-local variable per thread. + If the thread is not managed on the Django app side, the connections won't be released by Django. + This handler releases the connections every time a ThreadListenerRunner execution completes. + """ + + def handle(self, request: BoltRequest, response: Optional[BoltResponse]) -> None: + release_thread_local_connections(request.context.logger, "listener-start") + + class DjangoListenerCompletionHandler(ListenerCompletionHandler): """Django sets DB connections as a thread-local variable per thread. If the thread is not managed on the Django app side, the connections won't be released by Django. @@ -101,7 +116,7 @@

    Module slack_bolt.adapter.django.handler

    """ def handle(self, request: BoltRequest, response: Optional[BoltResponse]) -> None: - release_thread_local_connections(request.context.logger, "listener") + release_thread_local_connections(request.context.logger, "listener-completion") class DjangoThreadLazyListenerRunner(ThreadLazyListenerRunner): @@ -113,11 +128,14 @@

    Module slack_bolt.adapter.django.handler

    ) def wrapped_func(): + release_thread_local_connections( + request.context.logger, "before-lazy-listener" + ) try: func() finally: release_thread_local_connections( - request.context.logger, "lazy-listener" + request.context.logger, "lazy-listener-completion" ) self.executor.submit(wrapped_func) @@ -144,18 +162,39 @@

    Module slack_bolt.adapter.django.handler

    self.app.logger.debug("App.process_before_response is set to True") return + current_start_handler = listener_runner.listener_start_handler + if current_start_handler is not None and not isinstance( + current_start_handler, DefaultListenerStartHandler + ): + # As we run release_thread_local_connections() before listener executions, + # it's okay to skip calling the same connection clean-up method at the listener completion. + message = """As you've already set app.listener_runner.listener_start_handler to your own one, + Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerStartHandler. + + If you go with your own handler here, we highly recommend having the following lines of code + in your handle() method to clean up unmanaged stale/old database connections: + + from django.db import close_old_connections + close_old_connections() + """ + self.app.logger.info(message) + else: + # for proper management of thread-local Django DB connections + self.app.listener_runner.listener_start_handler = ( + DjangoListenerStartHandler() + ) + self.app.logger.debug("DjangoListenerStartHandler has been enabled") + current_completion_handler = listener_runner.listener_completion_handler if current_completion_handler is not None and not isinstance( current_completion_handler, DefaultListenerCompletionHandler ): + # As we run release_thread_local_connections() before listener executions, + # it's okay to skip calling the same connection clean-up method at the listener completion. message = """As you've already set app.listener_runner.listener_completion_handler to your own one, Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerCompletionHandler. - We strongly recommend having the following lines of code in your listener_completion_handler: - - from django.db import connections - connections.close_all() """ - self.app.logger.warning(message) + self.app.logger.info(message) return # for proper management of thread-local Django DB connections self.app.listener_runner.listener_completion_handler = ( @@ -188,7 +227,7 @@

    Module slack_bolt.adapter.django.handler

    Functions

    -def release_thread_local_connections(logger: logging.Logger, execution_type: str) +def release_thread_local_connections(logger: logging.Logger, execution_timing: str)
    @@ -196,12 +235,13 @@

    Functions

    Expand source code -
    def release_thread_local_connections(logger: Logger, execution_type: str):
    -    connections.close_all()
    +
    def release_thread_local_connections(logger: Logger, execution_timing: str):
    +    close_old_connections()
         if logger.level <= logging.DEBUG:
             current: Thread = current_thread()
             logger.debug(
    -            f"Released thread-bound DB connections (thread name: {current.name}, execution type: {execution_type})"
    +            "Released thread-bound old DB connections "
    +            f"(thread name: {current.name}, execution timing: {execution_timing})"
             )
    @@ -281,7 +321,7 @@

    Classes

    """ def handle(self, request: BoltRequest, response: Optional[BoltResponse]) -> None: - release_thread_local_connections(request.context.logger, "listener")
    + release_thread_local_connections(request.context.logger, "listener-completion")

    Ancestors

      @@ -296,6 +336,39 @@

      Inherited members

    +
    +class DjangoListenerStartHandler +
    +
    +

    Django sets DB connections as a thread-local variable per thread. +If the thread is not managed on the Django app side, the connections won't be released by Django. +This handler releases the connections every time a ThreadListenerRunner execution completes.

    +
    + +Expand source code + +
    class DjangoListenerStartHandler(ListenerStartHandler):
    +    """Django sets DB connections as a thread-local variable per thread.
    +    If the thread is not managed on the Django app side, the connections won't be released by Django.
    +    This handler releases the connections every time a ThreadListenerRunner execution completes.
    +    """
    +
    +    def handle(self, request: BoltRequest, response: Optional[BoltResponse]) -> None:
    +        release_thread_local_connections(request.context.logger, "listener-start")
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    class DjangoThreadLazyListenerRunner (logger: logging.Logger, executor: concurrent.futures._base.Executor) @@ -315,11 +388,14 @@

    Inherited members

    ) def wrapped_func(): + release_thread_local_connections( + request.context.logger, "before-lazy-listener" + ) try: func() finally: release_thread_local_connections( - request.context.logger, "lazy-listener" + request.context.logger, "lazy-listener-completion" ) self.executor.submit(wrapped_func)
    @@ -377,18 +453,39 @@

    Inherited members

    self.app.logger.debug("App.process_before_response is set to True") return + current_start_handler = listener_runner.listener_start_handler + if current_start_handler is not None and not isinstance( + current_start_handler, DefaultListenerStartHandler + ): + # As we run release_thread_local_connections() before listener executions, + # it's okay to skip calling the same connection clean-up method at the listener completion. + message = """As you've already set app.listener_runner.listener_start_handler to your own one, + Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerStartHandler. + + If you go with your own handler here, we highly recommend having the following lines of code + in your handle() method to clean up unmanaged stale/old database connections: + + from django.db import close_old_connections + close_old_connections() + """ + self.app.logger.info(message) + else: + # for proper management of thread-local Django DB connections + self.app.listener_runner.listener_start_handler = ( + DjangoListenerStartHandler() + ) + self.app.logger.debug("DjangoListenerStartHandler has been enabled") + current_completion_handler = listener_runner.listener_completion_handler if current_completion_handler is not None and not isinstance( current_completion_handler, DefaultListenerCompletionHandler ): + # As we run release_thread_local_connections() before listener executions, + # it's okay to skip calling the same connection clean-up method at the listener completion. message = """As you've already set app.listener_runner.listener_completion_handler to your own one, Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerCompletionHandler. - We strongly recommend having the following lines of code in your listener_completion_handler: - - from django.db import connections - connections.close_all() """ - self.app.logger.warning(message) + self.app.logger.info(message) return # for proper management of thread-local Django DB connections self.app.listener_runner.listener_completion_handler = ( @@ -469,6 +566,9 @@

    Index

    DjangoListenerCompletionHandler

  • +

    DjangoListenerStartHandler

    +
  • +
  • DjangoThreadLazyListenerRunner

    • logger
    • diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index 70faf2c9d..35b39c817 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -51,6 +51,7 @@

      Module slack_bolt.app.app

      from slack_bolt.listener.builtins import TokenRevocationListeners from slack_bolt.listener.custom_listener import CustomListener from slack_bolt.listener.listener import Listener +from slack_bolt.listener.listener_start_handler import DefaultListenerStartHandler from slack_bolt.listener.listener_completion_handler import ( DefaultListenerCompletionHandler, ) @@ -345,6 +346,9 @@

      Module slack_bolt.app.app

      listener_error_handler=DefaultListenerErrorHandler( logger=self._framework_logger ), + listener_start_handler=DefaultListenerStartHandler( + logger=self._framework_logger + ), listener_completion_handler=DefaultListenerCompletionHandler( logger=self._framework_logger ), @@ -1807,6 +1811,9 @@

      Args

      listener_error_handler=DefaultListenerErrorHandler( logger=self._framework_logger ), + listener_start_handler=DefaultListenerStartHandler( + logger=self._framework_logger + ), listener_completion_handler=DefaultListenerCompletionHandler( logger=self._framework_logger ), diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index 0296dd263..2eb4079e2 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -36,6 +36,9 @@

      Module slack_bolt.app.async_app

      from slack_bolt.app.async_server import AsyncSlackAppServer from slack_bolt.listener.async_builtins import AsyncTokenRevocationListeners +from slack_bolt.listener.async_listener_start_handler import ( + AsyncDefaultListenerStartHandler, +) from slack_bolt.listener.async_listener_completion_handler import ( AsyncDefaultListenerCompletionHandler, ) @@ -365,6 +368,9 @@

      Module slack_bolt.app.async_app

      listener_error_handler=AsyncDefaultListenerErrorHandler( logger=self._framework_logger ), + listener_start_handler=AsyncDefaultListenerStartHandler( + logger=self._framework_logger + ), listener_completion_handler=AsyncDefaultListenerCompletionHandler( logger=self._framework_logger ), @@ -1753,6 +1759,9 @@

      Args

      listener_error_handler=AsyncDefaultListenerErrorHandler( logger=self._framework_logger ), + listener_start_handler=AsyncDefaultListenerStartHandler( + logger=self._framework_logger + ), listener_completion_handler=AsyncDefaultListenerCompletionHandler( logger=self._framework_logger ), diff --git a/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html b/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html index e123987db..0abf440e0 100644 --- a/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html +++ b/docs/api-docs/slack_bolt/listener/async_listener_completion_handler.html @@ -43,10 +43,9 @@

      Module slack_bolt.listener.async_listener_completion_han request: AsyncBoltRequest, response: Optional[BoltResponse], ) -> None: - """Handles an unhandled exception. + """Do something extra after the listener execution Args: - error: The raised exception. request: The request. response: The response. """ @@ -188,10 +187,9 @@

      Inherited members

      request: AsyncBoltRequest, response: Optional[BoltResponse], ) -> None: - """Handles an unhandled exception. + """Do something extra after the listener execution Args: - error: The raised exception. request: The request. response: The response. """ @@ -208,11 +206,9 @@

      Methods

      async def handle(self, request: AsyncBoltRequest, response: Optional[BoltResponse]) ‑> None

  • -

    Handles an unhandled exception.

    +

    Do something extra after the listener execution

    Args

    -
    error
    -
    The raised exception.
    request
    The request.
    response
    @@ -228,10 +224,9 @@

    Args

    request: AsyncBoltRequest, response: Optional[BoltResponse], ) -> None: - """Handles an unhandled exception. + """Do something extra after the listener execution Args: - error: The raised exception. request: The request. response: The response. """ diff --git a/docs/api-docs/slack_bolt/listener/async_listener_start_handler.html b/docs/api-docs/slack_bolt/listener/async_listener_start_handler.html new file mode 100644 index 000000000..5e324498c --- /dev/null +++ b/docs/api-docs/slack_bolt/listener/async_listener_start_handler.html @@ -0,0 +1,275 @@ + + + + + + +slack_bolt.listener.async_listener_start_handler API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.listener.async_listener_start_handler

    +
    +
    +
    + +Expand source code + +
    import inspect
    +from abc import ABCMeta, abstractmethod
    +from logging import Logger
    +from typing import Callable, Dict, Any, Awaitable, Optional
    +
    +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs
    +from slack_bolt.request.async_request import AsyncBoltRequest
    +from slack_bolt.response import BoltResponse
    +
    +
    +class AsyncListenerStartHandler(metaclass=ABCMeta):
    +    @abstractmethod
    +    async def handle(
    +        self,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        """Do something extra before the listener execution
    +
    +        Args:
    +            request: The request.
    +            response: The response.
    +        """
    +        raise NotImplementedError()
    +
    +
    +class AsyncCustomListenerStartHandler(AsyncListenerStartHandler):
    +    def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]):
    +        self.func = func
    +        self.logger = logger
    +        self.arg_names = inspect.getfullargspec(func).args
    +
    +    async def handle(
    +        self,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        kwargs: Dict[str, Any] = build_async_required_kwargs(
    +            required_arg_names=self.arg_names,
    +            logger=self.logger,
    +            request=request,
    +            response=response,
    +            next_keys_required=False,
    +        )
    +        await self.func(**kwargs)
    +
    +
    +class AsyncDefaultListenerStartHandler(AsyncListenerStartHandler):
    +    def __init__(self, logger: Logger):
    +        self.logger = logger
    +
    +    async def handle(
    +        self,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        pass
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class AsyncCustomListenerStartHandler +(logger: logging.Logger, func: Callable[..., Awaitable[None]]) +
    +
    +
    +
    + +Expand source code + +
    class AsyncCustomListenerStartHandler(AsyncListenerStartHandler):
    +    def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]):
    +        self.func = func
    +        self.logger = logger
    +        self.arg_names = inspect.getfullargspec(func).args
    +
    +    async def handle(
    +        self,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        kwargs: Dict[str, Any] = build_async_required_kwargs(
    +            required_arg_names=self.arg_names,
    +            logger=self.logger,
    +            request=request,
    +            response=response,
    +            next_keys_required=False,
    +        )
    +        await self.func(**kwargs)
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    +
    +class AsyncDefaultListenerStartHandler +(logger: logging.Logger) +
    +
    +
    +
    + +Expand source code + +
    class AsyncDefaultListenerStartHandler(AsyncListenerStartHandler):
    +    def __init__(self, logger: Logger):
    +        self.logger = logger
    +
    +    async def handle(
    +        self,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        pass
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    +
    +class AsyncListenerStartHandler +
    +
    +
    +
    + +Expand source code + +
    class AsyncListenerStartHandler(metaclass=ABCMeta):
    +    @abstractmethod
    +    async def handle(
    +        self,
    +        request: AsyncBoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        """Do something extra before the listener execution
    +
    +        Args:
    +            request: The request.
    +            response: The response.
    +        """
    +        raise NotImplementedError()
    +
    +

    Subclasses

    + +

    Methods

    +
    +
    +async def handle(self, request: AsyncBoltRequest, response: Optional[BoltResponse]) ‑> None +
    +
    +

    Do something extra before the listener execution

    +

    Args

    +
    +
    request
    +
    The request.
    +
    response
    +
    The response.
    +
    +
    + +Expand source code + +
    @abstractmethod
    +async def handle(
    +    self,
    +    request: AsyncBoltRequest,
    +    response: Optional[BoltResponse],
    +) -> None:
    +    """Do something extra before the listener execution
    +
    +    Args:
    +        request: The request.
    +        response: The response.
    +    """
    +    raise NotImplementedError()
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/asyncio_runner.html b/docs/api-docs/slack_bolt/listener/asyncio_runner.html index 55b1969fa..7dc21f332 100644 --- a/docs/api-docs/slack_bolt/listener/asyncio_runner.html +++ b/docs/api-docs/slack_bolt/listener/asyncio_runner.html @@ -35,6 +35,9 @@

    Module slack_bolt.listener.asyncio_runner

    from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.lazy_listener.async_runner import AsyncLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener +from slack_bolt.listener.async_listener_start_handler import ( + AsyncListenerStartHandler, +) from slack_bolt.listener.async_listener_completion_handler import ( AsyncListenerCompletionHandler, ) @@ -53,6 +56,7 @@

    Module slack_bolt.listener.asyncio_runner

    logger: Logger process_before_response: bool listener_error_handler: AsyncListenerErrorHandler + listener_start_handler: AsyncListenerStartHandler listener_completion_handler: AsyncListenerCompletionHandler lazy_listener_runner: AsyncLazyListenerRunner @@ -61,12 +65,14 @@

    Module slack_bolt.listener.asyncio_runner

    logger: Logger, process_before_response: bool, listener_error_handler: AsyncListenerErrorHandler, + listener_start_handler: AsyncListenerStartHandler, listener_completion_handler: AsyncListenerCompletionHandler, lazy_listener_runner: AsyncLazyListenerRunner, ): self.logger = logger self.process_before_response = process_before_response self.listener_error_handler = listener_error_handler + self.listener_start_handler = listener_start_handler self.listener_completion_handler = listener_completion_handler self.lazy_listener_runner = lazy_listener_runner @@ -83,6 +89,9 @@

    Module slack_bolt.listener.asyncio_runner

    if self.process_before_response: if not request.lazy_only: try: + await self.listener_start_handler.handle( + request=request, response=response + ) returned_value = await listener.run_ack_function( request=request, response=response ) @@ -141,6 +150,9 @@

    Module slack_bolt.listener.asyncio_runner

    response: BoltResponse, ): try: + await self.listener_start_handler.handle( + request=request, response=response + ) await listener.run_ack_function( request=request, response=response ) @@ -238,7 +250,7 @@

    Classes

    class AsyncioListenerRunner -(logger: logging.Logger, process_before_response: bool, listener_error_handler: AsyncListenerErrorHandler, listener_completion_handler: AsyncListenerCompletionHandler, lazy_listener_runner: AsyncLazyListenerRunner) +(logger: logging.Logger, process_before_response: bool, listener_error_handler: AsyncListenerErrorHandler, listener_start_handler: AsyncListenerStartHandler, listener_completion_handler: AsyncListenerCompletionHandler, lazy_listener_runner: AsyncLazyListenerRunner)
    @@ -250,6 +262,7 @@

    Classes

    logger: Logger process_before_response: bool listener_error_handler: AsyncListenerErrorHandler + listener_start_handler: AsyncListenerStartHandler listener_completion_handler: AsyncListenerCompletionHandler lazy_listener_runner: AsyncLazyListenerRunner @@ -258,12 +271,14 @@

    Classes

    logger: Logger, process_before_response: bool, listener_error_handler: AsyncListenerErrorHandler, + listener_start_handler: AsyncListenerStartHandler, listener_completion_handler: AsyncListenerCompletionHandler, lazy_listener_runner: AsyncLazyListenerRunner, ): self.logger = logger self.process_before_response = process_before_response self.listener_error_handler = listener_error_handler + self.listener_start_handler = listener_start_handler self.listener_completion_handler = listener_completion_handler self.lazy_listener_runner = lazy_listener_runner @@ -280,6 +295,9 @@

    Classes

    if self.process_before_response: if not request.lazy_only: try: + await self.listener_start_handler.handle( + request=request, response=response + ) returned_value = await listener.run_ack_function( request=request, response=response ) @@ -338,6 +356,9 @@

    Classes

    response: BoltResponse, ): try: + await self.listener_start_handler.handle( + request=request, response=response + ) await listener.run_ack_function( request=request, response=response ) @@ -437,6 +458,10 @@

    Class variables

    +
    var listener_start_handlerAsyncListenerStartHandler
    +
    +
    +
    var logger : logging.Logger
    @@ -470,6 +495,9 @@

    Methods

    if self.process_before_response: if not request.lazy_only: try: + await self.listener_start_handler.handle( + request=request, response=response + ) returned_value = await listener.run_ack_function( request=request, response=response ) @@ -528,6 +556,9 @@

    Methods

    response: BoltResponse, ): try: + await self.listener_start_handler.handle( + request=request, response=response + ) await listener.run_ack_function( request=request, response=response ) @@ -613,6 +644,7 @@

    lazy_listener_runner
  • listener_completion_handler
  • listener_error_handler
  • +
  • listener_start_handler
  • logger
  • process_before_response
  • run
  • diff --git a/docs/api-docs/slack_bolt/listener/index.html b/docs/api-docs/slack_bolt/listener/index.html index f0deb9b00..3b9a557ff 100644 --- a/docs/api-docs/slack_bolt/listener/index.html +++ b/docs/api-docs/slack_bolt/listener/index.html @@ -65,6 +65,10 @@

    Sub-modules

    +
    slack_bolt.listener.async_listener_start_handler
    +
    +
    +
    slack_bolt.listener.asyncio_runner
    @@ -89,6 +93,10 @@

    Sub-modules

    +
    slack_bolt.listener.listener_start_handler
    +
    +
    +
    slack_bolt.listener.thread_runner
    @@ -119,12 +127,14 @@

    Index

  • slack_bolt.listener.async_listener
  • slack_bolt.listener.async_listener_completion_handler
  • slack_bolt.listener.async_listener_error_handler
  • +
  • slack_bolt.listener.async_listener_start_handler
  • slack_bolt.listener.asyncio_runner
  • slack_bolt.listener.builtins
  • slack_bolt.listener.custom_listener
  • slack_bolt.listener.listener
  • slack_bolt.listener.listener_completion_handler
  • slack_bolt.listener.listener_error_handler
  • +
  • slack_bolt.listener.listener_start_handler
  • slack_bolt.listener.thread_runner
  • diff --git a/docs/api-docs/slack_bolt/listener/listener_completion_handler.html b/docs/api-docs/slack_bolt/listener/listener_completion_handler.html index f5269d4e3..c24fd0f90 100644 --- a/docs/api-docs/slack_bolt/listener/listener_completion_handler.html +++ b/docs/api-docs/slack_bolt/listener/listener_completion_handler.html @@ -43,7 +43,7 @@

    Module slack_bolt.listener.listener_completion_handlerInherited members

    request: BoltRequest, response: Optional[BoltResponse], ) -> None: - """Handles an unhandled exception. + """Do something extra after the listener execution Args: request: The request. @@ -207,7 +207,7 @@

    Methods

    def handle(self, request: BoltRequest, response: Optional[BoltResponse]) ‑> None
    -

    Handles an unhandled exception.

    +

    Do something extra after the listener execution

    Args

    request
    @@ -225,7 +225,7 @@

    Args

    request: BoltRequest, response: Optional[BoltResponse], ) -> None: - """Handles an unhandled exception. + """Do something extra after the listener execution Args: request: The request. diff --git a/docs/api-docs/slack_bolt/listener/listener_start_handler.html b/docs/api-docs/slack_bolt/listener/listener_start_handler.html new file mode 100644 index 000000000..b2371ae67 --- /dev/null +++ b/docs/api-docs/slack_bolt/listener/listener_start_handler.html @@ -0,0 +1,291 @@ + + + + + + +slack_bolt.listener.listener_start_handler API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.listener.listener_start_handler

    +
    +
    +
    + +Expand source code + +
    import inspect
    +from abc import ABCMeta, abstractmethod
    +from logging import Logger
    +from typing import Callable, Dict, Any, Optional
    +
    +from slack_bolt.kwargs_injection import build_required_kwargs
    +from slack_bolt.request.request import BoltRequest
    +from slack_bolt.response.response import BoltResponse
    +
    +
    +class ListenerStartHandler(metaclass=ABCMeta):
    +    @abstractmethod
    +    def handle(
    +        self,
    +        request: BoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        """Do something extra before the listener execution.
    +
    +        This handler is useful if a developer needs to maintain/clean up
    +        thread-local resources such as Django ORM database connections
    +        before a listener execution starts.
    +
    +        Args:
    +            request: The request.
    +            response: The response.
    +        """
    +        raise NotImplementedError()
    +
    +
    +class CustomListenerStartHandler(ListenerStartHandler):
    +    def __init__(self, logger: Logger, func: Callable[..., None]):
    +        self.func = func
    +        self.logger = logger
    +        self.arg_names = inspect.getfullargspec(func).args
    +
    +    def handle(
    +        self,
    +        request: BoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        kwargs: Dict[str, Any] = build_required_kwargs(
    +            required_arg_names=self.arg_names,
    +            logger=self.logger,
    +            request=request,
    +            response=response,
    +            next_keys_required=False,
    +        )
    +        self.func(**kwargs)
    +
    +
    +class DefaultListenerStartHandler(ListenerStartHandler):
    +    def __init__(self, logger: Logger):
    +        self.logger = logger
    +
    +    def handle(
    +        self,
    +        request: BoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        pass
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class CustomListenerStartHandler +(logger: logging.Logger, func: Callable[..., None]) +
    +
    +
    +
    + +Expand source code + +
    class CustomListenerStartHandler(ListenerStartHandler):
    +    def __init__(self, logger: Logger, func: Callable[..., None]):
    +        self.func = func
    +        self.logger = logger
    +        self.arg_names = inspect.getfullargspec(func).args
    +
    +    def handle(
    +        self,
    +        request: BoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        kwargs: Dict[str, Any] = build_required_kwargs(
    +            required_arg_names=self.arg_names,
    +            logger=self.logger,
    +            request=request,
    +            response=response,
    +            next_keys_required=False,
    +        )
    +        self.func(**kwargs)
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    +
    +class DefaultListenerStartHandler +(logger: logging.Logger) +
    +
    +
    +
    + +Expand source code + +
    class DefaultListenerStartHandler(ListenerStartHandler):
    +    def __init__(self, logger: Logger):
    +        self.logger = logger
    +
    +    def handle(
    +        self,
    +        request: BoltRequest,
    +        response: Optional[BoltResponse],
    +    ):
    +        pass
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    +
    +class ListenerStartHandler +
    +
    +
    +
    + +Expand source code + +
    class ListenerStartHandler(metaclass=ABCMeta):
    +    @abstractmethod
    +    def handle(
    +        self,
    +        request: BoltRequest,
    +        response: Optional[BoltResponse],
    +    ) -> None:
    +        """Do something extra before the listener execution.
    +
    +        This handler is useful if a developer needs to maintain/clean up
    +        thread-local resources such as Django ORM database connections
    +        before a listener execution starts.
    +
    +        Args:
    +            request: The request.
    +            response: The response.
    +        """
    +        raise NotImplementedError()
    +
    +

    Subclasses

    + +

    Methods

    +
    +
    +def handle(self, request: BoltRequest, response: Optional[BoltResponse]) ‑> None +
    +
    +

    Do something extra before the listener execution.

    +

    This handler is useful if a developer needs to maintain/clean up +thread-local resources such as Django ORM database connections +before a listener execution starts.

    +

    Args

    +
    +
    request
    +
    The request.
    +
    response
    +
    The response.
    +
    +
    + +Expand source code + +
    @abstractmethod
    +def handle(
    +    self,
    +    request: BoltRequest,
    +    response: Optional[BoltResponse],
    +) -> None:
    +    """Do something extra before the listener execution.
    +
    +    This handler is useful if a developer needs to maintain/clean up
    +    thread-local resources such as Django ORM database connections
    +    before a listener execution starts.
    +
    +    Args:
    +        request: The request.
    +        response: The response.
    +    """
    +    raise NotImplementedError()
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/listener/thread_runner.html b/docs/api-docs/slack_bolt/listener/thread_runner.html index 0105a89f7..43c7da783 100644 --- a/docs/api-docs/slack_bolt/listener/thread_runner.html +++ b/docs/api-docs/slack_bolt/listener/thread_runner.html @@ -33,6 +33,7 @@

    Module slack_bolt.listener.thread_runner

    from slack_bolt.lazy_listener import LazyListenerRunner from slack_bolt.listener import Listener +from slack_bolt.listener.listener_start_handler import ListenerStartHandler from slack_bolt.listener.listener_completion_handler import ListenerCompletionHandler from slack_bolt.listener.listener_error_handler import ListenerErrorHandler from slack_bolt.logger.messages import ( @@ -49,6 +50,7 @@

    Module slack_bolt.listener.thread_runner

    logger: Logger process_before_response: bool listener_error_handler: ListenerErrorHandler + listener_start_handler: ListenerStartHandler listener_completion_handler: ListenerCompletionHandler listener_executor: Executor lazy_listener_runner: LazyListenerRunner @@ -58,6 +60,7 @@

    Module slack_bolt.listener.thread_runner

    logger: Logger, process_before_response: bool, listener_error_handler: ListenerErrorHandler, + listener_start_handler: ListenerStartHandler, listener_completion_handler: ListenerCompletionHandler, listener_executor: Executor, lazy_listener_runner: LazyListenerRunner, @@ -65,6 +68,7 @@

    Module slack_bolt.listener.thread_runner

    self.logger = logger self.process_before_response = process_before_response self.listener_error_handler = listener_error_handler + self.listener_start_handler = listener_start_handler self.listener_completion_handler = listener_completion_handler self.listener_executor = listener_executor self.lazy_listener_runner = lazy_listener_runner @@ -82,6 +86,10 @@

    Module slack_bolt.listener.thread_runner

    if self.process_before_response: if not request.lazy_only: try: + self.listener_start_handler.handle( + request=request, + response=response, + ) returned_value = listener.run_ack_function( request=request, response=response ) @@ -137,6 +145,10 @@

    Module slack_bolt.listener.thread_runner

    def run_ack_function_asynchronously(): nonlocal ack, request, response try: + self.listener_start_handler.handle( + request=request, + response=response, + ) listener.run_ack_function(request=request, response=response) except Exception as e: # The default response status code is 500 in this case. @@ -235,7 +247,7 @@

    Classes

    class ThreadListenerRunner -(logger: logging.Logger, process_before_response: bool, listener_error_handler: ListenerErrorHandler, listener_completion_handler: ListenerCompletionHandler, listener_executor: concurrent.futures._base.Executor, lazy_listener_runner: LazyListenerRunner) +(logger: logging.Logger, process_before_response: bool, listener_error_handler: ListenerErrorHandler, listener_start_handler: ListenerStartHandler, listener_completion_handler: ListenerCompletionHandler, listener_executor: concurrent.futures._base.Executor, lazy_listener_runner: LazyListenerRunner)
    @@ -247,6 +259,7 @@

    Classes

    logger: Logger process_before_response: bool listener_error_handler: ListenerErrorHandler + listener_start_handler: ListenerStartHandler listener_completion_handler: ListenerCompletionHandler listener_executor: Executor lazy_listener_runner: LazyListenerRunner @@ -256,6 +269,7 @@

    Classes

    logger: Logger, process_before_response: bool, listener_error_handler: ListenerErrorHandler, + listener_start_handler: ListenerStartHandler, listener_completion_handler: ListenerCompletionHandler, listener_executor: Executor, lazy_listener_runner: LazyListenerRunner, @@ -263,6 +277,7 @@

    Classes

    self.logger = logger self.process_before_response = process_before_response self.listener_error_handler = listener_error_handler + self.listener_start_handler = listener_start_handler self.listener_completion_handler = listener_completion_handler self.listener_executor = listener_executor self.lazy_listener_runner = lazy_listener_runner @@ -280,6 +295,10 @@

    Classes

    if self.process_before_response: if not request.lazy_only: try: + self.listener_start_handler.handle( + request=request, + response=response, + ) returned_value = listener.run_ack_function( request=request, response=response ) @@ -335,6 +354,10 @@

    Classes

    def run_ack_function_asynchronously(): nonlocal ack, request, response try: + self.listener_start_handler.handle( + request=request, + response=response, + ) listener.run_ack_function(request=request, response=response) except Exception as e: # The default response status code is 500 in this case. @@ -439,6 +462,10 @@

    Class variables

    +
    var listener_start_handlerListenerStartHandler
    +
    +
    +
    var logger : logging.Logger
    @@ -472,6 +499,10 @@

    Methods

    if self.process_before_response: if not request.lazy_only: try: + self.listener_start_handler.handle( + request=request, + response=response, + ) returned_value = listener.run_ack_function( request=request, response=response ) @@ -527,6 +558,10 @@

    Methods

    def run_ack_function_asynchronously(): nonlocal ack, request, response try: + self.listener_start_handler.handle( + request=request, + response=response, + ) listener.run_ack_function(request=request, response=response) except Exception as e: # The default response status code is 500 in this case. @@ -616,6 +651,7 @@

    listener_completion_handler
  • listener_error_handler
  • listener_executor
  • +
  • listener_start_handler
  • logger
  • process_before_response
  • run
  • diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index dfb472789..816a33b11 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.9.4"
    +__version__ = "1.10.0"

    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 0144027c3..0872b5816 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.10.0a" +__version__ = "1.10.0" From c4d60dccfddc1c491f04e881cc07d2e6403ad6e7 Mon Sep 17 00:00:00 2001 From: TORIFUKUKaiou Date: Mon, 8 Nov 2021 06:06:07 +0900 Subject: [PATCH 417/865] Signing Secret can be gotten from the Basic Information page (#518) (#519) --- docs/_tutorials/ja_getting_started_http.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_tutorials/ja_getting_started_http.md b/docs/_tutorials/ja_getting_started_http.md index 8d90c8921..13088add4 100644 --- a/docs/_tutorials/ja_getting_started_http.md +++ b/docs/_tutorials/ja_getting_started_http.md @@ -85,7 +85,7 @@ which python3 Bolt for Python のパッケージを新しいプロジェクトにインストールする前に、アプリの設定時に作成された **ボットトークン** と **署名シークレット** を保存しましょう。 -1. **OAuth & Permissions ページの署名シークレットをコピー**して、新しい環境変数に保存します。以下のコマンド例は Linux と macOS で利用できます。[Windows でもこれに似たコマンドが利用できます](https://superuser.com/questions/212150/how-to-set-env-variable-in-windows-cmd-line/212153#212153)。 +1. **Basic Information ページの署名シークレットをコピー**して、新しい環境変数に保存します。以下のコマンド例は Linux と macOS で利用できます。[Windows でもこれに似たコマンドが利用できます](https://superuser.com/questions/212150/how-to-set-env-variable-in-windows-cmd-line/212153#212153)。 ```shell export SLACK_SIGNING_SECRET= ``` From 1b5f4a15a679e47e4d343bd93eab6c62ea09ae47 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 12 Nov 2021 13:18:58 +0900 Subject: [PATCH 418/865] Fix a missing import in AWS Lambda code example --- examples/aws_lambda/lazy_aws_lambda.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/aws_lambda/lazy_aws_lambda.py b/examples/aws_lambda/lazy_aws_lambda.py index 82ef3ae98..dcc47498f 100644 --- a/examples/aws_lambda/lazy_aws_lambda.py +++ b/examples/aws_lambda/lazy_aws_lambda.py @@ -1,4 +1,5 @@ import logging +import time from slack_bolt import App from slack_bolt.adapter.aws_lambda import SlackRequestHandler From 068749aa06d77b6b1e575c0bf58da5980238219e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 15 Nov 2021 18:29:32 +0900 Subject: [PATCH 419/865] Improve the Django app example to be more robust (#523) --- examples/django/oauth_app/slack_datastores.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/django/oauth_app/slack_datastores.py b/examples/django/oauth_app/slack_datastores.py index a18bb4899..5e226e8ad 100644 --- a/examples/django/oauth_app/slack_datastores.py +++ b/examples/django/oauth_app/slack_datastores.py @@ -33,9 +33,13 @@ def save(self, installation: Installation): i = installation.to_dict() if is_naive(i["installed_at"]): i["installed_at"] = make_aware(i["installed_at"]) - if "bot_token_expires_at" in i and is_naive(i["bot_token_expires_at"]): + if i.get("bot_token_expires_at") is not None and is_naive( + i["bot_token_expires_at"] + ): i["bot_token_expires_at"] = make_aware(i["bot_token_expires_at"]) - if "user_token_expires_at" in i and is_naive(i["user_token_expires_at"]): + if i.get("user_token_expires_at") is not None and is_naive( + i["user_token_expires_at"] + ): i["user_token_expires_at"] = make_aware(i["user_token_expires_at"]) i["client_id"] = self.client_id row_to_update = ( @@ -58,7 +62,7 @@ def save_bot(self, bot: Bot): b = bot.to_dict() if is_naive(b["installed_at"]): b["installed_at"] = make_aware(b["installed_at"]) - if "bot_token_expires_at" in b is not None and is_naive( + if b.get("bot_token_expires_at") is not None and is_naive( b["bot_token_expires_at"] ): b["bot_token_expires_at"] = make_aware(b["bot_token_expires_at"]) From 68ac4fe8c2d99ac3d765719ee3d0349cdd9f318d Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Mon, 15 Nov 2021 20:59:11 -0800 Subject: [PATCH 420/865] Update lazy lambda docs (#524) * Clarify lazy listener description * Update acknowledging requests --- docs/_advanced/lazy_listener.md | 12 ++++++------ ...owledging_events.md => acknowledging_requests.md} | 7 ++++--- 2 files changed, 10 insertions(+), 9 deletions(-) rename docs/_basic/{acknowledging_events.md => acknowledging_requests.md} (52%) diff --git a/docs/_advanced/lazy_listener.md b/docs/_advanced/lazy_listener.md index 15f81a216..a7058085b 100644 --- a/docs/_advanced/lazy_listener.md +++ b/docs/_advanced/lazy_listener.md @@ -6,15 +6,15 @@ order: 10 ---
    -⚠️ Lazy listener functions are a beta feature to make it easier to deploy Bolt for Python apps to FaaS environments. As the feature is developed, Bolt for Python's API is subject to change. +Lazy Listeners are a feature which make it easier to deploy Slack apps to FaaS (Function-as-a-Service) environments. Please note that this feature is only available in Bolt for Python, and we are not planning to add the same to other Bolt frameworks. -Typically you'd call `ack()` as the first step of your listener functions. Calling `ack()` tells Slack that you've received the request and are handling it in within reasonable amount of time (3 seconds). +Typically when handling actions, commands, shortcuts, options and view submissions, you must acknowledge the request from Slack by calling `ack()` within 3 seconds. Calling `ack()` results in sending an HTTP 200 OK response to Slack, letting Slack know that you're handling the response. We normally encourage you to do this as the very first step in your handler function. -However, apps running on FaaS or similar runtimes that don't allow you to run threads or processes after returning an HTTP response cannot follow this pattern. Instead, you should set the `process_before_response` flag to `True`. This allows you to create a listener that calls `ack()` and handles the request safely, though you still need to complete everything within 3 seconds. For events, while a listener doesn't need `ack()` method call as you normally would, the listener needs to complete within 3 seconds, too. +However, when running your app on FaaS or similar runtimes which **do not allow you to run threads or processes after returning an HTTP response**, we cannot follow the typical pattern of acknowledgement first, processing later. To work with these runtimes, set the `process_before_response` flag to `True`. When this flag is true, the Bolt framework holds off sending an HTTP response until all the things in a listener function are done. You need to complete your processing within 3 seconds or you will run into errors with Slack timeouts. Note that in the case of events, while the listener doesn't need to explicitly call the `ack()` method, it still needs to complete its function within 3 seconds as well. -Lazy listeners can be a solution for this issue. Rather than acting as a decorator, lazy listeners take two keyword args: -* `ack: Callable`: Responsible for calling `ack()` -* `lazy: List[Callable]`: Responsible for handling any time-consuming processes related to the request. The lazy function does not have access to `ack()`. +To allow you to still run more time-consuming processes as part of your handler, we've added a lazy listener function mechanism. Rather than acting as a decorator, a lazy listener accepts two keyword args: +* `ack: Callable`: Responsible for calling `ack()` within 3 seconds +* `lazy: List[Callable]`: Responsible for handling time-consuming processes related to the request. The lazy function does not have access to `ack()`.
    ```python diff --git a/docs/_basic/acknowledging_events.md b/docs/_basic/acknowledging_requests.md similarity index 52% rename from docs/_basic/acknowledging_events.md rename to docs/_basic/acknowledging_requests.md index 67c767f21..2f3f6c466 100644 --- a/docs/_basic/acknowledging_events.md +++ b/docs/_basic/acknowledging_requests.md @@ -7,12 +7,13 @@ order: 7
    -Actions, commands, and options requests must **always** be acknowledged using the `ack()` function. This lets Slack know that the request was received and updates the Slack user interface accordingly. +Actions, commands, shortcuts, options requests, and view submissions must **always** be acknowledged using the `ack()` function. This lets Slack know that the request was received so that it may update the Slack user interface accordingly. -Depending on the type of request, your acknowledgement may be different. For example, when acknowledging a menu selection associated with an external data source, you would call `ack()` with a list of relevant [options](https://api.slack.com/reference/block-kit/composition-objects#option). +Depending on the type of request, your acknowledgement may be different. For example, when acknowledging a menu selection associated with an external data source, you would call `ack()` with a list of relevant [options](https://api.slack.com/reference/block-kit/composition-objects#option). When acknowledging a view submission, you may supply a `response_action` as part of your acknowledge to update the view. Please see the relevant sections of the docs for more detail on options for `ack()` these different requests. -We recommend calling `ack()` right away before sending a new message or fetching information from your database since you only have 3 seconds to respond. +We recommend calling `ack()` right away before initiating any time-consuming processes such as fetching information from your database or sending a new message, since you only have 3 seconds to respond before Slack registers a timeout error. +💡 When working in a FaaS / serverless environment, our guidelines for when to `ack()` are different. See the section on **Lazy listeners (FaaS)** for more detail on this.
    From 8cb660c77c6637808394eff541cf0da9c6f6d6b5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 26 Nov 2021 12:07:41 +0900 Subject: [PATCH 421/865] Update code in docs to be more consistent --- docs/_advanced/adapters.md | 2 +- docs/_advanced/ja_adapters.md | 2 +- docs/_basic/authenticating_oauth.md | 2 +- docs/_basic/ja_authenticating_oauth.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/_advanced/adapters.md b/docs/_advanced/adapters.md index 5797d41e0..c811fadef 100644 --- a/docs/_advanced/adapters.md +++ b/docs/_advanced/adapters.md @@ -18,7 +18,7 @@ The full list adapters, as well as configuration and sample usage, can be found ```python from slack_bolt import App app = App( - signing_secret=os.environ.get("SIGNING_SECRET"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), token=os.environ.get("SLACK_BOT_TOKEN") ) diff --git a/docs/_advanced/ja_adapters.md b/docs/_advanced/ja_adapters.md index 54aa42eb7..cdd840288 100644 --- a/docs/_advanced/ja_adapters.md +++ b/docs/_advanced/ja_adapters.md @@ -18,7 +18,7 @@ order: 0 ```python from slack_bolt import App app = App( - signing_secret=os.environ.get("SIGNING_SECRET"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), token=os.environ.get("SLACK_BOT_TOKEN") ) diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md index fa1db0873..67a0fe79e 100644 --- a/docs/_basic/authenticating_oauth.md +++ b/docs/_basic/authenticating_oauth.md @@ -35,7 +35,7 @@ oauth_settings = OAuthSettings( ) app = App( - signing_secret=os.environ["SIGNING_SECRET"], + signing_secret=os.environ["SLACK_SIGNING_SECRET"], oauth_settings=oauth_settings ) ``` diff --git a/docs/_basic/ja_authenticating_oauth.md b/docs/_basic/ja_authenticating_oauth.md index a10c87399..4ce955570 100644 --- a/docs/_basic/ja_authenticating_oauth.md +++ b/docs/_basic/ja_authenticating_oauth.md @@ -35,7 +35,7 @@ oauth_settings = OAuthSettings( ) app = App( - signing_secret=os.environ["SIGNING_SECRET"], + signing_secret=os.environ["SLACK_SIGNING_SECRET"], oauth_settings=oauth_settings ) ``` From a6bdd3f343313bb0745a7f84dbd4bf674f5ca55e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 26 Nov 2021 12:33:29 +0900 Subject: [PATCH 422/865] Improve the OAuth code examples --- docs/_basic/authenticating_oauth.md | 8 ++++---- docs/_basic/ja_authenticating_oauth.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md index 67a0fe79e..ab42beefc 100644 --- a/docs/_basic/authenticating_oauth.md +++ b/docs/_basic/authenticating_oauth.md @@ -30,8 +30,8 @@ oauth_settings = OAuthSettings( client_id=os.environ["SLACK_CLIENT_ID"], client_secret=os.environ["SLACK_CLIENT_SECRET"], scopes=["channels:read", "groups:read", "chat:write"], - installation_store=FileInstallationStore(base_dir="./data"), - state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data") + installation_store=FileInstallationStore(base_dir="./data/installations"), + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data/states") ) app = App( @@ -85,7 +85,7 @@ from slack_sdk.oauth.state_store import FileOAuthStateStore app = App( signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), - installation_store=FileInstallationStore(base_dir="./data"), + installation_store=FileInstallationStore(base_dir="./data/installations"), oauth_settings=OAuthSettings( client_id=os.environ.get("SLACK_CLIENT_ID"), client_secret=os.environ.get("SLACK_CLIENT_SECRET"), @@ -94,7 +94,7 @@ app = App( redirect_uri=None, install_path="/slack/install", redirect_uri_path="/slack/oauth_redirect", - state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data"), + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data/states"), callback_options=callback_options, ), ) diff --git a/docs/_basic/ja_authenticating_oauth.md b/docs/_basic/ja_authenticating_oauth.md index 4ce955570..2aca09500 100644 --- a/docs/_basic/ja_authenticating_oauth.md +++ b/docs/_basic/ja_authenticating_oauth.md @@ -30,8 +30,8 @@ oauth_settings = OAuthSettings( client_id=os.environ["SLACK_CLIENT_ID"], client_secret=os.environ["SLACK_CLIENT_SECRET"], scopes=["channels:read", "groups:read", "chat:write"], - installation_store=FileInstallationStore(base_dir="./data"), - state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data") + installation_store=FileInstallationStore(base_dir="./data/installations"), + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data/states") ) app = App( @@ -85,7 +85,7 @@ from slack_sdk.oauth.state_store import FileOAuthStateStore app = App( signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), - installation_store=FileInstallationStore(base_dir="./data"), + installation_store=FileInstallationStore(base_dir="./data/installations"), oauth_settings=OAuthSettings( client_id=os.environ.get("SLACK_CLIENT_ID"), client_secret=os.environ.get("SLACK_CLIENT_SECRET"), @@ -94,7 +94,7 @@ app = App( redirect_uri=None, install_path="/slack/install", redirect_uri_path="/slack/oauth_redirect", - state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data"), + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data/states"), callback_options=callback_options, ), ) From 0b8db0bfbc452c392c55b76de02abcfa1e314b69 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Mon, 29 Nov 2021 13:29:33 -0800 Subject: [PATCH 423/865] Add github acgtion (#528) --- .github/workflows/triage-issues.yml | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/triage-issues.yml diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml new file mode 100644 index 000000000..8619c6e32 --- /dev/null +++ b/.github/workflows/triage-issues.yml @@ -0,0 +1,33 @@ +# This workflow uses the following github action to automate +# management of stale issues and prs in this repo: +# https://github.com/marketplace/actions/close-stale-issues + +name: Close stale issues and PRs + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 0' + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v4.0.0 + with: + days-before-issue-stale: 30 + days-before-issue-close: 10 + days-before-pr-stale: -1 + days-before-pr-close: -1 + stale-issue-label: auto-triage-stale + stale-issue-message: 👋 It looks like this issue has been open for 30 days with no activity. We'll mark this as stale for now, and wait 10 days for an update or for further comment before closing this issue out. + close-issue-message: As this issue has been inactive for more than one month, we will be closing it. Thank you to all the participants! If you would like to raise a related issue, please create a new issue which includes your specific details and references this issue number. + exempt-issue-labels: auto-triage-skip + exempt-all-milestones: true + remove-stale-when-updated: true + enable-statistics: true + operations-per-run: 60 \ No newline at end of file From f81ee5210f1060e85704879b27d3d0b3d74ed0e6 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Tue, 7 Dec 2021 03:40:43 -0800 Subject: [PATCH 424/865] Update (#536) --- .github/workflows/triage-issues.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index 8619c6e32..cf6864ab3 100644 --- a/.github/workflows/triage-issues.yml +++ b/.github/workflows/triage-issues.yml @@ -7,7 +7,7 @@ name: Close stale issues and PRs on: workflow_dispatch: schedule: - - cron: '0 0 * * 0' + - cron: '0 0 * * 1' permissions: issues: write @@ -24,7 +24,7 @@ jobs: days-before-pr-stale: -1 days-before-pr-close: -1 stale-issue-label: auto-triage-stale - stale-issue-message: 👋 It looks like this issue has been open for 30 days with no activity. We'll mark this as stale for now, and wait 10 days for an update or for further comment before closing this issue out. + stale-issue-message: 👋 It looks like this issue has been open for 30 days with no activity. We'll mark this as stale for now, and wait 10 days for an update or for further comment before closing this issue out. If you think this issue needs to be prioritized, please comment to get the thread going again! Maintainers also review issues marked as stale on a regular basis and comment or adjust status if the issue needs to be reprioritized. close-issue-message: As this issue has been inactive for more than one month, we will be closing it. Thank you to all the participants! If you would like to raise a related issue, please create a new issue which includes your specific details and references this issue number. exempt-issue-labels: auto-triage-skip exempt-all-milestones: true From c741819d4f4cc7c807c979f0748ebef775c02a56 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 7 Dec 2021 21:06:08 +0900 Subject: [PATCH 425/865] Set websocket-client version range due to the package regression --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index db728656d..a3be4e443 100755 --- a/setup.py +++ b/setup.py @@ -85,7 +85,9 @@ "uvicorn<1", "gunicorn>=20,<21", # Socket Mode 3rd party implementation - "websocket_client>=1,<2", + # TODO: 1.2.2 has a regression (https://github.com/websocket-client/websocket-client/issues/769) + # ERROR on_error invoked (error: AttributeError, message: 'Dispatcher' object has no attribute 'read') + "websocket_client>=1,<1.2.2", ], # pip install -e ".[testing_without_asyncio]" "testing_without_asyncio": test_dependencies, From 1f044cbc2b26b32252202a7210455ea00d0c3688 Mon Sep 17 00:00:00 2001 From: Jason Wong Date: Tue, 7 Dec 2021 21:19:43 +0900 Subject: [PATCH 426/865] Fixes #534, updates link to view submisions (#535) Co-authored-by: Kazuhiro Sera --- docs/_basic/ja_listening_modals.md | 2 +- docs/_basic/listening_modals.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_basic/ja_listening_modals.md b/docs/_basic/ja_listening_modals.md index fb469eea1..360bfbbab 100644 --- a/docs/_basic/ja_listening_modals.md +++ b/docs/_basic/ja_listening_modals.md @@ -25,7 +25,7 @@ def handle_submission(ack, body): ``` この例と同様に、モーダルでの送信リクエストに対して、エラーを表示するためのオプションもあります。 -モーダルの送信について詳しくは、API ドキュメントを参照してください。 +モーダルの送信について詳しくは、API ドキュメントを参照してください。
    diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md index a0146c453..0342b0b3e 100644 --- a/docs/_basic/listening_modals.md +++ b/docs/_basic/listening_modals.md @@ -24,7 +24,7 @@ def handle_submission(ack, body): ack(response_action="update", view=build_new_view(body)) ``` Similarly, there are options for [displaying errors](https://api.slack.com/surfaces/modals/using#displaying_errors) in response to view submissions. -Read more about view submissions in our API documentation. +Read more about view submissions in our API documentation. From 92c3380c1c19501b746ddbc3c30b05b4e6b074e1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 8 Dec 2021 00:22:57 +0900 Subject: [PATCH 427/865] Update the respond utility guide (#538) Co-authored-by: Sarah Jiang --- docs/.gitignore | 1 + docs/_basic/ja_responding_actions.md | 2 +- docs/_basic/responding_actions.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/.gitignore b/docs/.gitignore index c48a717ef..ee9abd8bf 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -5,3 +5,4 @@ Gemfile.lock .vscode/ .bundle/ vendor/ +.ruby-version diff --git a/docs/_basic/ja_responding_actions.md b/docs/_basic/ja_responding_actions.md index c1cb5f357..dd974182c 100644 --- a/docs/_basic/ja_responding_actions.md +++ b/docs/_basic/ja_responding_actions.md @@ -31,7 +31,7 @@ def approve_request(ack, say):
    -`respond()` は `response_url` を使って送信するときに便利なメソッドで、これらと同じような動作をします。投稿するメッセージのペイロードには JSON オブジェクトを渡すことができ、メッセージはやり取りの発生元に反映されます。オプションのプロパティとして `response_type`(値は `in_channel` または `ephemeral`)、`replace_original`、`delete_original` などを指定できます。 +`respond()` は `response_url` を使って送信するときに便利なメソッドで、これらと同じような動作をします。投稿するメッセージのペイロードには、全ての[メッセージペイロードのプロパティ](https://api.slack.com/reference/messaging/payload)とオプションのプロパティとして `response_type`(値は `"in_channel"` または `"ephemeral"`)、`replace_original`、`delete_original`、`unfurl_links`、`unfurl_media` などを指定できます。こうすることによってアプリから送信されるメッセージは、やり取りの発生元に反映されます。
    diff --git a/docs/_basic/responding_actions.md b/docs/_basic/responding_actions.md index 3c04d230c..30219a098 100644 --- a/docs/_basic/responding_actions.md +++ b/docs/_basic/responding_actions.md @@ -32,7 +32,7 @@ def approve_request(ack, say):
    -Since `respond()` is a utility for calling the `response_url`, it behaves in the same way. You can pass a JSON object with a new message payload that will be published back to the source of the original interaction with optional properties like `response_type` (which has a value of `in_channel` or `ephemeral`), `replace_original`, and `delete_original`. +Since `respond()` is a utility for calling the `response_url`, it behaves in the same way. You can pass [all the message payload properties](https://api.slack.com/reference/messaging/payload) as keyword arguments along with optional properties like `response_type` (which has a value of `"in_channel"` or `"ephemeral"`), `replace_original`, `delete_original`, `unfurl_links`, and `unfurl_media`. With that, your app can send a new message payload that will be published back to the source of the original interaction.
    From e8556c1d55ceac7a75c736bcc124f28de2edcb47 Mon Sep 17 00:00:00 2001 From: Jason Wong Date: Mon, 13 Dec 2021 09:04:59 +0900 Subject: [PATCH 428/865] Fix #525 Japanese translation for #524 (lazy listeners doc updates) (#541) Co-authored-by: Kazuhiro Sera --- docs/_advanced/ja_lazy_listener.md | 10 ++++++---- docs/_basic/acknowledging_requests.md | 4 ++-- ...wledging_events.md => ja_acknowledging_requests.md} | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) rename docs/_basic/{ja_acknowledging_events.md => ja_acknowledging_requests.md} (55%) diff --git a/docs/_advanced/ja_lazy_listener.md b/docs/_advanced/ja_lazy_listener.md index eb9c15008..b472e0930 100644 --- a/docs/_advanced/ja_lazy_listener.md +++ b/docs/_advanced/ja_lazy_listener.md @@ -6,13 +6,15 @@ order: 10 ---
    -⚠️ Lazy リスナー関数は、FaaS 環境への Bolt for Python アプリのデプロイを容易にする、ベータ版の機能です。開発中の機能であるため、Bolt for Python の API は変更される可能性があります。 +Lazy リスナー関数は、FaaS 環境への Slack アプリのデプロイを容易にする機能です。この機能は Bolt for Python でのみ利用可能で、他の Bolt フレームワークでこの機能に対応することは予定していません。 -通常、リスナー関数では最初の手順として `ack()` を呼び出します。`ack()` を呼び出すことで、アプリがリクエストを受け取り、適切な時間内(3 秒間)に処理する予定であることが Slack に伝えられます。 +通常、アクション(action)、コマンド(command)、ショートカット(shortcut)、オプション(options)、およびモーダルからのデータ送信(view_submission)をハンドルするとき、 `ack()` を呼び出し、Slack からのリクエストを 3 秒以内に確認する必要があります。`ack()` を呼び出すと Slack に HTTP ステータスが 200 OK の応答が返されます。こうすることで、アプリがリクエストの応答を処理中であることを Slack に伝えられます。通常であれば、この確認処理を処理関数の最初のステップとして行うことを推奨しています。 -しかし、FaaS 環境や類似のランタイムで実行されるアプリでは、HTTP レスポンスを返したあとにスレッドやプロセスの実行を続けることができないため、同じパターンに従うことはできません。代わりに、`process_before_response` フラグを `True` に設定します。この設定により、`ack()` の呼び出しとリクエストの処理を安全に行うリスナーを作成することができます。しかし、3 秒以内にすべての処理を完了させる必要があることは変わりません。Events APIに応答するリスナーでは`ack()` メソッドの呼び出しを必要としませんが、この設定では処理を 3 秒以内に完了させる必要があります。 +しかし、FaaS 環境や類似のランタイムで実行されるアプリでは、 **HTTP レスポンスを返したあとにスレッドやプロセスの実行を続けることができない** ため、確認の応答を送信した後で時間のかかる処理をするという通常のパターンに従うことができません。こうした環境で動作させるためには、 `process_before_response` フラグを `True` に設定します。このフラグが `True` に設定されている場合、Bolt はリスナー関数での処理が完了するまで HTTP レスポンスの送信を遅延させます。そのため 3 秒以内にリスナーのすべての処理が完了しなかった場合は Slack 上でタイムアウトのエラー表示となってしまいます。また、Events API に応答するリスナーでは明示的な `ack()` メソッドの呼び出しを必要としませんが、この設定を有効にしている場合、リスナーの処理を 3 秒以内に完了させる必要があることにも注意してください。 -Lazy リスナーは、この問題を解決するためのソリューションです。Lazy リスナーは、デコレーターとして動作するものではなく、次の 2 つのキーワード引数を指定することにより動作するものです。 * `ack:Callable` : `ack()` の呼び出しを行います。 * `lazy:List[Callable]` : リクエストに関係する、時間のかかるプロセスの処理を担当します。Lazy 関数からは `ack()` にアクセスできません。 +処理関数の中で時間のかかる処理を実行できるようにするために、私たちは Lazy リスナーという関数を実行する仕組みを導入しました。Lazy リスナーは、デコレーターとして動作させるのではなく、以下の 2 つのキーワード引数を受け取ります。 +* `ack: Callable`: 3 秒以内での `ack()` メソッドの呼び出しを担当します。 +* `lazy: List[Callable]` : リクエストに関する時間のかかる処理のハンドリングを担当します。Lazy 関数からは `ack()` にアクセスすることはできません。
    ```python diff --git a/docs/_basic/acknowledging_requests.md b/docs/_basic/acknowledging_requests.md index 2f3f6c466..41aeef193 100644 --- a/docs/_basic/acknowledging_requests.md +++ b/docs/_basic/acknowledging_requests.md @@ -9,11 +9,11 @@ order: 7 Actions, commands, shortcuts, options requests, and view submissions must **always** be acknowledged using the `ack()` function. This lets Slack know that the request was received so that it may update the Slack user interface accordingly. -Depending on the type of request, your acknowledgement may be different. For example, when acknowledging a menu selection associated with an external data source, you would call `ack()` with a list of relevant [options](https://api.slack.com/reference/block-kit/composition-objects#option). When acknowledging a view submission, you may supply a `response_action` as part of your acknowledge to update the view. Please see the relevant sections of the docs for more detail on options for `ack()` these different requests. +Depending on the type of request, your acknowledgement may be different. For example, when acknowledging a menu selection associated with an external data source, you would call `ack()` with a list of relevant [options](https://api.slack.com/reference/block-kit/composition-objects#option). When acknowledging a view submission, you may supply a `response_action` as part of your acknowledgement to [update the view](#update-views-on-submission). We recommend calling `ack()` right away before initiating any time-consuming processes such as fetching information from your database or sending a new message, since you only have 3 seconds to respond before Slack registers a timeout error. -💡 When working in a FaaS / serverless environment, our guidelines for when to `ack()` are different. See the section on **Lazy listeners (FaaS)** for more detail on this. +💡 When working in a FaaS / serverless environment, our guidelines for when to `ack()` are different. See the section on [Lazy listeners (FaaS)](#lazy-listeners) for more detail on this.
    diff --git a/docs/_basic/ja_acknowledging_events.md b/docs/_basic/ja_acknowledging_requests.md similarity index 55% rename from docs/_basic/ja_acknowledging_events.md rename to docs/_basic/ja_acknowledging_requests.md index 8967308cc..24ab1f979 100644 --- a/docs/_basic/ja_acknowledging_events.md +++ b/docs/_basic/ja_acknowledging_requests.md @@ -7,12 +7,13 @@ order: 7
    -アクション(action)、コマンド(command)、およびオプション(options)の各リクエストは、**必ず** `ack()` 関数を使って確認を行う必要があります。これによってリクエストが受信されたことが Slack に認識され、Slack のユーザーインターフェイスが適切に更新されます。 +アクション(action)、コマンド(command)、ショートカット(shortcut)、オプション(options)、およびモーダルからのデータ送信(view_submission)の各リクエストは、**必ず** `ack()` 関数を使って確認を行う必要があります。これによってリクエストが受信されたことが Slack に認識され、Slack のユーザーインターフェイスが適切に更新されます。 -リクエストの種類によっては、確認で通知方法が異なる場合があります。例えば、外部データソースを使用する選択メニューのオプションのリクエストに対する確認では、適切な[オプション](https://api.slack.com/reference/block-kit/composition-objects#option)のリストとともに `ack()` を呼び出します。 +リクエストの種類によっては、確認で通知方法が異なる場合があります。例えば、外部データソースを使用する選択メニューのオプションのリクエストに対する確認では、適切な[オプション](https://api.slack.com/reference/block-kit/composition-objects#option)のリストとともに `ack()` を呼び出します。モーダルからのデータ送信に対する確認では、 `response_action` を渡すことで[モーダルの更新](#update-views-on-submission)などを行えます。 -確認までの猶予は 3 秒しかないため、新しいメッセージの送信や、データベースからの情報の取得は、`ack()` を呼び出した後で行うことをおすすめします。 +確認までの猶予は 3 秒しかないため、新しいメッセージの送信やデータベースからの情報の取得といった時間のかかる処理は、`ack()` を呼び出した後で行うことをおすすめします。 + FaaS / serverless 環境を使う場合、 `ack()` するタイミングが異なります。 これに関する詳細は [Lazy listeners (FaaS)](#lazy-listeners) を参照してください。
    From d5289c9990b755cea487c92a0bb95d74d9b03dcc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 14 Dec 2021 12:57:10 +0900 Subject: [PATCH 429/865] Fix url_verification error with the Flask adapter (#543) --- examples/app_authorize.py | 2 +- examples/async_app.py | 2 +- slack_bolt/adapter/flask/handler.py | 3 +++ tests/adapter_tests/flask/test_flask.py | 34 +++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/examples/app_authorize.py b/examples/app_authorize.py index 527e2325a..843ecd6f6 100644 --- a/examples/app_authorize.py +++ b/examples/app_authorize.py @@ -39,4 +39,4 @@ def event_test(body, say, logger): # pip install slack_bolt # export SLACK_SIGNING_SECRET=*** # export MY_TOKEN=xoxb-*** -# python app.py +# python app_authorize.py diff --git a/examples/async_app.py b/examples/async_app.py index dd5172964..a699dbb29 100644 --- a/examples/async_app.py +++ b/examples/async_app.py @@ -32,4 +32,4 @@ async def command(ack, body): # pip install slack_bolt # export SLACK_SIGNING_SECRET=*** # export SLACK_BOT_TOKEN=xoxb-*** -# python app.py +# python async_app.py diff --git a/slack_bolt/adapter/flask/handler.py b/slack_bolt/adapter/flask/handler.py index fb7d44ce1..b111c5f85 100644 --- a/slack_bolt/adapter/flask/handler.py +++ b/slack_bolt/adapter/flask/handler.py @@ -17,6 +17,9 @@ def to_bolt_request(req: Request) -> BoltRequest: def to_flask_response(bolt_resp: BoltResponse) -> Response: resp: Response = make_response(bolt_resp.body, bolt_resp.status) for k, values in bolt_resp.headers.items(): + if k.lower() == "content-type" and resp.headers.get("content-type") is not None: + # Remove the one set by Flask + resp.headers.pop("content-type") for v in values: resp.headers.add_header(k, v) return resp diff --git a/tests/adapter_tests/flask/test_flask.py b/tests/adapter_tests/flask/test_flask.py index b8c198e68..034a8b580 100644 --- a/tests/adapter_tests/flask/test_flask.py +++ b/tests/adapter_tests/flask/test_flask.py @@ -99,6 +99,7 @@ def endpoint(): headers=self.build_headers(timestamp, body), ) assert rv.status_code == 200 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" assert_auth_test_count(self, 1) def test_shortcuts(self): @@ -142,6 +143,7 @@ def endpoint(): headers=self.build_headers(timestamp, body), ) assert rv.status_code == 200 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" assert_auth_test_count(self, 1) def test_commands(self): @@ -185,6 +187,7 @@ def endpoint(): headers=self.build_headers(timestamp, body), ) assert rv.status_code == 200 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" assert_auth_test_count(self, 1) def test_oauth(self): @@ -205,4 +208,35 @@ def endpoint(): with flask_app.test_client() as client: rv = client.get("/slack/install") + assert rv.headers.get("content-type") == "text/html; charset=utf-8" assert rv.status_code == 200 + + def test_url_verification(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + input = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + flask_app = Flask(__name__) + + @flask_app.route("/slack/events", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) From bc094f01b7d84bb9a61f082eb03e678062ebaa05 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 14 Dec 2021 12:58:33 +0900 Subject: [PATCH 430/865] Fix #542 Add additional context values for FastAPI apps (#544) --- examples/fastapi/async_app_custom_props.py | 41 +++++++++++++++ slack_bolt/adapter/starlette/async_handler.py | 26 +++++++--- slack_bolt/adapter/starlette/handler.py | 28 ++++++++--- tests/adapter_tests/starlette/test_fastapi.py | 50 ++++++++++++++++++- .../adapter_tests_async/test_async_fastapi.py | 50 ++++++++++++++++++- 5 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 examples/fastapi/async_app_custom_props.py diff --git a/examples/fastapi/async_app_custom_props.py b/examples/fastapi/async_app_custom_props.py new file mode 100644 index 000000000..497e47aa2 --- /dev/null +++ b/examples/fastapi/async_app_custom_props.py @@ -0,0 +1,41 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler + +app = AsyncApp() +app_handler = AsyncSlackRequestHandler(app) + + +@app.event("app_mention") +async def handle_app_mentions(context, say, logger): + logger.info(context) + assert context.get("foo") == "FOO" + await say("What's up?") + + +@app.event("message") +async def handle_message(): + pass + + +from fastapi import FastAPI, Request, Depends + +api = FastAPI() + + +def get_foo(): + yield "FOO" + + +@api.post("/slack/events") +async def endpoint(req: Request, foo: str = Depends(get_foo)): + return await app_handler.handle(req, {"foo": foo}) + + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# uvicorn async_app_custom_props:api --reload --port 3000 --log-level warning diff --git a/slack_bolt/adapter/starlette/async_handler.py b/slack_bolt/adapter/starlette/async_handler.py index bc38176bf..5fb13ade3 100644 --- a/slack_bolt/adapter/starlette/async_handler.py +++ b/slack_bolt/adapter/starlette/async_handler.py @@ -1,3 +1,5 @@ +from typing import Dict, Any, Optional + from starlette.requests import Request from starlette.responses import Response @@ -6,12 +8,20 @@ from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow -def to_async_bolt_request(req: Request, body: bytes) -> AsyncBoltRequest: - return AsyncBoltRequest( +def to_async_bolt_request( + req: Request, + body: bytes, + addition_context_properties: Optional[Dict[str, Any]] = None, +) -> AsyncBoltRequest: + request = AsyncBoltRequest( body=body.decode("utf-8"), query=req.query_params, headers=req.headers, ) + if addition_context_properties is not None: + for k, v in addition_context_properties.items(): + request.context[k] = v + return request def to_starlette_response(bolt_resp: BoltResponse) -> Response: @@ -39,23 +49,27 @@ class AsyncSlackRequestHandler: def __init__(self, app: AsyncApp): # type: ignore self.app = app - async def handle(self, req: Request) -> Response: + async def handle( + self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None + ) -> Response: body = await req.body() if req.method == "GET": if self.app.oauth_flow is not None: oauth_flow: AsyncOAuthFlow = self.app.oauth_flow if req.url.path == oauth_flow.install_path: bolt_resp = await oauth_flow.handle_installation( - to_async_bolt_request(req, body) + to_async_bolt_request(req, body, addition_context_properties) ) return to_starlette_response(bolt_resp) elif req.url.path == oauth_flow.redirect_uri_path: bolt_resp = await oauth_flow.handle_callback( - to_async_bolt_request(req, body) + to_async_bolt_request(req, body, addition_context_properties) ) return to_starlette_response(bolt_resp) elif req.method == "POST": - bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, body)) + bolt_resp = await self.app.async_dispatch( + to_async_bolt_request(req, body, addition_context_properties) + ) return to_starlette_response(bolt_resp) return Response( diff --git a/slack_bolt/adapter/starlette/handler.py b/slack_bolt/adapter/starlette/handler.py index c8cea10d4..d1c9a381e 100644 --- a/slack_bolt/adapter/starlette/handler.py +++ b/slack_bolt/adapter/starlette/handler.py @@ -1,3 +1,5 @@ +from typing import Dict, Any, Optional + from starlette.requests import Request from starlette.responses import Response @@ -5,12 +7,20 @@ from slack_bolt.oauth import OAuthFlow -def to_bolt_request(req: Request, body: bytes) -> BoltRequest: - return BoltRequest( +def to_bolt_request( + req: Request, + body: bytes, + addition_context_properties: Optional[Dict[str, Any]] = None, +) -> BoltRequest: + request = BoltRequest( body=body.decode("utf-8"), query=req.query_params, headers=req.headers, ) + if addition_context_properties is not None: + for k, v in addition_context_properties.items(): + request.context[k] = v + return request def to_starlette_response(bolt_resp: BoltResponse) -> Response: @@ -38,21 +48,27 @@ class SlackRequestHandler: def __init__(self, app: App): # type: ignore self.app = app - async def handle(self, req: Request) -> Response: + async def handle( + self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None + ) -> Response: body = await req.body() if req.method == "GET": if self.app.oauth_flow is not None: oauth_flow: OAuthFlow = self.app.oauth_flow if req.url.path == oauth_flow.install_path: bolt_resp = oauth_flow.handle_installation( - to_bolt_request(req, body) + to_bolt_request(req, body, addition_context_properties) ) return to_starlette_response(bolt_resp) elif req.url.path == oauth_flow.redirect_uri_path: - bolt_resp = oauth_flow.handle_callback(to_bolt_request(req, body)) + bolt_resp = oauth_flow.handle_callback( + to_bolt_request(req, body, addition_context_properties) + ) return to_starlette_response(bolt_resp) elif req.method == "POST": - bolt_resp = self.app.dispatch(to_bolt_request(req, body)) + bolt_resp = self.app.dispatch( + to_bolt_request(req, body, addition_context_properties) + ) return to_starlette_response(bolt_resp) return Response( diff --git a/tests/adapter_tests/starlette/test_fastapi.py b/tests/adapter_tests/starlette/test_fastapi.py index 4b09c618a..20da33a76 100644 --- a/tests/adapter_tests/starlette/test_fastapi.py +++ b/tests/adapter_tests/starlette/test_fastapi.py @@ -2,7 +2,7 @@ from time import time from urllib.parse import quote -from fastapi import FastAPI +from fastapi import FastAPI, Depends from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient from starlette.requests import Request @@ -214,3 +214,51 @@ async def endpoint(req: Request): assert response.status_code == 200 assert response.headers.get("content-type") == "text/html; charset=utf-8" assert "https://slack.com/oauth/v2/authorize?state=" in response.text + + def test_custom_props(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack, context): + assert context.get("foo") == "FOO" + 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))}" + + api = FastAPI() + app_handler = SlackRequestHandler(app) + + def get_foo(): + yield "FOO" + + @api.post("/slack/events") + async def endpoint(req: Request, foo: str = Depends(get_foo)): + return await app_handler.handle(req, {"foo": foo}) + + client = TestClient(api) + response = client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests_async/test_async_fastapi.py b/tests/adapter_tests_async/test_async_fastapi.py index 41e1f4923..5850114d6 100644 --- a/tests/adapter_tests_async/test_async_fastapi.py +++ b/tests/adapter_tests_async/test_async_fastapi.py @@ -2,7 +2,7 @@ from time import time from urllib.parse import quote -from fastapi import FastAPI +from fastapi import FastAPI, Depends from slack_sdk.signature import SignatureVerifier from slack_sdk.web.async_client import AsyncWebClient from starlette.requests import Request @@ -215,3 +215,51 @@ async def endpoint(req: Request): assert response.headers.get("content-type") == "text/html; charset=utf-8" assert response.headers.get("content-length") == "597" assert "https://slack.com/oauth/v2/authorize?state=" in response.text + + def test_custom_props(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def shortcut_handler(ack, context): + assert context.get("foo") == "FOO" + 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))}" + + api = FastAPI() + app_handler = AsyncSlackRequestHandler(app) + + def get_foo(): + yield "FOO" + + @api.post("/slack/events") + async def endpoint(req: Request, foo: str = Depends(get_foo)): + return await app_handler.handle(req, {"foo": foo}) + + client = TestClient(api) + response = client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) From dc09c4ca3109e7df4b6013df60441454557c84e3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 14 Dec 2021 12:59:35 +0900 Subject: [PATCH 431/865] Fix #545 Enable to use lazy listeners even when having any custom context data (#546) --- slack_bolt/context/async_context.py | 18 +++++++ slack_bolt/context/base_context.py | 21 ++++++++ slack_bolt/context/context.py | 19 +++++++ slack_bolt/listener/asyncio_runner.py | 2 +- slack_bolt/listener/thread_runner.py | 2 +- slack_bolt/request/async_request.py | 9 ++++ slack_bolt/request/request.py | 9 ++++ tests/scenario_tests/test_lazy.py | 70 +++++++++++++++++++++++++ tests/scenario_tests_async/test_lazy.py | 70 +++++++++++++++++++++++++ 9 files changed, 218 insertions(+), 2 deletions(-) diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index 0a02c299c..46eb6c69d 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -6,11 +6,29 @@ from slack_bolt.context.base_context import BaseContext from slack_bolt.context.respond.async_respond import AsyncRespond from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.util.utils import create_copy class AsyncBoltContext(BaseContext): """Context object associated with a request from Slack.""" + def to_copyable(self) -> "AsyncBoltContext": + new_dict = {} + for prop_name, prop_value in self.items(): + if prop_name in self.standard_property_names: + # all the standard properties are copiable + new_dict[prop_name] = prop_value + else: + try: + copied_value = create_copy(prop_value) + new_dict[prop_name] = copied_value + except TypeError as te: + self.logger.debug( + f"Skipped settings '{prop_name}' to a copied request for lazy listeners " + f"as it's not possible to make a deep copy (error: {te})" + ) + return AsyncBoltContext(new_dict) + @property def client(self) -> Optional[AsyncWebClient]: """The `AsyncWebClient` instance available for this request. diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 307aa02b9..266df9e5c 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -7,6 +7,27 @@ class BaseContext(dict): """Context object associated with a request from Slack.""" + standard_property_names = [ + "logger", + "token", + "enterprise_id", + "is_enterprise_install", + "team_id", + "user_id", + "channel_id", + "response_url", + "matches", + "authorize_result", + "bot_token", + "bot_id", + "bot_user_id", + "user_token", + "client", + "ack", + "say", + "respond", + ] + @property def logger(self) -> Logger: """The properly configured logger that is available for middleware/listeners.""" diff --git a/slack_bolt/context/context.py b/slack_bolt/context/context.py index b47405324..a92ac05bb 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -7,11 +7,30 @@ from slack_bolt.context.base_context import BaseContext from slack_bolt.context.respond import Respond from slack_bolt.context.say import Say +from slack_bolt.util.utils import create_copy class BoltContext(BaseContext): """Context object associated with a request from Slack.""" + def to_copyable(self) -> "BoltContext": + new_dict = {} + for prop_name, prop_value in self.items(): + if prop_name in self.standard_property_names: + # all the standard properties are copiable + new_dict[prop_name] = prop_value + else: + try: + copied_value = create_copy(prop_value) + new_dict[prop_name] = copied_value + except TypeError as te: + self.logger.warning( + f"Skipped setting '{prop_name}' to a copied request for lazy listeners " + "due to a deep-copy creation error. Consider passing the value not as part of context object " + f"(error: {te})" + ) + return BoltContext(new_dict) + @property def client(self) -> Optional[WebClient]: """The `WebClient` instance available for this request. diff --git a/slack_bolt/listener/asyncio_runner.py b/slack_bolt/listener/asyncio_runner.py index ecd66568b..6edbbaac3 100644 --- a/slack_bolt/listener/asyncio_runner.py +++ b/slack_bolt/listener/asyncio_runner.py @@ -198,7 +198,7 @@ def _start_lazy_function( def _build_lazy_request( request: AsyncBoltRequest, lazy_func_name: str ) -> AsyncBoltRequest: - copied_request = create_copy(request) + copied_request = create_copy(request.to_copyable()) copied_request.method = "NONE" copied_request.lazy_only = True copied_request.lazy_function_name = lazy_func_name diff --git a/slack_bolt/listener/thread_runner.py b/slack_bolt/listener/thread_runner.py index b6a6b6e11..05f9fb76a 100644 --- a/slack_bolt/listener/thread_runner.py +++ b/slack_bolt/listener/thread_runner.py @@ -195,7 +195,7 @@ def _start_lazy_function( @staticmethod def _build_lazy_request(request: BoltRequest, lazy_func_name: str) -> BoltRequest: - copied_request = create_copy(request) + copied_request = create_copy(request.to_copyable()) copied_request.method = "NONE" copied_request.lazy_only = True copied_request.lazy_function_name = lazy_func_name diff --git a/slack_bolt/request/async_request.py b/slack_bolt/request/async_request.py index ad671ccc2..e590bc93b 100644 --- a/slack_bolt/request/async_request.py +++ b/slack_bolt/request/async_request.py @@ -75,3 +75,12 @@ def __init__( "x-slack-bolt-lazy-function-name", [None] )[0] self.mode = mode + + def to_copyable(self) -> "AsyncBoltRequest": + return AsyncBoltRequest( + body=self.raw_body, + query=self.query, + headers=self.headers, + context=self.context.to_copyable(), + mode=self.mode, + ) diff --git a/slack_bolt/request/request.py b/slack_bolt/request/request.py index d91e78ba6..4810d2018 100644 --- a/slack_bolt/request/request.py +++ b/slack_bolt/request/request.py @@ -72,3 +72,12 @@ def __init__( "x-slack-bolt-lazy-function-name", [None] )[0] self.mode = mode + + def to_copyable(self) -> "BoltRequest": + return BoltRequest( + body=self.raw_body, + query=self.query, + headers=self.headers, + context=self.context.to_copyable(), + mode=self.mode, + ) diff --git a/tests/scenario_tests/test_lazy.py b/tests/scenario_tests/test_lazy.py index 6a2c89607..263835b59 100644 --- a/tests/scenario_tests/test_lazy.py +++ b/tests/scenario_tests/test_lazy.py @@ -138,3 +138,73 @@ def async2(say): assert response.status == 200 time.sleep(1) # wait a bit assert self.mock_received_requests["/chat.postMessage"] == 2 + + def test_issue_545_context_copy_failure(self): + def just_ack(ack): + ack() + + class LazyClass: + def __call__(self, context, say): + assert context.get("foo") == "FOO" + assert context.get("ssl_context") is None + time.sleep(0.3) + say(text="lazy function 1") + + def async2(context, say): + assert context.get("foo") == "FOO" + assert context.get("ssl_context") is None + time.sleep(0.5) + say(text="lazy function 2") + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.middleware + def set_ssl_context(context, next_): + from ssl import SSLContext + + context["foo"] = "FOO" + # This causes an error when starting lazy listener executions + context["ssl_context"] = SSLContext() + next_() + + # 2021-12-13 11:14:29 ERROR Failed to run a middleware middleware (error: cannot pickle 'SSLContext' object) + # Traceback (most recent call last): + # File "/path/to/bolt-python/slack_bolt/app/app.py", line 545, in dispatch + # ] = self._listener_runner.run( + # File "/path/to/bolt-python/slack_bolt/listener/thread_runner.py", line 166, in run + # self._start_lazy_function(lazy_func, request) + # File "/path/to/bolt-python/slack_bolt/listener/thread_runner.py", line 193, in _start_lazy_function + # copied_request = self._build_lazy_request(request, func_name) + # File "/path/to/bolt-python/slack_bolt/listener/thread_runner.py", line 198, in _build_lazy_request + # copied_request = create_copy(request) + # File "/path/to/bolt-python/slack_bolt/util/utils.py", line 48, in create_copy + # return copy.deepcopy(original) + # File "/path/to/python/lib/python3.9/copy.py", line 172, in deepcopy + # y = _reconstruct(x, memo, *rv) + # File "/path/to/python/lib/python3.9/copy.py", line 270, in _reconstruct + # state = deepcopy(state, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 146, in deepcopy + # y = copier(x, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 230, in _deepcopy_dict + # y[deepcopy(key, memo)] = deepcopy(value, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 172, in deepcopy + # y = _reconstruct(x, memo, *rv) + # File "/path/to/python/lib/python3.9/copy.py", line 296, in _reconstruct + # value = deepcopy(value, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 161, in deepcopy + # rv = reductor(4) + # TypeError: cannot pickle 'SSLContext' object + + app.action("a")( + ack=just_ack, + lazy=[LazyClass(), async2], + ) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + time.sleep(1) # wait a bit + assert self.mock_received_requests["/chat.postMessage"] == 2 diff --git a/tests/scenario_tests_async/test_lazy.py b/tests/scenario_tests_async/test_lazy.py index f8fe438bc..e69056fe9 100644 --- a/tests/scenario_tests_async/test_lazy.py +++ b/tests/scenario_tests_async/test_lazy.py @@ -117,3 +117,73 @@ async def async2(say): assert response.status == 200 await asyncio.sleep(1) # wait a bit assert self.mock_received_requests["/chat.postMessage"] == 2 + + @pytest.mark.asyncio + async def test_issue_545_context_copy_failure(self): + async def just_ack(ack): + await ack() + + async def async1(context, say): + assert context.get("foo") == "FOO" + assert context.get("ssl_context") is None + await asyncio.sleep(0.3) + await say(text="lazy function 1") + + async def async2(context, say): + assert context.get("foo") == "FOO" + assert context.get("ssl_context") is None + await asyncio.sleep(0.5) + await say(text="lazy function 2") + + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.middleware + async def set_ssl_context(context, next_): + from ssl import SSLContext + + context["foo"] = "FOO" + # This causes an error when starting lazy listener executions + context["ssl_context"] = SSLContext() + await next_() + + # 2021-12-13 11:52:46 ERROR Failed to run a middleware function (error: cannot pickle 'SSLContext' object) + # Traceback (most recent call last): + # File "/path/to/bolt-python/slack_bolt/app/async_app.py", line 585, in async_dispatch + # ] = await self._async_listener_runner.run( + # File "/path/to/bolt-python/slack_bolt/listener/asyncio_runner.py", line 167, in run + # self._start_lazy_function(lazy_func, request) + # File "/path/to/bolt-python/slack_bolt/listener/asyncio_runner.py", line 194, in _start_lazy_function + # copied_request = self._build_lazy_request(request, func_name) + # File "/path/to/bolt-python/slack_bolt/listener/asyncio_runner.py", line 201, in _build_lazy_request + # copied_request = create_copy(request) + # File "/path/to/bolt-python/slack_bolt/util/utils.py", line 48, in create_copy + # return copy.deepcopy(original) + # File "/path/to/python/lib/python3.9/copy.py", line 172, in deepcopy + # y = _reconstruct(x, memo, *rv) + # File "/path/to/python/lib/python3.9/copy.py", line 270, in _reconstruct + # state = deepcopy(state, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 146, in deepcopy + # y = copier(x, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 230, in _deepcopy_dict + # y[deepcopy(key, memo)] = deepcopy(value, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 172, in deepcopy + # y = _reconstruct(x, memo, *rv) + # File "/path/to/python/lib/python3.9/copy.py", line 296, in _reconstruct + # value = deepcopy(value, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 161, in deepcopy + # rv = reductor(4) + # TypeError: cannot pickle 'SSLContext' object + + app.action("a")( + ack=just_ack, + lazy=[async1, async2], + ) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await asyncio.sleep(1) # wait a bit + assert self.mock_received_requests["/chat.postMessage"] == 2 From 68e792e393ca599628965f3f7b384aad15d5c41c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 14 Dec 2021 13:51:03 +0900 Subject: [PATCH 432/865] Upgrade pytype, black versions (#547) --- scripts/run_pytype.sh | 2 +- setup.py | 2 +- slack_bolt/adapter/falcon/resource.py | 2 +- slack_bolt/adapter/sanic/async_handler.py | 2 +- slack_bolt/adapter/tornado/handler.py | 2 +- slack_bolt/context/base_context.py | 3 +++ 6 files changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index 4b305f1b6..3523791e0 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -5,5 +5,5 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ pip install -e ".[async]" && \ pip install -e ".[adapter]" && \ - pip install "pytype==2021.10.25" && \ + pip install "pytype==2021.12.8" && \ pytype slack_bolt/ diff --git a/setup.py b/setup.py index a3be4e443..8f58c3d0f 100755 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ "pytest-cov>=3,<4", "Flask-Sockets>=0.2,<1", "Werkzeug<2", # TODO: support Flask 2.x - "black==21.10b0", + "black==21.12b0", ] async_test_dependencies = test_dependencies + [ diff --git a/slack_bolt/adapter/falcon/resource.py b/slack_bolt/adapter/falcon/resource.py index dec1663a3..fb6fd24bf 100644 --- a/slack_bolt/adapter/falcon/resource.py +++ b/slack_bolt/adapter/falcon/resource.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime # type: ignore from http import HTTPStatus from falcon import Request, Response diff --git a/slack_bolt/adapter/sanic/async_handler.py b/slack_bolt/adapter/sanic/async_handler.py index 1555709b8..f1c3f807a 100644 --- a/slack_bolt/adapter/sanic/async_handler.py +++ b/slack_bolt/adapter/sanic/async_handler.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime # type: ignore from sanic.request import Request from sanic.response import HTTPResponse diff --git a/slack_bolt/adapter/tornado/handler.py b/slack_bolt/adapter/tornado/handler.py index 47e06ba9d..7eab94dbf 100644 --- a/slack_bolt/adapter/tornado/handler.py +++ b/slack_bolt/adapter/tornado/handler.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime # type: ignore from tornado.httputil import HTTPServerRequest from tornado.web import RequestHandler diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 266df9e5c..f6c67bb60 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -1,3 +1,6 @@ +# pytype: skip-file +# Note: Since 2021.12.8, the pytype code analyzer does not properly work for this file + from logging import Logger from typing import Optional, Tuple From 6df6bba62ba26ac0917472efc3a30240d98eeda6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 14 Dec 2021 13:52:15 +0900 Subject: [PATCH 433/865] version 1.11.0 --- .../slack_bolt/adapter/falcon/resource.html | 2 +- .../slack_bolt/adapter/flask/handler.html | 6 ++ .../adapter/sanic/async_handler.html | 2 +- .../adapter/starlette/async_handler.html | 70 ++++++++++++----- .../slack_bolt/adapter/starlette/handler.html | 76 ++++++++++++++----- .../slack_bolt/adapter/tornado/handler.html | 2 +- .../slack_bolt/context/async_context.html | 66 ++++++++++++++++ .../slack_bolt/context/base_context.html | 55 +++++++++++++- docs/api-docs/slack_bolt/context/context.html | 69 +++++++++++++++++ .../slack_bolt/listener/asyncio_runner.html | 4 +- .../slack_bolt/listener/thread_runner.html | 4 +- .../slack_bolt/request/async_request.html | 45 ++++++++++- docs/api-docs/slack_bolt/request/request.html | 45 ++++++++++- docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 15 files changed, 396 insertions(+), 54 deletions(-) diff --git a/docs/api-docs/slack_bolt/adapter/falcon/resource.html b/docs/api-docs/slack_bolt/adapter/falcon/resource.html index ae29f18b2..7a59a9c63 100644 --- a/docs/api-docs/slack_bolt/adapter/falcon/resource.html +++ b/docs/api-docs/slack_bolt/adapter/falcon/resource.html @@ -26,7 +26,7 @@

    Module slack_bolt.adapter.falcon.resource

    Expand source code -
    from datetime import datetime
    +
    from datetime import datetime  # type: ignore
     from http import HTTPStatus
     
     from falcon import Request, Response
    diff --git a/docs/api-docs/slack_bolt/adapter/flask/handler.html b/docs/api-docs/slack_bolt/adapter/flask/handler.html
    index 247dbba15..e69438873 100644
    --- a/docs/api-docs/slack_bolt/adapter/flask/handler.html
    +++ b/docs/api-docs/slack_bolt/adapter/flask/handler.html
    @@ -45,6 +45,9 @@ 

    Module slack_bolt.adapter.flask.handler

    def to_flask_response(bolt_resp: BoltResponse) -> Response: resp: Response = make_response(bolt_resp.body, bolt_resp.status) for k, values in bolt_resp.headers.items(): + if k.lower() == "content-type" and resp.headers.get("content-type") is not None: + # Remove the one set by Flask + resp.headers.pop("content-type") for v in values: resp.headers.add_header(k, v) return resp @@ -107,6 +110,9 @@

    Functions

    def to_flask_response(bolt_resp: BoltResponse) -> Response:
         resp: Response = make_response(bolt_resp.body, bolt_resp.status)
         for k, values in bolt_resp.headers.items():
    +        if k.lower() == "content-type" and resp.headers.get("content-type") is not None:
    +            # Remove the one set by Flask
    +            resp.headers.pop("content-type")
             for v in values:
                 resp.headers.add_header(k, v)
         return resp
    diff --git a/docs/api-docs/slack_bolt/adapter/sanic/async_handler.html b/docs/api-docs/slack_bolt/adapter/sanic/async_handler.html index 1577a05f6..ff0c7d1c3 100644 --- a/docs/api-docs/slack_bolt/adapter/sanic/async_handler.html +++ b/docs/api-docs/slack_bolt/adapter/sanic/async_handler.html @@ -26,7 +26,7 @@

    Module slack_bolt.adapter.sanic.async_handler

    Expand source code -
    from datetime import datetime
    +
    from datetime import datetime  # type: ignore
     
     from sanic.request import Request
     from sanic.response import HTTPResponse
    diff --git a/docs/api-docs/slack_bolt/adapter/starlette/async_handler.html b/docs/api-docs/slack_bolt/adapter/starlette/async_handler.html
    index 5f1ecf75a..82c371af7 100644
    --- a/docs/api-docs/slack_bolt/adapter/starlette/async_handler.html
    +++ b/docs/api-docs/slack_bolt/adapter/starlette/async_handler.html
    @@ -26,7 +26,9 @@ 

    Module slack_bolt.adapter.starlette.async_handler Expand source code -
    from starlette.requests import Request
    +
    from typing import Dict, Any, Optional
    +
    +from starlette.requests import Request
     from starlette.responses import Response
     
     from slack_bolt import BoltResponse
    @@ -34,12 +36,20 @@ 

    Module slack_bolt.adapter.starlette.async_handler from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow -def to_async_bolt_request(req: Request, body: bytes) -> AsyncBoltRequest: - return AsyncBoltRequest( +def to_async_bolt_request( + req: Request, + body: bytes, + addition_context_properties: Optional[Dict[str, Any]] = None, +) -> AsyncBoltRequest: + request = AsyncBoltRequest( body=body.decode("utf-8"), query=req.query_params, headers=req.headers, ) + if addition_context_properties is not None: + for k, v in addition_context_properties.items(): + request.context[k] = v + return request def to_starlette_response(bolt_resp: BoltResponse) -> Response: @@ -67,23 +77,27 @@

    Module slack_bolt.adapter.starlette.async_handler def __init__(self, app: AsyncApp): # type: ignore self.app = app - async def handle(self, req: Request) -> Response: + async def handle( + self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None + ) -> Response: body = await req.body() if req.method == "GET": if self.app.oauth_flow is not None: oauth_flow: AsyncOAuthFlow = self.app.oauth_flow if req.url.path == oauth_flow.install_path: bolt_resp = await oauth_flow.handle_installation( - to_async_bolt_request(req, body) + to_async_bolt_request(req, body, addition_context_properties) ) return to_starlette_response(bolt_resp) elif req.url.path == oauth_flow.redirect_uri_path: bolt_resp = await oauth_flow.handle_callback( - to_async_bolt_request(req, body) + to_async_bolt_request(req, body, addition_context_properties) ) return to_starlette_response(bolt_resp) elif req.method == "POST": - bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, body)) + bolt_resp = await self.app.async_dispatch( + to_async_bolt_request(req, body, addition_context_properties) + ) return to_starlette_response(bolt_resp) return Response( @@ -100,7 +114,7 @@

    Module slack_bolt.adapter.starlette.async_handler

    Functions

    -def to_async_bolt_request(req: starlette.requests.Request, body: bytes) ‑> AsyncBoltRequest +def to_async_bolt_request(req: starlette.requests.Request, body: bytes, addition_context_properties: Optional[Dict[str, Any]] = None) ‑> AsyncBoltRequest
    @@ -108,12 +122,20 @@

    Functions

    Expand source code -
    def to_async_bolt_request(req: Request, body: bytes) -> AsyncBoltRequest:
    -    return AsyncBoltRequest(
    +
    def to_async_bolt_request(
    +    req: Request,
    +    body: bytes,
    +    addition_context_properties: Optional[Dict[str, Any]] = None,
    +) -> AsyncBoltRequest:
    +    request = AsyncBoltRequest(
             body=body.decode("utf-8"),
             query=req.query_params,
             headers=req.headers,
    -    )
    + ) + if addition_context_properties is not None: + for k, v in addition_context_properties.items(): + request.context[k] = v + return request
    @@ -165,23 +187,27 @@

    Classes

    def __init__(self, app: AsyncApp): # type: ignore self.app = app - async def handle(self, req: Request) -> Response: + async def handle( + self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None + ) -> Response: body = await req.body() if req.method == "GET": if self.app.oauth_flow is not None: oauth_flow: AsyncOAuthFlow = self.app.oauth_flow if req.url.path == oauth_flow.install_path: bolt_resp = await oauth_flow.handle_installation( - to_async_bolt_request(req, body) + to_async_bolt_request(req, body, addition_context_properties) ) return to_starlette_response(bolt_resp) elif req.url.path == oauth_flow.redirect_uri_path: bolt_resp = await oauth_flow.handle_callback( - to_async_bolt_request(req, body) + to_async_bolt_request(req, body, addition_context_properties) ) return to_starlette_response(bolt_resp) elif req.method == "POST": - bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, body)) + bolt_resp = await self.app.async_dispatch( + to_async_bolt_request(req, body, addition_context_properties) + ) return to_starlette_response(bolt_resp) return Response( @@ -192,7 +218,7 @@

    Classes

    Methods

    -async def handle(self, req: starlette.requests.Request) ‑> starlette.responses.Response +async def handle(self, req: starlette.requests.Request, addition_context_properties: Optional[Dict[str, Any]] = None) ‑> starlette.responses.Response
    @@ -200,23 +226,27 @@

    Methods

    Expand source code -
    async def handle(self, req: Request) -> Response:
    +
    async def handle(
    +    self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None
    +) -> Response:
         body = await req.body()
         if req.method == "GET":
             if self.app.oauth_flow is not None:
                 oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
                 if req.url.path == oauth_flow.install_path:
                     bolt_resp = await oauth_flow.handle_installation(
    -                    to_async_bolt_request(req, body)
    +                    to_async_bolt_request(req, body, addition_context_properties)
                     )
                     return to_starlette_response(bolt_resp)
                 elif req.url.path == oauth_flow.redirect_uri_path:
                     bolt_resp = await oauth_flow.handle_callback(
    -                    to_async_bolt_request(req, body)
    +                    to_async_bolt_request(req, body, addition_context_properties)
                     )
                     return to_starlette_response(bolt_resp)
         elif req.method == "POST":
    -        bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, body))
    +        bolt_resp = await self.app.async_dispatch(
    +            to_async_bolt_request(req, body, addition_context_properties)
    +        )
             return to_starlette_response(bolt_resp)
     
         return Response(
    diff --git a/docs/api-docs/slack_bolt/adapter/starlette/handler.html b/docs/api-docs/slack_bolt/adapter/starlette/handler.html
    index cb77cba85..d605b274a 100644
    --- a/docs/api-docs/slack_bolt/adapter/starlette/handler.html
    +++ b/docs/api-docs/slack_bolt/adapter/starlette/handler.html
    @@ -26,19 +26,29 @@ 

    Module slack_bolt.adapter.starlette.handler

    Expand source code -
    from starlette.requests import Request
    +
    from typing import Dict, Any, Optional
    +
    +from starlette.requests import Request
     from starlette.responses import Response
     
     from slack_bolt import BoltRequest, App, BoltResponse
     from slack_bolt.oauth import OAuthFlow
     
     
    -def to_bolt_request(req: Request, body: bytes) -> BoltRequest:
    -    return BoltRequest(
    +def to_bolt_request(
    +    req: Request,
    +    body: bytes,
    +    addition_context_properties: Optional[Dict[str, Any]] = None,
    +) -> BoltRequest:
    +    request = BoltRequest(
             body=body.decode("utf-8"),
             query=req.query_params,
             headers=req.headers,
         )
    +    if addition_context_properties is not None:
    +        for k, v in addition_context_properties.items():
    +            request.context[k] = v
    +    return request
     
     
     def to_starlette_response(bolt_resp: BoltResponse) -> Response:
    @@ -66,21 +76,27 @@ 

    Module slack_bolt.adapter.starlette.handler

    def __init__(self, app: App): # type: ignore self.app = app - async def handle(self, req: Request) -> Response: + async def handle( + self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None + ) -> Response: body = await req.body() if req.method == "GET": if self.app.oauth_flow is not None: oauth_flow: OAuthFlow = self.app.oauth_flow if req.url.path == oauth_flow.install_path: bolt_resp = oauth_flow.handle_installation( - to_bolt_request(req, body) + to_bolt_request(req, body, addition_context_properties) ) return to_starlette_response(bolt_resp) elif req.url.path == oauth_flow.redirect_uri_path: - bolt_resp = oauth_flow.handle_callback(to_bolt_request(req, body)) + bolt_resp = oauth_flow.handle_callback( + to_bolt_request(req, body, addition_context_properties) + ) return to_starlette_response(bolt_resp) elif req.method == "POST": - bolt_resp = self.app.dispatch(to_bolt_request(req, body)) + bolt_resp = self.app.dispatch( + to_bolt_request(req, body, addition_context_properties) + ) return to_starlette_response(bolt_resp) return Response( @@ -97,7 +113,7 @@

    Module slack_bolt.adapter.starlette.handler

    Functions

    -def to_bolt_request(req: starlette.requests.Request, body: bytes) ‑> BoltRequest +def to_bolt_request(req: starlette.requests.Request, body: bytes, addition_context_properties: Optional[Dict[str, Any]] = None) ‑> BoltRequest
    @@ -105,12 +121,20 @@

    Functions

    Expand source code -
    def to_bolt_request(req: Request, body: bytes) -> BoltRequest:
    -    return BoltRequest(
    +
    def to_bolt_request(
    +    req: Request,
    +    body: bytes,
    +    addition_context_properties: Optional[Dict[str, Any]] = None,
    +) -> BoltRequest:
    +    request = BoltRequest(
             body=body.decode("utf-8"),
             query=req.query_params,
             headers=req.headers,
    -    )
    + ) + if addition_context_properties is not None: + for k, v in addition_context_properties.items(): + request.context[k] = v + return request
    @@ -162,21 +186,27 @@

    Classes

    def __init__(self, app: App): # type: ignore self.app = app - async def handle(self, req: Request) -> Response: + async def handle( + self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None + ) -> Response: body = await req.body() if req.method == "GET": if self.app.oauth_flow is not None: oauth_flow: OAuthFlow = self.app.oauth_flow if req.url.path == oauth_flow.install_path: bolt_resp = oauth_flow.handle_installation( - to_bolt_request(req, body) + to_bolt_request(req, body, addition_context_properties) ) return to_starlette_response(bolt_resp) elif req.url.path == oauth_flow.redirect_uri_path: - bolt_resp = oauth_flow.handle_callback(to_bolt_request(req, body)) + bolt_resp = oauth_flow.handle_callback( + to_bolt_request(req, body, addition_context_properties) + ) return to_starlette_response(bolt_resp) elif req.method == "POST": - bolt_resp = self.app.dispatch(to_bolt_request(req, body)) + bolt_resp = self.app.dispatch( + to_bolt_request(req, body, addition_context_properties) + ) return to_starlette_response(bolt_resp) return Response( @@ -187,7 +217,7 @@

    Classes

    Methods

    -async def handle(self, req: starlette.requests.Request) ‑> starlette.responses.Response +async def handle(self, req: starlette.requests.Request, addition_context_properties: Optional[Dict[str, Any]] = None) ‑> starlette.responses.Response
    @@ -195,21 +225,27 @@

    Methods

    Expand source code -
    async def handle(self, req: Request) -> Response:
    +
    async def handle(
    +    self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None
    +) -> Response:
         body = await req.body()
         if req.method == "GET":
             if self.app.oauth_flow is not None:
                 oauth_flow: OAuthFlow = self.app.oauth_flow
                 if req.url.path == oauth_flow.install_path:
                     bolt_resp = oauth_flow.handle_installation(
    -                    to_bolt_request(req, body)
    +                    to_bolt_request(req, body, addition_context_properties)
                     )
                     return to_starlette_response(bolt_resp)
                 elif req.url.path == oauth_flow.redirect_uri_path:
    -                bolt_resp = oauth_flow.handle_callback(to_bolt_request(req, body))
    +                bolt_resp = oauth_flow.handle_callback(
    +                    to_bolt_request(req, body, addition_context_properties)
    +                )
                     return to_starlette_response(bolt_resp)
         elif req.method == "POST":
    -        bolt_resp = self.app.dispatch(to_bolt_request(req, body))
    +        bolt_resp = self.app.dispatch(
    +            to_bolt_request(req, body, addition_context_properties)
    +        )
             return to_starlette_response(bolt_resp)
     
         return Response(
    diff --git a/docs/api-docs/slack_bolt/adapter/tornado/handler.html b/docs/api-docs/slack_bolt/adapter/tornado/handler.html
    index 3c1ca74b2..c41a9f48e 100644
    --- a/docs/api-docs/slack_bolt/adapter/tornado/handler.html
    +++ b/docs/api-docs/slack_bolt/adapter/tornado/handler.html
    @@ -26,7 +26,7 @@ 

    Module slack_bolt.adapter.tornado.handler

    Expand source code -
    from datetime import datetime
    +
    from datetime import datetime  # type: ignore
     
     from tornado.httputil import HTTPServerRequest
     from tornado.web import RequestHandler
    diff --git a/docs/api-docs/slack_bolt/context/async_context.html b/docs/api-docs/slack_bolt/context/async_context.html
    index a417e91b5..f20c1d0f5 100644
    --- a/docs/api-docs/slack_bolt/context/async_context.html
    +++ b/docs/api-docs/slack_bolt/context/async_context.html
    @@ -34,11 +34,29 @@ 

    Module slack_bolt.context.async_context

    from slack_bolt.context.base_context import BaseContext from slack_bolt.context.respond.async_respond import AsyncRespond from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.util.utils import create_copy class AsyncBoltContext(BaseContext): """Context object associated with a request from Slack.""" + def to_copyable(self) -> "AsyncBoltContext": + new_dict = {} + for prop_name, prop_value in self.items(): + if prop_name in self.standard_property_names: + # all the standard properties are copiable + new_dict[prop_name] = prop_value + else: + try: + copied_value = create_copy(prop_value) + new_dict[prop_name] = copied_value + except TypeError as te: + self.logger.debug( + f"Skipped settings '{prop_name}' to a copied request for lazy listeners " + f"as it's not possible to make a deep copy (error: {te})" + ) + return AsyncBoltContext(new_dict) + @property def client(self) -> Optional[AsyncWebClient]: """The `AsyncWebClient` instance available for this request. @@ -152,6 +170,23 @@

    Classes

    class AsyncBoltContext(BaseContext):
         """Context object associated with a request from Slack."""
     
    +    def to_copyable(self) -> "AsyncBoltContext":
    +        new_dict = {}
    +        for prop_name, prop_value in self.items():
    +            if prop_name in self.standard_property_names:
    +                # all the standard properties are copiable
    +                new_dict[prop_name] = prop_value
    +            else:
    +                try:
    +                    copied_value = create_copy(prop_value)
    +                    new_dict[prop_name] = copied_value
    +                except TypeError as te:
    +                    self.logger.debug(
    +                        f"Skipped settings '{prop_name}' to a copied request for lazy listeners "
    +                        f"as it's not possible to make a deep copy (error: {te})"
    +                    )
    +        return AsyncBoltContext(new_dict)
    +
         @property
         def client(self) -> Optional[AsyncWebClient]:
             """The `AsyncWebClient` instance available for this request.
    @@ -426,6 +461,36 @@ 

    Returns

    +

    Methods

    +
    +
    +def to_copyable(self) ‑> AsyncBoltContext +
    +
    +
    +
    + +Expand source code + +
    def to_copyable(self) -> "AsyncBoltContext":
    +    new_dict = {}
    +    for prop_name, prop_value in self.items():
    +        if prop_name in self.standard_property_names:
    +            # all the standard properties are copiable
    +            new_dict[prop_name] = prop_value
    +        else:
    +            try:
    +                copied_value = create_copy(prop_value)
    +                new_dict[prop_name] = copied_value
    +            except TypeError as te:
    +                self.logger.debug(
    +                    f"Skipped settings '{prop_name}' to a copied request for lazy listeners "
    +                    f"as it's not possible to make a deep copy (error: {te})"
    +                )
    +    return AsyncBoltContext(new_dict)
    +
    +
    +

    Inherited members

    diff --git a/docs/api-docs/slack_bolt/context/base_context.html b/docs/api-docs/slack_bolt/context/base_context.html index d694b9e20..fcf7bbb85 100644 --- a/docs/api-docs/slack_bolt/context/base_context.html +++ b/docs/api-docs/slack_bolt/context/base_context.html @@ -26,7 +26,10 @@

    Module slack_bolt.context.base_context

    Expand source code -
    from logging import Logger
    +
    # pytype: skip-file
    +# Note: Since 2021.12.8, the pytype code analyzer does not properly work for this file
    +
    +from logging import Logger
     from typing import Optional, Tuple
     
     from slack_bolt.authorization import AuthorizeResult
    @@ -35,6 +38,27 @@ 

    Module slack_bolt.context.base_context

    class BaseContext(dict): """Context object associated with a request from Slack.""" + standard_property_names = [ + "logger", + "token", + "enterprise_id", + "is_enterprise_install", + "team_id", + "user_id", + "channel_id", + "response_url", + "matches", + "authorize_result", + "bot_token", + "bot_id", + "bot_user_id", + "user_token", + "client", + "ack", + "say", + "respond", + ] + @property def logger(self) -> Logger: """The properly configured logger that is available for middleware/listeners.""" @@ -143,6 +167,27 @@

    Classes

    class BaseContext(dict):
         """Context object associated with a request from Slack."""
     
    +    standard_property_names = [
    +        "logger",
    +        "token",
    +        "enterprise_id",
    +        "is_enterprise_install",
    +        "team_id",
    +        "user_id",
    +        "channel_id",
    +        "response_url",
    +        "matches",
    +        "authorize_result",
    +        "bot_token",
    +        "bot_id",
    +        "bot_user_id",
    +        "user_token",
    +        "client",
    +        "ack",
    +        "say",
    +        "respond",
    +    ]
    +
         @property
         def logger(self) -> Logger:
             """The properly configured logger that is available for middleware/listeners."""
    @@ -237,6 +282,13 @@ 

    Subclasses

  • AsyncBoltContext
  • BoltContext
  • +

    Class variables

    +
    +
    var standard_property_names
    +
    +
    +
    +

    Instance variables

    var authorize_result : Optional[AuthorizeResult]
    @@ -479,6 +531,7 @@

    matches
  • response_url
  • set_authorize_result
  • +
  • standard_property_names
  • team_id
  • token
  • user_id
  • diff --git a/docs/api-docs/slack_bolt/context/context.html b/docs/api-docs/slack_bolt/context/context.html index 9cbda05ba..af4ad5f26 100644 --- a/docs/api-docs/slack_bolt/context/context.html +++ b/docs/api-docs/slack_bolt/context/context.html @@ -35,11 +35,30 @@

    Module slack_bolt.context.context

    from slack_bolt.context.base_context import BaseContext from slack_bolt.context.respond import Respond from slack_bolt.context.say import Say +from slack_bolt.util.utils import create_copy class BoltContext(BaseContext): """Context object associated with a request from Slack.""" + def to_copyable(self) -> "BoltContext": + new_dict = {} + for prop_name, prop_value in self.items(): + if prop_name in self.standard_property_names: + # all the standard properties are copiable + new_dict[prop_name] = prop_value + else: + try: + copied_value = create_copy(prop_value) + new_dict[prop_name] = copied_value + except TypeError as te: + self.logger.warning( + f"Skipped setting '{prop_name}' to a copied request for lazy listeners " + "due to a deep-copy creation error. Consider passing the value not as part of context object " + f"(error: {te})" + ) + return BoltContext(new_dict) + @property def client(self) -> Optional[WebClient]: """The `WebClient` instance available for this request. @@ -153,6 +172,24 @@

    Classes

    class BoltContext(BaseContext):
         """Context object associated with a request from Slack."""
     
    +    def to_copyable(self) -> "BoltContext":
    +        new_dict = {}
    +        for prop_name, prop_value in self.items():
    +            if prop_name in self.standard_property_names:
    +                # all the standard properties are copiable
    +                new_dict[prop_name] = prop_value
    +            else:
    +                try:
    +                    copied_value = create_copy(prop_value)
    +                    new_dict[prop_name] = copied_value
    +                except TypeError as te:
    +                    self.logger.warning(
    +                        f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
    +                        "due to a deep-copy creation error. Consider passing the value not as part of context object "
    +                        f"(error: {te})"
    +                    )
    +        return BoltContext(new_dict)
    +
         @property
         def client(self) -> Optional[WebClient]:
             """The `WebClient` instance available for this request.
    @@ -427,6 +464,37 @@ 

    Returns

    +

    Methods

    +
    +
    +def to_copyable(self) ‑> BoltContext +
    +
    +
    +
    + +Expand source code + +
    def to_copyable(self) -> "BoltContext":
    +    new_dict = {}
    +    for prop_name, prop_value in self.items():
    +        if prop_name in self.standard_property_names:
    +            # all the standard properties are copiable
    +            new_dict[prop_name] = prop_value
    +        else:
    +            try:
    +                copied_value = create_copy(prop_value)
    +                new_dict[prop_name] = copied_value
    +            except TypeError as te:
    +                self.logger.warning(
    +                    f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
    +                    "due to a deep-copy creation error. Consider passing the value not as part of context object "
    +                    f"(error: {te})"
    +                )
    +    return BoltContext(new_dict)
    +
    +
    +

    Inherited members

    diff --git a/docs/api-docs/slack_bolt/listener/asyncio_runner.html b/docs/api-docs/slack_bolt/listener/asyncio_runner.html index 7dc21f332..8f0a822aa 100644 --- a/docs/api-docs/slack_bolt/listener/asyncio_runner.html +++ b/docs/api-docs/slack_bolt/listener/asyncio_runner.html @@ -226,7 +226,7 @@

    Module slack_bolt.listener.asyncio_runner

    def _build_lazy_request( request: AsyncBoltRequest, lazy_func_name: str ) -> AsyncBoltRequest: - copied_request = create_copy(request) + copied_request = create_copy(request.to_copyable()) copied_request.method = "NONE" copied_request.lazy_only = True copied_request.lazy_function_name = lazy_func_name @@ -432,7 +432,7 @@

    Classes

    def _build_lazy_request( request: AsyncBoltRequest, lazy_func_name: str ) -> AsyncBoltRequest: - copied_request = create_copy(request) + copied_request = create_copy(request.to_copyable()) copied_request.method = "NONE" copied_request.lazy_only = True copied_request.lazy_function_name = lazy_func_name diff --git a/docs/api-docs/slack_bolt/listener/thread_runner.html b/docs/api-docs/slack_bolt/listener/thread_runner.html index 43c7da783..ce6a74e17 100644 --- a/docs/api-docs/slack_bolt/listener/thread_runner.html +++ b/docs/api-docs/slack_bolt/listener/thread_runner.html @@ -223,7 +223,7 @@

    Module slack_bolt.listener.thread_runner

    @staticmethod def _build_lazy_request(request: BoltRequest, lazy_func_name: str) -> BoltRequest: - copied_request = create_copy(request) + copied_request = create_copy(request.to_copyable()) copied_request.method = "NONE" copied_request.lazy_only = True copied_request.lazy_function_name = lazy_func_name @@ -432,7 +432,7 @@

    Classes

    @staticmethod def _build_lazy_request(request: BoltRequest, lazy_func_name: str) -> BoltRequest: - copied_request = create_copy(request) + copied_request = create_copy(request.to_copyable()) copied_request.method = "NONE" copied_request.lazy_only = True copied_request.lazy_function_name = lazy_func_name diff --git a/docs/api-docs/slack_bolt/request/async_request.html b/docs/api-docs/slack_bolt/request/async_request.html index 2106594cd..f415d8243 100644 --- a/docs/api-docs/slack_bolt/request/async_request.html +++ b/docs/api-docs/slack_bolt/request/async_request.html @@ -102,7 +102,16 @@

    Module slack_bolt.request.async_request

    self.lazy_function_name = self.headers.get( "x-slack-bolt-lazy-function-name", [None] )[0] - self.mode = mode
    + self.mode = mode + + def to_copyable(self) -> "AsyncBoltRequest": + return AsyncBoltRequest( + body=self.raw_body, + query=self.query, + headers=self.headers, + context=self.context.to_copyable(), + mode=self.mode, + )

    @@ -199,7 +208,16 @@

    Args

    self.lazy_function_name = self.headers.get( "x-slack-bolt-lazy-function-name", [None] )[0] - self.mode = mode
    + self.mode = mode + + def to_copyable(self) -> "AsyncBoltRequest": + return AsyncBoltRequest( + body=self.raw_body, + query=self.query, + headers=self.headers, + context=self.context.to_copyable(), + mode=self.mode, + )

    Class variables

    @@ -240,6 +258,28 @@

    Class variables

    +

    Methods

    +
    +
    +def to_copyable(self) ‑> AsyncBoltRequest +
    +
    +
    +
    + +Expand source code + +
    def to_copyable(self) -> "AsyncBoltRequest":
    +    return AsyncBoltRequest(
    +        body=self.raw_body,
    +        query=self.query,
    +        headers=self.headers,
    +        context=self.context.to_copyable(),
    +        mode=self.mode,
    +    )
    +
    +
    +

    @@ -269,6 +309,7 @@

    mode
  • query
  • raw_body
  • +
  • to_copyable
  • diff --git a/docs/api-docs/slack_bolt/request/request.html b/docs/api-docs/slack_bolt/request/request.html index deec3891a..f8fec2d0d 100644 --- a/docs/api-docs/slack_bolt/request/request.html +++ b/docs/api-docs/slack_bolt/request/request.html @@ -99,7 +99,16 @@

    Module slack_bolt.request.request

    self.lazy_function_name = self.headers.get( "x-slack-bolt-lazy-function-name", [None] )[0] - self.mode = mode
    + self.mode = mode + + def to_copyable(self) -> "BoltRequest": + return BoltRequest( + body=self.raw_body, + query=self.query, + headers=self.headers, + context=self.context.to_copyable(), + mode=self.mode, + )
    @@ -193,7 +202,16 @@

    Args

    self.lazy_function_name = self.headers.get( "x-slack-bolt-lazy-function-name", [None] )[0] - self.mode = mode + self.mode = mode + + def to_copyable(self) -> "BoltRequest": + return BoltRequest( + body=self.raw_body, + query=self.query, + headers=self.headers, + context=self.context.to_copyable(), + mode=self.mode, + )

    Class variables

    @@ -234,6 +252,28 @@

    Class variables

    +

    Methods

    +
    +
    +def to_copyable(self) ‑> BoltRequest +
    +
    +
    +
    + +Expand source code + +
    def to_copyable(self) -> "BoltRequest":
    +    return BoltRequest(
    +        body=self.raw_body,
    +        query=self.query,
    +        headers=self.headers,
    +        context=self.context.to_copyable(),
    +        mode=self.mode,
    +    )
    +
    +
    +
    @@ -263,6 +303,7 @@

    mode
  • query
  • raw_body
  • +
  • to_copyable
  • diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index 816a33b11..860e5fc2e 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.10.0"
    +__version__ = "1.11.0"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 0872b5816..63a520b24 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.10.0" +__version__ = "1.11.0" From f3f47180e752d0e22a8bc745e30c9ae1fc706dbf Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 24 Dec 2021 10:40:56 +0900 Subject: [PATCH 434/865] Update tests due to Sanic's breaking change --- tests/adapter_tests_async/test_async_sanic.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py index 0dda90ca0..d79b5de7a 100644 --- a/tests/adapter_tests_async/test_async_sanic.py +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -219,9 +219,7 @@ async def test_oauth(self): async def endpoint(req: Request): return await app_handler.handle(req) - _, response = await api.asgi_client.get( - url="/slack/install", allow_redirects=False - ) + _, response = await api.asgi_client.get(url="/slack/install") assert response.status_code == 200 assert response.headers.get("content-type") == "text/html; charset=utf-8" From d7af0bc552cea79f95f54ad091d9f5fbc71cb54e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 24 Dec 2021 11:29:08 +0900 Subject: [PATCH 435/865] Fix #552 Unable to use request body with lazy listener when socket mode is enabled (#555) --- slack_bolt/request/async_request.py | 3 +- slack_bolt/request/request.py | 3 +- .../socket_mode/test_lazy_listeners.py | 79 +++++++++++++++++ .../socket_mode/test_async_lazy_listeners.py | 84 +++++++++++++++++++ 4 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tests/adapter_tests/socket_mode/test_lazy_listeners.py create mode 100644 tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py diff --git a/slack_bolt/request/async_request.py b/slack_bolt/request/async_request.py index e590bc93b..93aa24520 100644 --- a/slack_bolt/request/async_request.py +++ b/slack_bolt/request/async_request.py @@ -77,8 +77,9 @@ def __init__( self.mode = mode def to_copyable(self) -> "AsyncBoltRequest": + body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body return AsyncBoltRequest( - body=self.raw_body, + body=body, query=self.query, headers=self.headers, context=self.context.to_copyable(), diff --git a/slack_bolt/request/request.py b/slack_bolt/request/request.py index 4810d2018..f1674b20d 100644 --- a/slack_bolt/request/request.py +++ b/slack_bolt/request/request.py @@ -74,8 +74,9 @@ def __init__( self.mode = mode def to_copyable(self) -> "BoltRequest": + body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body return BoltRequest( - body=self.raw_body, + body=body, query=self.query, headers=self.headers, context=self.context.to_copyable(), diff --git a/tests/adapter_tests/socket_mode/test_lazy_listeners.py b/tests/adapter_tests/socket_mode/test_lazy_listeners.py new file mode 100644 index 000000000..441dda3f5 --- /dev/null +++ b/tests/adapter_tests/socket_mode/test_lazy_listeners.py @@ -0,0 +1,79 @@ +import logging +import time + +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, + stop_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 TestSocketModeLazyListeners: + 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", + ) + start_socket_mode_server(self, 3011) + time.sleep(2) # wait for the server + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + stop_socket_mode_server(self) + + def test_lazy_listener_calls(self): + + app = App(client=self.web_client) + + result = {"lazy_called": False} + + @app.shortcut("do-something") + def handle_shortcuts(ack): + ack() + + @app.event("message") + def handle_message_events(body, logger): + logger.info(body) + + def lazy_func(body): + assert body.get("command") == "/hello-socket-mode" + result["lazy_called"] = True + + app.command("/hello-socket-mode")( + ack=lambda ack: ack(), + lazy=[lazy_func], + ) + + handler = SocketModeHandler( + app_token="xapp-A111-222-xyz", + app=app, + trace_enabled=True, + ) + try: + 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") + + spent_time = 0 + while spent_time < 5 and result["lazy_called"] is False: + spent_time += 0.5 + time.sleep(0.5) + assert result["lazy_called"] is True + + finally: + handler.client.close() diff --git a/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py new file mode 100644 index 000000000..28d24c399 --- /dev/null +++ b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py @@ -0,0 +1,84 @@ +import asyncio + +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, + stop_socket_mode_server_async, +) + + +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_lazy_listeners(self): + start_socket_mode_server(self, 3021) + await asyncio.sleep(1) # wait for the server + + app = AsyncApp(client=self.web_client) + + result = {"lazy_called": False} + + @app.shortcut("do-something") + async def shortcut_handler(ack): + await ack() + + @app.event("message") + async def handle_message_events(body, logger): + logger.info(body) + + async def just_ack(ack): + await ack() + + async def lazy_func(body): + assert body.get("command") == "/hello-socket-mode" + result["lazy_called"] = True + + app.command("/hello-socket-mode")(ack=just_ack, lazy=[lazy_func]) + + 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") + + spent_time = 0 + while spent_time < 5 and result["lazy_called"] is False: + spent_time += 0.5 + await asyncio.sleep(0.5) + assert result["lazy_called"] is True + + finally: + await handler.client.close() + await stop_socket_mode_server_async(self) From 0b980ecfd1422dec7596232c9988d129d2d95032 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 24 Dec 2021 11:40:08 +0900 Subject: [PATCH 436/865] Upgrade pytype to 2021.12.15 --- scripts/run_pytype.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index 3523791e0..43f43d225 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -5,5 +5,5 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ pip install -e ".[async]" && \ pip install -e ".[adapter]" && \ - pip install "pytype==2021.12.8" && \ + pip install "pytype==2021.12.15" && \ pytype slack_bolt/ From 8cf608783024b6561717598ae4db1155bd2b2f6f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 24 Dec 2021 11:45:16 +0900 Subject: [PATCH 437/865] version 1.11.1 --- docs/api-docs/slack_bolt/request/async_request.html | 9 ++++++--- docs/api-docs/slack_bolt/request/request.html | 9 ++++++--- docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/api-docs/slack_bolt/request/async_request.html b/docs/api-docs/slack_bolt/request/async_request.html index f415d8243..914f44d24 100644 --- a/docs/api-docs/slack_bolt/request/async_request.html +++ b/docs/api-docs/slack_bolt/request/async_request.html @@ -105,8 +105,9 @@

    Module slack_bolt.request.async_request

    self.mode = mode def to_copyable(self) -> "AsyncBoltRequest": + body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body return AsyncBoltRequest( - body=self.raw_body, + body=body, query=self.query, headers=self.headers, context=self.context.to_copyable(), @@ -211,8 +212,9 @@

    Args

    self.mode = mode def to_copyable(self) -> "AsyncBoltRequest": + body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body return AsyncBoltRequest( - body=self.raw_body, + body=body, query=self.query, headers=self.headers, context=self.context.to_copyable(), @@ -270,8 +272,9 @@

    Methods

    Expand source code
    def to_copyable(self) -> "AsyncBoltRequest":
    +    body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
         return AsyncBoltRequest(
    -        body=self.raw_body,
    +        body=body,
             query=self.query,
             headers=self.headers,
             context=self.context.to_copyable(),
    diff --git a/docs/api-docs/slack_bolt/request/request.html b/docs/api-docs/slack_bolt/request/request.html
    index f8fec2d0d..bec3bdea9 100644
    --- a/docs/api-docs/slack_bolt/request/request.html
    +++ b/docs/api-docs/slack_bolt/request/request.html
    @@ -102,8 +102,9 @@ 

    Module slack_bolt.request.request

    self.mode = mode def to_copyable(self) -> "BoltRequest": + body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body return BoltRequest( - body=self.raw_body, + body=body, query=self.query, headers=self.headers, context=self.context.to_copyable(), @@ -205,8 +206,9 @@

    Args

    self.mode = mode def to_copyable(self) -> "BoltRequest": + body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body return BoltRequest( - body=self.raw_body, + body=body, query=self.query, headers=self.headers, context=self.context.to_copyable(), @@ -264,8 +266,9 @@

    Methods

    Expand source code
    def to_copyable(self) -> "BoltRequest":
    +    body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
         return BoltRequest(
    -        body=self.raw_body,
    +        body=body,
             query=self.query,
             headers=self.headers,
             context=self.context.to_copyable(),
    diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html
    index 860e5fc2e..b2166e22a 100644
    --- a/docs/api-docs/slack_bolt/version.html
    +++ b/docs/api-docs/slack_bolt/version.html
    @@ -28,7 +28,7 @@ 

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.11.0"
    +__version__ = "1.11.1"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 63a520b24..df976cb3e 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.11.0" +__version__ = "1.11.1" From 0ee6bb7e382798707d2baa595a958c24fcde2f7d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 18 Jan 2022 07:34:13 +0900 Subject: [PATCH 438/865] Fix #561 matchers can be called even when app.message keyword does not match (#577) * Fix #561 matchers can be called even when app.message keyword does not match * Fix a bug --- slack_bolt/app/app.py | 4 +- slack_bolt/app/async_app.py | 4 +- slack_bolt/listener_matcher/builtins.py | 94 +++++++++++++++------- tests/scenario_tests/test_message.py | 18 +++++ tests/scenario_tests_async/test_message.py | 23 +++++- 5 files changed, 107 insertions(+), 36 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index e3eea413f..7f6627026 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -810,7 +810,9 @@ def __call__(*args, **kwargs): "thread_broadcast", ), } - primary_matcher = builtin_matchers.event(constraints=constraints) + primary_matcher = builtin_matchers.message_event( + keyword=keyword, constraints=constraints + ) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index e44d1d59d..a479e0d90 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -860,8 +860,8 @@ def __call__(*args, **kwargs): "thread_broadcast", ), } - primary_matcher = builtin_matchers.event( - constraints=constraints, asyncio=True + primary_matcher = builtin_matchers.message_event( + constraints=constraints, keyword=keyword, asyncio=True ) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener( diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index cffcf95c1..1b46183a6 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -1,5 +1,6 @@ # pytype: skip-file import inspect +import re import sys from slack_bolt.error import BoltError @@ -95,37 +96,10 @@ def func(body: Dict[str, Any]) -> bool: def func(body: Dict[str, Any]) -> bool: if is_event(body): - event = body["event"] - if not _matches(constraints["type"], event["type"]): - return False - if "subtype" in constraints: - expected_subtype: Union[ - str, Sequence[Optional[Union[str, Pattern]]] - ] = constraints["subtype"] - if expected_subtype is None: - # "subtype" in constraints is intentionally None for this pattern - return "subtype" not in event - elif isinstance(expected_subtype, (str, Pattern)): - return "subtype" in event and _matches( - expected_subtype, event["subtype"] - ) - elif isinstance(expected_subtype, Sequence): - subtypes: Sequence[ - Optional[Union[str, Pattern]] - ] = expected_subtype - for expected in subtypes: - actual: Optional[str] = event.get("subtype") - if expected is None: - if actual is None: - return True - elif actual is not None and _matches(expected, actual): - return True - return False - else: - return "subtype" in event and _matches( - expected_subtype, event["subtype"] - ) - return True + return _check_event_subtype( + event_payload=body["event"], + constraints=constraints, + ) return False return build_listener_matcher(func, asyncio) @@ -135,6 +109,64 @@ def func(body: Dict[str, Any]) -> bool: ) +def message_event( + constraints: Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]], + keyword: Union[str, Pattern], + asyncio: bool = False, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + if "type" in constraints and keyword is not None: + _verify_message_event_type(constraints["type"]) + + def func(body: Dict[str, Any]) -> bool: + if is_event(body): + is_valid_subtype = _check_event_subtype( + event_payload=body["event"], + constraints=constraints, + ) + if is_valid_subtype is True: + # Check keyword matching + text = body.get("event", {}).get("text", "") + match_result = re.findall(keyword, text) + if match_result is not None and match_result != []: + return True + return False + + return build_listener_matcher(func, asyncio) + + raise BoltError(f"event ({constraints}: {type(constraints)}) must be dict") + + +def _check_event_subtype(event_payload: dict, constraints: dict) -> bool: + if not _matches(constraints["type"], event_payload["type"]): + return False + if "subtype" in constraints: + expected_subtype: Union[ + str, Sequence[Optional[Union[str, Pattern]]] + ] = constraints["subtype"] + if expected_subtype is None: + # "subtype" in constraints is intentionally None for this pattern + return "subtype" not in event_payload + elif isinstance(expected_subtype, (str, Pattern)): + return "subtype" in event_payload and _matches( + expected_subtype, event_payload["subtype"] + ) + elif isinstance(expected_subtype, Sequence): + subtypes: Sequence[Optional[Union[str, Pattern]]] = expected_subtype + for expected in subtypes: + actual: Optional[str] = event_payload.get("subtype") + if expected is None: + if actual is None: + return True + elif actual is not None and _matches(expected, actual): + return True + return False + else: + return "subtype" in event_payload and _matches( + expected_subtype, event_payload["subtype"] + ) + return True + + def _verify_message_event_type(event_type: str) -> None: if isinstance(event_type, str) and event_type.startswith("message."): raise ValueError(error_message_event_type(event_type)) diff --git a/tests/scenario_tests/test_message.py b/tests/scenario_tests/test_message.py index a7f7c0be0..e3fca87e7 100644 --- a/tests/scenario_tests/test_message.py +++ b/tests/scenario_tests/test_message.py @@ -205,6 +205,24 @@ def second(): assert called["first"] == False assert called["second"] == True + def test_issue_561_matchers(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def just_fail(): + raise "This matcher should not be called!" + + @app.message("xxx", matchers=[just_fail]) + def just_ack(): + raise "This listener should not be called!" + + request = self.build_request() + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) + message_body = { "token": "verification_token", diff --git a/tests/scenario_tests_async/test_message.py b/tests/scenario_tests_async/test_message.py index 43600363e..a7bcd1ab7 100644 --- a/tests/scenario_tests_async/test_message.py +++ b/tests/scenario_tests_async/test_message.py @@ -219,8 +219,27 @@ async def second(): assert response.status == 200 await asyncio.sleep(0.3) - assert called["first"] == False - assert called["second"] == True + assert called["first"] is False + assert called["second"] is True + + @pytest.mark.asyncio + async def test_issue_561_matchers(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def just_fail(): + raise "This matcher should not be called!" + + @app.message("xxx", matchers=[just_fail]) + async def just_ack(): + raise "This listener should not be called!" + + request = self.build_request() + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) message_body = { From 5ecf7e368c139c6a97ddf508131b35b1482bcc80 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 18 Jan 2022 08:04:13 +0900 Subject: [PATCH 439/865] Improve the built-in authorize for better support of user-scope only installations (#576) --- slack_bolt/app/app.py | 1 + slack_bolt/app/async_app.py | 1 + slack_bolt/authorization/async_authorize.py | 109 +++++++++----- slack_bolt/authorization/authorize.py | 123 ++++++++++----- tests/mock_web_api_server.py | 114 ++++++++++---- .../authorization/test_authorize.py | 140 +++++++++++++++++ .../authorization/test_async_authorize.py | 142 ++++++++++++++++++ 7 files changed, 529 insertions(+), 101 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 7f6627026..2679dca01 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -231,6 +231,7 @@ def message_hello(message, say): client_secret=settings.client_secret if settings is not None else None, logger=self._framework_logger, bot_only=installation_store_bot_only, + client=self._client, # for proxy use cases etc. ) self._oauth_flow: Optional[OAuthFlow] = None diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index a479e0d90..b80b650c8 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -239,6 +239,7 @@ async def message_hello(message, say): # async function client_secret=settings.client_secret if settings is not None else None, logger=self._framework_logger, bot_only=installation_store_bot_only, + client=self._async_client, # for proxy use cases etc. ) self._async_oauth_flow: Optional[AsyncOAuthFlow] = None diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index 09aa9c179..6d37924c5 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -8,6 +8,7 @@ AsyncInstallationStore, ) from slack_sdk.oauth.token_rotation.async_rotator import AsyncTokenRotator +from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.authorization.async_authorize_args import AsyncAuthorizeArgs from slack_bolt.authorization import AuthorizeResult @@ -124,6 +125,7 @@ def __init__( # use only InstallationStore#find_bot(enterprise_id, team_id) bot_only: bool = False, cache_enabled: bool = False, + client: Optional[AsyncWebClient] = None, ): self.logger = logger self.installation_store = installation_store @@ -136,6 +138,7 @@ def __init__( self.token_rotator = AsyncTokenRotator( client_id=client_id, client_secret=client_secret, + client=client, ) else: self.token_rotator = None @@ -168,23 +171,35 @@ async def __call__( try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. - installation: Optional[ + latest_installation: Optional[ Installation ] = await self.installation_store.async_find_installation( enterprise_id=enterprise_id, team_id=team_id, is_enterprise_install=context.is_enterprise_install, ) - - if installation is not None: - if installation.user_id != user_id: + # If the user_token in the latest_installation is not for the user associated with this request, + # we'll fetch a different installation for the user below + # The example use cases are: + # - The app's installation requires both bot and user tokens + # - The app has two installation paths 1) bot installation 2) individual user authorization + this_user_installation: Optional[Installation] = None + + if latest_installation is not None: + # Save the latest bot token + bot_token = latest_installation.bot_token # this still can be None + user_token = ( + latest_installation.user_token + ) # this still can be None + + if latest_installation.user_id != user_id: # First off, remove the user token as the installer is a different user - installation.user_token = None - installation.user_scopes = [] + latest_installation.user_token = None + latest_installation.user_scopes = [] # try to fetch the request user's installation # to reflect the user's access token if exists - user_installation = ( + this_user_installation = ( await self.installation_store.async_find_installation( enterprise_id=enterprise_id, team_id=team_id, @@ -192,41 +207,42 @@ async def __call__( is_enterprise_install=context.is_enterprise_install, ) ) - if user_installation is not None: - # Overwrite the installation with the one for this user - installation = user_installation - - bot_token, user_token = ( - installation.bot_token, - installation.user_token, - ) - - if ( - installation.user_refresh_token is not None - or installation.bot_refresh_token is not None - ): - if self.token_rotator is None: - raise BoltError(self._config_error_message) - refreshed = await self.token_rotator.perform_token_rotation( - installation=installation, - minutes_before_expiration=self.token_rotation_expiration_minutes, - ) - if refreshed is not None: - await self.installation_store.async_save(refreshed) - bot_token, user_token = ( - refreshed.bot_token, - refreshed.user_token, + if this_user_installation is not None: + user_token = this_user_installation.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = this_user_installation.bot_token + + # If token rotation is enabled, running rotation may be needed here + refreshed = await self._rotate_and_save_tokens_if_necessary( + this_user_installation ) + if refreshed is not None: + user_token = refreshed.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = refreshed.bot_token + + # If token rotation is enabled, running rotation may be needed here + refreshed = await self._rotate_and_save_tokens_if_necessary( + latest_installation + ) + if refreshed is not None: + bot_token = refreshed.bot_token + if this_user_installation is None: + # Only when we don't have `this_user_installation` here, + # the `user_token` is for the user associated with this request + user_token = refreshed.user_token except NotImplementedError as _: self.find_installation_available = False if ( - # If you intentionally use only find_bot / delete_bot, + # If you intentionally use only `find_bot` / `delete_bot`, self.bot_only - # If find_installation method is not available, + # If the `find_installation` method is not available, or not self.find_installation_available - # If find_installation did not return data and find_bot method is available, + # If the `find_installation` method did not return data and find_bot method is available, or ( self.find_bot_available is True and bot_token is None @@ -294,3 +310,28 @@ def _debug_log_for_not_found( "No installation data found " f"for enterprise_id: {enterprise_id} team_id: {team_id}" ) + + async def _rotate_and_save_tokens_if_necessary( + self, installation: Optional[Installation] + ) -> Optional[Installation]: + if installation is None or ( + installation.user_refresh_token is None + and installation.bot_refresh_token is None + ): + # No need to rotate tokens + return None + + if self.token_rotator is None: + # Token rotation is required but this Bolt app is not properly configured + raise BoltError(self._config_error_message) + + refreshed: Optional[ + Installation + ] = await self.token_rotator.perform_token_rotation( + installation=installation, + minutes_before_expiration=self.token_rotation_expiration_minutes, + ) + if refreshed is not None: + # Save the refreshed data in database for following requests + await self.installation_store.async_save(refreshed) + return refreshed diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index aaa4d2ed4..5cd9a0193 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -4,9 +4,10 @@ from slack_sdk.errors import SlackApiError from slack_sdk.oauth import InstallationStore -from slack_sdk.oauth.installation_store import Bot +from slack_sdk.oauth.installation_store.models.bot import Bot from slack_sdk.oauth.installation_store.models.installation import Installation from slack_sdk.oauth.token_rotation.rotator import TokenRotator +from slack_sdk.web import WebClient from slack_bolt.authorization.authorize_args import AuthorizeArgs from slack_bolt.authorization.authorize_result import AuthorizeResult @@ -33,8 +34,8 @@ def __call__( class CallableAuthorize(Authorize): - """When you pass the authorize argument in AsyncApp constructor, - This authorize implementation will be used. + """When you pass the `authorize` argument in AsyncApp constructor, + This `authorize` implementation will be used. """ def __init__( @@ -102,9 +103,9 @@ def __call__( class InstallationStoreAuthorize(Authorize): - """If you use the OAuth flow settings, this authorize implementation will be used. + """If you use the OAuth flow settings, this `authorize` implementation will be used. As long as your own InstallationStore (or the built-in ones) works as you expect, - you can expect that the authorize layer should work for you without any customization. + you can expect that the `authorize` layer should work for you without any customization. """ authorize_result_cache: Dict[str, AuthorizeResult] @@ -129,6 +130,7 @@ def __init__( # use only InstallationStore#find_bot(enterprise_id, team_id) bot_only: bool = False, cache_enabled: bool = False, + client: Optional[WebClient] = None, ): self.logger = logger self.installation_store = installation_store @@ -143,6 +145,7 @@ def __init__( self.token_rotator = TokenRotator( client_id=client_id, client_secret=client_secret, + client=client, ) else: self.token_rotator = None @@ -168,61 +171,78 @@ def __call__( try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. - installation: Optional[ + latest_installation: Optional[ Installation ] = self.installation_store.find_installation( enterprise_id=enterprise_id, team_id=team_id, is_enterprise_install=context.is_enterprise_install, ) - if installation is not None: - if installation.user_id != user_id: + # If the user_token in the latest_installation is not for the user associated with this request, + # we'll fetch a different installation for the user below. + # The example use cases are: + # - The app's installation requires both bot and user tokens + # - The app has two installation paths 1) bot installation 2) individual user authorization + this_user_installation: Optional[Installation] = None + + if latest_installation is not None: + # Save the latest bot token + bot_token = latest_installation.bot_token # this still can be None + user_token = ( + latest_installation.user_token + ) # this still can be None + + if latest_installation.user_id != user_id: # First off, remove the user token as the installer is a different user - installation.user_token = None - installation.user_scopes = [] + latest_installation.user_token = None + latest_installation.user_scopes = [] # try to fetch the request user's installation # to reflect the user's access token if exists - user_installation = self.installation_store.find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, + this_user_installation = ( + self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) ) - if user_installation is not None: - # Overwrite the installation with the one for this user - installation = user_installation + if this_user_installation is not None: + user_token = this_user_installation.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = this_user_installation.bot_token - bot_token, user_token = ( - installation.bot_token, - installation.user_token, - ) - if ( - installation.user_refresh_token is not None - or installation.bot_refresh_token is not None - ): - if self.token_rotator is None: - raise BoltError(self._config_error_message) - refreshed = self.token_rotator.perform_token_rotation( - installation=installation, - minutes_before_expiration=self.token_rotation_expiration_minutes, - ) - if refreshed is not None: - self.installation_store.save(refreshed) - bot_token, user_token = ( - refreshed.bot_token, - refreshed.user_token, + # If token rotation is enabled, running rotation may be needed here + refreshed = self._rotate_and_save_tokens_if_necessary( + this_user_installation ) + if refreshed is not None: + user_token = refreshed.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = refreshed.bot_token + + # If token rotation is enabled, running rotation may be needed here + refreshed = self._rotate_and_save_tokens_if_necessary( + latest_installation + ) + if refreshed is not None: + bot_token = refreshed.bot_token + if this_user_installation is None: + # Only when we don't have `this_user_installation` here, + # the `user_token` is for the user associated with this request + user_token = refreshed.user_token except NotImplementedError as _: self.find_installation_available = False if ( - # If you intentionally use only find_bot / delete_bot, + # If you intentionally use only `find_bot` / `delete_bot`, self.bot_only - # If find_installation method is not available, + # If the `find_installation` method is not available, or not self.find_installation_available - # If find_installation did not return data and find_bot method is available, + # If the `find_installation` method did not return data and find_bot method is available, or ( self.find_bot_available is True and bot_token is None @@ -290,3 +310,26 @@ def _debug_log_for_not_found( "No installation data found " f"for enterprise_id: {enterprise_id} team_id: {team_id}" ) + + def _rotate_and_save_tokens_if_necessary( + self, installation: Optional[Installation] + ) -> Optional[Installation]: + if installation is None or ( + installation.user_refresh_token is None + and installation.bot_refresh_token is None + ): + # No need to rotate tokens + return None + + if self.token_rotator is None: + # Token rotation is required but this Bolt app is not properly configured + raise BoltError(self._config_error_message) + + refreshed: Optional[Installation] = self.token_rotator.perform_token_rotation( + installation=installation, + minutes_before_expiration=self.token_rotation_expiration_minutes, + ) + if refreshed is not None: + # Save the refreshed data in database for following requests + self.installation_store.save(refreshed) + return refreshed diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index 2c3204912..6c905f8b5 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -6,7 +6,7 @@ import time from http import HTTPStatus from http.server import HTTPServer, SimpleHTTPRequestHandler -from typing import Type +from typing import Type, Optional from unittest import TestCase from urllib.parse import urlparse, parse_qs, ParseResult @@ -65,7 +65,46 @@ def set_common_headers(self): "token_type": "user" } } - """ +""" + oauth_v2_access_bot_refresh_response = """ + { + "ok": true, + "app_id": "A0KRD7HC3", + "access_token": "xoxb-valid-refreshed", + "expires_in": 43200, + "refresh_token": "xoxe-1-valid-bot-refreshed", + "token_type": "bot", + "scope": "chat:write,commands", + "bot_user_id": "U0KRQLJ9H", + "team": { + "name": "Slack Softball Team", + "id": "T9TK3CUKW" + }, + "enterprise": { + "name": "slack-sports", + "id": "E12345678" + } + } +""" + oauth_v2_access_user_refresh_response = """ + { + "ok": true, + "app_id": "A0KRD7HC3", + "access_token": "xoxp-valid-refreshed", + "expires_in": 43200, + "refresh_token": "xoxe-1-valid-user-refreshed", + "token_type": "user", + "scope": "search:read", + "team": { + "name": "Slack Softball Team", + "id": "T9TK3CUKW" + }, + "enterprise": { + "name": "slack-sports", + "id": "E12345678" + } + } + """ bot_auth_test_response = """ { "ok": true, @@ -108,10 +147,31 @@ def _handle(self): body = {"ok": True} if path == "/oauth.v2.access": - self.send_response(200) - self.set_common_headers() - self.wfile.write(self.oauth_v2_access_response.encode("utf-8")) - return + if self.headers.get("authorization") is not None: + request_body = self._parse_request_body( + parsed_path=parsed_path, + content_len=int(self.headers.get("Content-Length") or 0), + ) + self.logger.info(f"request body: {request_body}") + + if request_body.get("grant_type") == "refresh_token": + if "bot-valid" in request_body.get("refresh_token"): + self.send_response(200) + self.set_common_headers() + body = self.oauth_v2_access_bot_refresh_response + self.wfile.write(body.encode("utf-8")) + return + if "user-valid" in request_body.get("refresh_token"): + self.send_response(200) + self.set_common_headers() + body = self.oauth_v2_access_user_refresh_response + self.wfile.write(body.encode("utf-8")) + return + if request_body.get("code") is not None: + self.send_response(200) + self.set_common_headers() + self.wfile.write(self.oauth_v2_access_response.encode("utf-8")) + return if self.is_valid_user_token(): if path == "/auth.test": @@ -127,27 +187,10 @@ def _handle(self): self.wfile.write(self.bot_auth_test_response.encode("utf-8")) return - 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() - } - + request_body = self._parse_request_body( + parsed_path=parsed_path, + content_len=int(self.headers.get("Content-Length") or 0), + ) self.logger.info(f"request: {path} {request_body}") header = self.headers["authorization"] @@ -175,6 +218,23 @@ def do_GET(self): def do_POST(self): self._handle() + def _parse_request_body(self, parsed_path: str, content_len: int) -> Optional[dict]: + 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()} + return request_body + # # multiprocessing diff --git a/tests/slack_bolt/authorization/test_authorize.py b/tests/slack_bolt/authorization/test_authorize.py index eb5c02087..c4d64a91d 100644 --- a/tests/slack_bolt/authorization/test_authorize.py +++ b/tests/slack_bolt/authorization/test_authorize.py @@ -10,6 +10,7 @@ from slack_bolt import BoltContext from slack_bolt.authorization.authorize import InstallationStoreAuthorize, Authorize +from slack_bolt.error import BoltError from tests.mock_web_api_server import ( cleanup_mock_web_api_server, setup_mock_web_api_server, @@ -195,6 +196,55 @@ def test_installation_store_cached(self): assert result.user_token == "xoxp-valid" assert_auth_test_count(self, 1) # cached + def test_fetch_different_user_token(self): + installation_store = ValidUserTokenInstallationStore() + authorize = InstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W222" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid" + assert result.user_token == "xoxp-valid" + assert_auth_test_count(self, 1) + + def test_fetch_different_user_token_with_rotation(self): + context = BoltContext() + mock_client = WebClient(base_url=self.mock_api_server_base_url) + context["client"] = mock_client + + installation_store = ValidUserTokenRotationInstallationStore() + invalid_authorize = InstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + with pytest.raises(BoltError): + invalid_authorize( + context=context, + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W222", + ) + + authorize = InstallationStoreAuthorize( + client_id="111.222", + client_secret="secret", + client=mock_client, + logger=installation_store.logger, + installation_store=installation_store, + ) + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W222" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid-refreshed" + assert result.user_token == "xoxp-valid-refreshed" + assert_auth_test_count(self, 1) + class LegacyMemoryInstallationStore(InstallationStore): @property @@ -257,3 +307,93 @@ def find_installation( is_enterprise_install: Optional[bool] = False, ) -> Optional[Installation]: raise ValueError + + +class ValidUserTokenInstallationStore(InstallationStore): + @property + def logger(self) -> Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + if user_id is None: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-different-installer", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + elif user_id == "W222": + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W222", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class ValidUserTokenRotationInstallationStore(InstallationStore): + @property + def logger(self) -> Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + if user_id is None: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_refresh_token="xoxe-bot-valid", + bot_token_expires_in=-10, + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-different-installer", + user_refresh_token="xoxe-1-user-valid", + user_token_expires_in=-10, + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + elif user_id == "W222": + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W222", + user_token="xoxp-valid", + user_refresh_token="xoxe-1-user-valid", + user_token_expires_in=-10, + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) diff --git a/tests/slack_bolt_async/authorization/test_async_authorize.py b/tests/slack_bolt_async/authorization/test_async_authorize.py index a19190ae5..495d70302 100644 --- a/tests/slack_bolt_async/authorization/test_async_authorize.py +++ b/tests/slack_bolt_async/authorization/test_async_authorize.py @@ -16,6 +16,7 @@ AsyncAuthorize, ) from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.error import BoltError from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -220,6 +221,57 @@ async def test_installation_store_cached(self): assert result.user_token == "xoxp-valid" await assert_auth_test_count_async(self, 1) # cached + @pytest.mark.asyncio + async def test_fetch_different_user_token(self): + installation_store = ValidUserTokenInstallationStore() + authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + context = AsyncBoltContext() + context["client"] = AsyncWebClient(base_url=self.mock_api_server_base_url) + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W222" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid" + assert result.user_token == "xoxp-valid" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_fetch_different_user_token_with_rotation(self): + context = AsyncBoltContext() + mock_client = AsyncWebClient(base_url=self.mock_api_server_base_url) + context["client"] = mock_client + + installation_store = ValidUserTokenRotationInstallationStore() + invalid_authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + with pytest.raises(BoltError): + await invalid_authorize( + context=context, + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W222", + ) + + authorize = AsyncInstallationStoreAuthorize( + client_id="111.222", + client_secret="secret", + client=mock_client, + logger=installation_store.logger, + installation_store=installation_store, + ) + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W222" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid-refreshed" + assert result.user_token == "xoxp-valid-refreshed" + await assert_auth_test_count_async(self, 1) + class LegacyMemoryInstallationStore(AsyncInstallationStore): @property @@ -282,3 +334,93 @@ async def async_find_installation( is_enterprise_install: Optional[bool] = False, ) -> Optional[Installation]: raise ValueError + + +class ValidUserTokenInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + if user_id is None: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-different-installer", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + elif user_id == "W222": + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W222", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class ValidUserTokenRotationInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + if user_id is None: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_refresh_token="xoxe-bot-valid", + bot_token_expires_in=-10, + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-different-installer", + user_refresh_token="xoxe-1-user-valid", + user_token_expires_in=-10, + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + elif user_id == "W222": + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W222", + user_token="xoxp-valid", + user_refresh_token="xoxe-1-user-valid", + user_token_expires_in=-10, + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) From ce85fa0c1d45c649978c7c6710d177fe01f756e2 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 18 Jan 2022 08:07:15 +0900 Subject: [PATCH 440/865] version 1.11.2 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index df976cb3e..692c2d5b1 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.11.1" +__version__ = "1.11.2" From cb3891bb0be1405d851d61efa73dd6fd66915eba Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 18 Jan 2022 08:13:11 +0900 Subject: [PATCH 441/865] Update the API docs --- docs/api-docs/slack_bolt/app/app.html | 14 +- docs/api-docs/slack_bolt/app/async_app.html | 14 +- .../authorization/async_authorize.html | 223 ++++++++++----- .../slack_bolt/authorization/authorize.html | 261 ++++++++++++------ .../slack_bolt/listener_matcher/builtins.html | 166 ++++++----- docs/api-docs/slack_bolt/version.html | 2 +- 6 files changed, 449 insertions(+), 231 deletions(-) diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index 35b39c817..1d80827e2 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -259,6 +259,7 @@

    Module slack_bolt.app.app

    client_secret=settings.client_secret if settings is not None else None, logger=self._framework_logger, bot_only=installation_store_bot_only, + client=self._client, # for proxy use cases etc. ) self._oauth_flow: Optional[OAuthFlow] = None @@ -838,7 +839,9 @@

    Module slack_bolt.app.app

    "thread_broadcast", ), } - primary_matcher = builtin_matchers.event(constraints=constraints) + primary_matcher = builtin_matchers.message_event( + keyword=keyword, constraints=constraints + ) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True @@ -1724,6 +1727,7 @@

    Args

    client_secret=settings.client_secret if settings is not None else None, logger=self._framework_logger, bot_only=installation_store_bot_only, + client=self._client, # for proxy use cases etc. ) self._oauth_flow: Optional[OAuthFlow] = None @@ -2303,7 +2307,9 @@

    Args

    "thread_broadcast", ), } - primary_matcher = builtin_matchers.event(constraints=constraints) + primary_matcher = builtin_matchers.message_event( + keyword=keyword, constraints=constraints + ) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True @@ -3656,7 +3662,9 @@

    Args

    "thread_broadcast", ), } - primary_matcher = builtin_matchers.event(constraints=constraints) + primary_matcher = builtin_matchers.message_event( + keyword=keyword, constraints=constraints + ) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index 2eb4079e2..f9c3059ca 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -267,6 +267,7 @@

    Module slack_bolt.app.async_app

    client_secret=settings.client_secret if settings is not None else None, logger=self._framework_logger, bot_only=installation_store_bot_only, + client=self._async_client, # for proxy use cases etc. ) self._async_oauth_flow: Optional[AsyncOAuthFlow] = None @@ -888,8 +889,8 @@

    Module slack_bolt.app.async_app

    "thread_broadcast", ), } - primary_matcher = builtin_matchers.event( - constraints=constraints, asyncio=True + primary_matcher = builtin_matchers.message_event( + constraints=constraints, keyword=keyword, asyncio=True ) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener( @@ -1658,6 +1659,7 @@

    Args

    client_secret=settings.client_secret if settings is not None else None, logger=self._framework_logger, bot_only=installation_store_bot_only, + client=self._async_client, # for proxy use cases etc. ) self._async_oauth_flow: Optional[AsyncOAuthFlow] = None @@ -2279,8 +2281,8 @@

    Args

    "thread_broadcast", ), } - primary_matcher = builtin_matchers.event( - constraints=constraints, asyncio=True + primary_matcher = builtin_matchers.message_event( + constraints=constraints, keyword=keyword, asyncio=True ) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener( @@ -3659,8 +3661,8 @@

    Args

    "thread_broadcast", ), } - primary_matcher = builtin_matchers.event( - constraints=constraints, asyncio=True + primary_matcher = builtin_matchers.message_event( + constraints=constraints, keyword=keyword, asyncio=True ) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener( diff --git a/docs/api-docs/slack_bolt/authorization/async_authorize.html b/docs/api-docs/slack_bolt/authorization/async_authorize.html index e919b6161..29af799ee 100644 --- a/docs/api-docs/slack_bolt/authorization/async_authorize.html +++ b/docs/api-docs/slack_bolt/authorization/async_authorize.html @@ -36,6 +36,7 @@

    Module slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorize + ) + + async def _rotate_and_save_tokens_if_necessary( + self, installation: Optional[Installation] + ) -> Optional[Installation]: + if installation is None or ( + installation.user_refresh_token is None + and installation.bot_refresh_token is None + ): + # No need to rotate tokens + return None + + if self.token_rotator is None: + # Token rotation is required but this Bolt app is not properly configured + raise BoltError(self._config_error_message) + + refreshed: Optional[ + Installation + ] = await self.token_rotator.perform_token_rotation( + installation=installation, + minutes_before_expiration=self.token_rotation_expiration_minutes, + ) + if refreshed is not None: + # Save the refreshed data in database for following requests + await self.installation_store.async_save(refreshed) + return refreshed

    @@ -449,7 +490,7 @@

    Ancestors

    class AsyncInstallationStoreAuthorize -(*, logger: logging.Logger, installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore, client_id: Optional[str] = None, client_secret: Optional[str] = None, token_rotation_expiration_minutes: Optional[int] = None, bot_only: bool = False, cache_enabled: bool = False) +(*, logger: logging.Logger, installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore, client_id: Optional[str] = None, client_secret: Optional[str] = None, token_rotation_expiration_minutes: Optional[int] = None, bot_only: bool = False, cache_enabled: bool = False, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None)

    If you use the OAuth flow settings, this authorize implementation will be used. @@ -484,6 +525,7 @@

    Ancestors

    # use only InstallationStore#find_bot(enterprise_id, team_id) bot_only: bool = False, cache_enabled: bool = False, + client: Optional[AsyncWebClient] = None, ): self.logger = logger self.installation_store = installation_store @@ -496,6 +538,7 @@

    Ancestors

    self.token_rotator = AsyncTokenRotator( client_id=client_id, client_secret=client_secret, + client=client, ) else: self.token_rotator = None @@ -528,23 +571,35 @@

    Ancestors

    try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. - installation: Optional[ + latest_installation: Optional[ Installation ] = await self.installation_store.async_find_installation( enterprise_id=enterprise_id, team_id=team_id, is_enterprise_install=context.is_enterprise_install, ) - - if installation is not None: - if installation.user_id != user_id: + # If the user_token in the latest_installation is not for the user associated with this request, + # we'll fetch a different installation for the user below + # The example use cases are: + # - The app's installation requires both bot and user tokens + # - The app has two installation paths 1) bot installation 2) individual user authorization + this_user_installation: Optional[Installation] = None + + if latest_installation is not None: + # Save the latest bot token + bot_token = latest_installation.bot_token # this still can be None + user_token = ( + latest_installation.user_token + ) # this still can be None + + if latest_installation.user_id != user_id: # First off, remove the user token as the installer is a different user - installation.user_token = None - installation.user_scopes = [] + latest_installation.user_token = None + latest_installation.user_scopes = [] # try to fetch the request user's installation # to reflect the user's access token if exists - user_installation = ( + this_user_installation = ( await self.installation_store.async_find_installation( enterprise_id=enterprise_id, team_id=team_id, @@ -552,41 +607,42 @@

    Ancestors

    is_enterprise_install=context.is_enterprise_install, ) ) - if user_installation is not None: - # Overwrite the installation with the one for this user - installation = user_installation - - bot_token, user_token = ( - installation.bot_token, - installation.user_token, - ) - - if ( - installation.user_refresh_token is not None - or installation.bot_refresh_token is not None - ): - if self.token_rotator is None: - raise BoltError(self._config_error_message) - refreshed = await self.token_rotator.perform_token_rotation( - installation=installation, - minutes_before_expiration=self.token_rotation_expiration_minutes, - ) - if refreshed is not None: - await self.installation_store.async_save(refreshed) - bot_token, user_token = ( - refreshed.bot_token, - refreshed.user_token, + if this_user_installation is not None: + user_token = this_user_installation.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = this_user_installation.bot_token + + # If token rotation is enabled, running rotation may be needed here + refreshed = await self._rotate_and_save_tokens_if_necessary( + this_user_installation ) + if refreshed is not None: + user_token = refreshed.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = refreshed.bot_token + + # If token rotation is enabled, running rotation may be needed here + refreshed = await self._rotate_and_save_tokens_if_necessary( + latest_installation + ) + if refreshed is not None: + bot_token = refreshed.bot_token + if this_user_installation is None: + # Only when we don't have `this_user_installation` here, + # the `user_token` is for the user associated with this request + user_token = refreshed.user_token except NotImplementedError as _: self.find_installation_available = False if ( - # If you intentionally use only find_bot / delete_bot, + # If you intentionally use only `find_bot` / `delete_bot`, self.bot_only - # If find_installation method is not available, + # If the `find_installation` method is not available, or not self.find_installation_available - # If find_installation did not return data and find_bot method is available, + # If the `find_installation` method did not return data and find_bot method is available, or ( self.find_bot_available is True and bot_token is None @@ -653,7 +709,32 @@

    Ancestors

    self.logger.debug( "No installation data found " f"for enterprise_id: {enterprise_id} team_id: {team_id}" - ) + ) + + async def _rotate_and_save_tokens_if_necessary( + self, installation: Optional[Installation] + ) -> Optional[Installation]: + if installation is None or ( + installation.user_refresh_token is None + and installation.bot_refresh_token is None + ): + # No need to rotate tokens + return None + + if self.token_rotator is None: + # Token rotation is required but this Bolt app is not properly configured + raise BoltError(self._config_error_message) + + refreshed: Optional[ + Installation + ] = await self.token_rotator.perform_token_rotation( + installation=installation, + minutes_before_expiration=self.token_rotation_expiration_minutes, + ) + if refreshed is not None: + # Save the refreshed data in database for following requests + await self.installation_store.async_save(refreshed) + return refreshed

    Ancestors

      diff --git a/docs/api-docs/slack_bolt/authorization/authorize.html b/docs/api-docs/slack_bolt/authorization/authorize.html index 197913bce..c666154a3 100644 --- a/docs/api-docs/slack_bolt/authorization/authorize.html +++ b/docs/api-docs/slack_bolt/authorization/authorize.html @@ -32,9 +32,10 @@

      Module slack_bolt.authorization.authorize

      from slack_sdk.errors import SlackApiError from slack_sdk.oauth import InstallationStore -from slack_sdk.oauth.installation_store import Bot +from slack_sdk.oauth.installation_store.models.bot import Bot from slack_sdk.oauth.installation_store.models.installation import Installation from slack_sdk.oauth.token_rotation.rotator import TokenRotator +from slack_sdk.web import WebClient from slack_bolt.authorization.authorize_args import AuthorizeArgs from slack_bolt.authorization.authorize_result import AuthorizeResult @@ -61,8 +62,8 @@

      Module slack_bolt.authorization.authorize

      class CallableAuthorize(Authorize): - """When you pass the authorize argument in AsyncApp constructor, - This authorize implementation will be used. + """When you pass the `authorize` argument in AsyncApp constructor, + This `authorize` implementation will be used. """ def __init__( @@ -130,9 +131,9 @@

      Module slack_bolt.authorization.authorize

      class InstallationStoreAuthorize(Authorize): - """If you use the OAuth flow settings, this authorize implementation will be used. + """If you use the OAuth flow settings, this `authorize` implementation will be used. As long as your own InstallationStore (or the built-in ones) works as you expect, - you can expect that the authorize layer should work for you without any customization. + you can expect that the `authorize` layer should work for you without any customization. """ authorize_result_cache: Dict[str, AuthorizeResult] @@ -157,6 +158,7 @@

      Module slack_bolt.authorization.authorize

      # use only InstallationStore#find_bot(enterprise_id, team_id) bot_only: bool = False, cache_enabled: bool = False, + client: Optional[WebClient] = None, ): self.logger = logger self.installation_store = installation_store @@ -171,6 +173,7 @@

      Module slack_bolt.authorization.authorize

      self.token_rotator = TokenRotator( client_id=client_id, client_secret=client_secret, + client=client, ) else: self.token_rotator = None @@ -196,61 +199,78 @@

      Module slack_bolt.authorization.authorize

      try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. - installation: Optional[ + latest_installation: Optional[ Installation ] = self.installation_store.find_installation( enterprise_id=enterprise_id, team_id=team_id, is_enterprise_install=context.is_enterprise_install, ) - if installation is not None: - if installation.user_id != user_id: + # If the user_token in the latest_installation is not for the user associated with this request, + # we'll fetch a different installation for the user below. + # The example use cases are: + # - The app's installation requires both bot and user tokens + # - The app has two installation paths 1) bot installation 2) individual user authorization + this_user_installation: Optional[Installation] = None + + if latest_installation is not None: + # Save the latest bot token + bot_token = latest_installation.bot_token # this still can be None + user_token = ( + latest_installation.user_token + ) # this still can be None + + if latest_installation.user_id != user_id: # First off, remove the user token as the installer is a different user - installation.user_token = None - installation.user_scopes = [] + latest_installation.user_token = None + latest_installation.user_scopes = [] # try to fetch the request user's installation # to reflect the user's access token if exists - user_installation = self.installation_store.find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, - ) - if user_installation is not None: - # Overwrite the installation with the one for this user - installation = user_installation - - bot_token, user_token = ( - installation.bot_token, - installation.user_token, - ) - if ( - installation.user_refresh_token is not None - or installation.bot_refresh_token is not None - ): - if self.token_rotator is None: - raise BoltError(self._config_error_message) - refreshed = self.token_rotator.perform_token_rotation( - installation=installation, - minutes_before_expiration=self.token_rotation_expiration_minutes, + this_user_installation = ( + self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) ) - if refreshed is not None: - self.installation_store.save(refreshed) - bot_token, user_token = ( - refreshed.bot_token, - refreshed.user_token, + if this_user_installation is not None: + user_token = this_user_installation.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = this_user_installation.bot_token + + # If token rotation is enabled, running rotation may be needed here + refreshed = self._rotate_and_save_tokens_if_necessary( + this_user_installation ) + if refreshed is not None: + user_token = refreshed.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = refreshed.bot_token + + # If token rotation is enabled, running rotation may be needed here + refreshed = self._rotate_and_save_tokens_if_necessary( + latest_installation + ) + if refreshed is not None: + bot_token = refreshed.bot_token + if this_user_installation is None: + # Only when we don't have `this_user_installation` here, + # the `user_token` is for the user associated with this request + user_token = refreshed.user_token except NotImplementedError as _: self.find_installation_available = False if ( - # If you intentionally use only find_bot / delete_bot, + # If you intentionally use only `find_bot` / `delete_bot`, self.bot_only - # If find_installation method is not available, + # If the `find_installation` method is not available, or not self.find_installation_available - # If find_installation did not return data and find_bot method is available, + # If the `find_installation` method did not return data and find_bot method is available, or ( self.find_bot_available is True and bot_token is None @@ -317,7 +337,30 @@

      Module slack_bolt.authorization.authorize

      self.logger.debug( "No installation data found " f"for enterprise_id: {enterprise_id} team_id: {team_id}" - ) + ) + + def _rotate_and_save_tokens_if_necessary( + self, installation: Optional[Installation] + ) -> Optional[Installation]: + if installation is None or ( + installation.user_refresh_token is None + and installation.bot_refresh_token is None + ): + # No need to rotate tokens + return None + + if self.token_rotator is None: + # Token rotation is required but this Bolt app is not properly configured + raise BoltError(self._config_error_message) + + refreshed: Optional[Installation] = self.token_rotator.perform_token_rotation( + installation=installation, + minutes_before_expiration=self.token_rotation_expiration_minutes, + ) + if refreshed is not None: + # Save the refreshed data in database for following requests + self.installation_store.save(refreshed) + return refreshed
    @@ -367,15 +410,15 @@

    Subclasses

    (*, logger: logging.Logger, func: Callable[..., AuthorizeResult])
    -

    When you pass the authorize argument in AsyncApp constructor, -This authorize implementation will be used.

    +

    When you pass the authorize argument in AsyncApp constructor, +This authorize implementation will be used.

    Expand source code
    class CallableAuthorize(Authorize):
    -    """When you pass the authorize argument in AsyncApp constructor,
    -    This authorize implementation will be used.
    +    """When you pass the `authorize` argument in AsyncApp constructor,
    +    This `authorize` implementation will be used.
         """
     
         def __init__(
    @@ -448,20 +491,20 @@ 

    Ancestors

    class InstallationStoreAuthorize -(*, logger: logging.Logger, installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore, client_id: Optional[str] = None, client_secret: Optional[str] = None, token_rotation_expiration_minutes: Optional[int] = None, bot_only: bool = False, cache_enabled: bool = False) +(*, logger: logging.Logger, installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore, client_id: Optional[str] = None, client_secret: Optional[str] = None, token_rotation_expiration_minutes: Optional[int] = None, bot_only: bool = False, cache_enabled: bool = False, client: Optional[slack_sdk.web.client.WebClient] = None)
    -

    If you use the OAuth flow settings, this authorize implementation will be used. +

    If you use the OAuth flow settings, this authorize implementation will be used. As long as your own InstallationStore (or the built-in ones) works as you expect, -you can expect that the authorize layer should work for you without any customization.

    +you can expect that the authorize layer should work for you without any customization.

    Expand source code
    class InstallationStoreAuthorize(Authorize):
    -    """If you use the OAuth flow settings, this authorize implementation will be used.
    +    """If you use the OAuth flow settings, this `authorize` implementation will be used.
         As long as your own InstallationStore (or the built-in ones) works as you expect,
    -    you can expect that the authorize layer should work for you without any customization.
    +    you can expect that the `authorize` layer should work for you without any customization.
         """
     
         authorize_result_cache: Dict[str, AuthorizeResult]
    @@ -486,6 +529,7 @@ 

    Ancestors

    # use only InstallationStore#find_bot(enterprise_id, team_id) bot_only: bool = False, cache_enabled: bool = False, + client: Optional[WebClient] = None, ): self.logger = logger self.installation_store = installation_store @@ -500,6 +544,7 @@

    Ancestors

    self.token_rotator = TokenRotator( client_id=client_id, client_secret=client_secret, + client=client, ) else: self.token_rotator = None @@ -525,61 +570,78 @@

    Ancestors

    try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. - installation: Optional[ + latest_installation: Optional[ Installation ] = self.installation_store.find_installation( enterprise_id=enterprise_id, team_id=team_id, is_enterprise_install=context.is_enterprise_install, ) - if installation is not None: - if installation.user_id != user_id: + # If the user_token in the latest_installation is not for the user associated with this request, + # we'll fetch a different installation for the user below. + # The example use cases are: + # - The app's installation requires both bot and user tokens + # - The app has two installation paths 1) bot installation 2) individual user authorization + this_user_installation: Optional[Installation] = None + + if latest_installation is not None: + # Save the latest bot token + bot_token = latest_installation.bot_token # this still can be None + user_token = ( + latest_installation.user_token + ) # this still can be None + + if latest_installation.user_id != user_id: # First off, remove the user token as the installer is a different user - installation.user_token = None - installation.user_scopes = [] + latest_installation.user_token = None + latest_installation.user_scopes = [] # try to fetch the request user's installation # to reflect the user's access token if exists - user_installation = self.installation_store.find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, - ) - if user_installation is not None: - # Overwrite the installation with the one for this user - installation = user_installation - - bot_token, user_token = ( - installation.bot_token, - installation.user_token, - ) - if ( - installation.user_refresh_token is not None - or installation.bot_refresh_token is not None - ): - if self.token_rotator is None: - raise BoltError(self._config_error_message) - refreshed = self.token_rotator.perform_token_rotation( - installation=installation, - minutes_before_expiration=self.token_rotation_expiration_minutes, + this_user_installation = ( + self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) ) - if refreshed is not None: - self.installation_store.save(refreshed) - bot_token, user_token = ( - refreshed.bot_token, - refreshed.user_token, + if this_user_installation is not None: + user_token = this_user_installation.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = this_user_installation.bot_token + + # If token rotation is enabled, running rotation may be needed here + refreshed = self._rotate_and_save_tokens_if_necessary( + this_user_installation ) + if refreshed is not None: + user_token = refreshed.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = refreshed.bot_token + + # If token rotation is enabled, running rotation may be needed here + refreshed = self._rotate_and_save_tokens_if_necessary( + latest_installation + ) + if refreshed is not None: + bot_token = refreshed.bot_token + if this_user_installation is None: + # Only when we don't have `this_user_installation` here, + # the `user_token` is for the user associated with this request + user_token = refreshed.user_token except NotImplementedError as _: self.find_installation_available = False if ( - # If you intentionally use only find_bot / delete_bot, + # If you intentionally use only `find_bot` / `delete_bot`, self.bot_only - # If find_installation method is not available, + # If the `find_installation` method is not available, or not self.find_installation_available - # If find_installation did not return data and find_bot method is available, + # If the `find_installation` method did not return data and find_bot method is available, or ( self.find_bot_available is True and bot_token is None @@ -646,7 +708,30 @@

    Ancestors

    self.logger.debug( "No installation data found " f"for enterprise_id: {enterprise_id} team_id: {team_id}" - )
    + ) + + def _rotate_and_save_tokens_if_necessary( + self, installation: Optional[Installation] + ) -> Optional[Installation]: + if installation is None or ( + installation.user_refresh_token is None + and installation.bot_refresh_token is None + ): + # No need to rotate tokens + return None + + if self.token_rotator is None: + # Token rotation is required but this Bolt app is not properly configured + raise BoltError(self._config_error_message) + + refreshed: Optional[Installation] = self.token_rotator.perform_token_rotation( + installation=installation, + minutes_before_expiration=self.token_rotation_expiration_minutes, + ) + if refreshed is not None: + # Save the refreshed data in database for following requests + self.installation_store.save(refreshed) + return refreshed

    Ancestors

      diff --git a/docs/api-docs/slack_bolt/listener_matcher/builtins.html b/docs/api-docs/slack_bolt/listener_matcher/builtins.html index 238285f36..f03e45470 100644 --- a/docs/api-docs/slack_bolt/listener_matcher/builtins.html +++ b/docs/api-docs/slack_bolt/listener_matcher/builtins.html @@ -28,6 +28,7 @@

      Module slack_bolt.listener_matcher.builtins

      # pytype: skip-file
       import inspect
      +import re
       import sys
       
       from slack_bolt.error import BoltError
      @@ -123,37 +124,10 @@ 

      Module slack_bolt.listener_matcher.builtins

      def func(body: Dict[str, Any]) -> bool: if is_event(body): - event = body["event"] - if not _matches(constraints["type"], event["type"]): - return False - if "subtype" in constraints: - expected_subtype: Union[ - str, Sequence[Optional[Union[str, Pattern]]] - ] = constraints["subtype"] - if expected_subtype is None: - # "subtype" in constraints is intentionally None for this pattern - return "subtype" not in event - elif isinstance(expected_subtype, (str, Pattern)): - return "subtype" in event and _matches( - expected_subtype, event["subtype"] - ) - elif isinstance(expected_subtype, Sequence): - subtypes: Sequence[ - Optional[Union[str, Pattern]] - ] = expected_subtype - for expected in subtypes: - actual: Optional[str] = event.get("subtype") - if expected is None: - if actual is None: - return True - elif actual is not None and _matches(expected, actual): - return True - return False - else: - return "subtype" in event and _matches( - expected_subtype, event["subtype"] - ) - return True + return _check_event_subtype( + event_payload=body["event"], + constraints=constraints, + ) return False return build_listener_matcher(func, asyncio) @@ -163,6 +137,64 @@

      Module slack_bolt.listener_matcher.builtins

      ) +def message_event( + constraints: Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]], + keyword: Union[str, Pattern], + asyncio: bool = False, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + if "type" in constraints and keyword is not None: + _verify_message_event_type(constraints["type"]) + + def func(body: Dict[str, Any]) -> bool: + if is_event(body): + is_valid_subtype = _check_event_subtype( + event_payload=body["event"], + constraints=constraints, + ) + if is_valid_subtype is True: + # Check keyword matching + text = body.get("event", {}).get("text", "") + match_result = re.findall(keyword, text) + if match_result is not None and match_result != []: + return True + return False + + return build_listener_matcher(func, asyncio) + + raise BoltError(f"event ({constraints}: {type(constraints)}) must be dict") + + +def _check_event_subtype(event_payload: dict, constraints: dict) -> bool: + if not _matches(constraints["type"], event_payload["type"]): + return False + if "subtype" in constraints: + expected_subtype: Union[ + str, Sequence[Optional[Union[str, Pattern]]] + ] = constraints["subtype"] + if expected_subtype is None: + # "subtype" in constraints is intentionally None for this pattern + return "subtype" not in event_payload + elif isinstance(expected_subtype, (str, Pattern)): + return "subtype" in event_payload and _matches( + expected_subtype, event_payload["subtype"] + ) + elif isinstance(expected_subtype, Sequence): + subtypes: Sequence[Optional[Union[str, Pattern]]] = expected_subtype + for expected in subtypes: + actual: Optional[str] = event_payload.get("subtype") + if expected is None: + if actual is None: + return True + elif actual is not None and _matches(expected, actual): + return True + return False + else: + return "subtype" in event_payload and _matches( + expected_subtype, event_payload["subtype"] + ) + return True + + def _verify_message_event_type(event_type: str) -> None: if isinstance(event_type, str) and event_type.startswith("message."): raise ValueError(error_message_event_type(event_type)) @@ -770,37 +802,10 @@

      Functions

      def func(body: Dict[str, Any]) -> bool: if is_event(body): - event = body["event"] - if not _matches(constraints["type"], event["type"]): - return False - if "subtype" in constraints: - expected_subtype: Union[ - str, Sequence[Optional[Union[str, Pattern]]] - ] = constraints["subtype"] - if expected_subtype is None: - # "subtype" in constraints is intentionally None for this pattern - return "subtype" not in event - elif isinstance(expected_subtype, (str, Pattern)): - return "subtype" in event and _matches( - expected_subtype, event["subtype"] - ) - elif isinstance(expected_subtype, Sequence): - subtypes: Sequence[ - Optional[Union[str, Pattern]] - ] = expected_subtype - for expected in subtypes: - actual: Optional[str] = event.get("subtype") - if expected is None: - if actual is None: - return True - elif actual is not None and _matches(expected, actual): - return True - return False - else: - return "subtype" in event and _matches( - expected_subtype, event["subtype"] - ) - return True + return _check_event_subtype( + event_payload=body["event"], + constraints=constraints, + ) return False return build_listener_matcher(func, asyncio) @@ -829,6 +834,42 @@

      Functions

      return build_listener_matcher(func, asyncio)
    +
    +def message_event(constraints: Dict[str, Union[str, Sequence[Union[str, re.Pattern, ForwardRef(None)]]]], keyword: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +
    +
    +
    +
    + +Expand source code + +
    def message_event(
    +    constraints: Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]],
    +    keyword: Union[str, Pattern],
    +    asyncio: bool = False,
    +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
    +    if "type" in constraints and keyword is not None:
    +        _verify_message_event_type(constraints["type"])
    +
    +        def func(body: Dict[str, Any]) -> bool:
    +            if is_event(body):
    +                is_valid_subtype = _check_event_subtype(
    +                    event_payload=body["event"],
    +                    constraints=constraints,
    +                )
    +                if is_valid_subtype is True:
    +                    # Check keyword matching
    +                    text = body.get("event", {}).get("text", "")
    +                    match_result = re.findall(keyword, text)
    +                    if match_result is not None and match_result != []:
    +                        return True
    +            return False
    +
    +        return build_listener_matcher(func, asyncio)
    +
    +    raise BoltError(f"event ({constraints}: {type(constraints)}) must be dict")
    +
    +
    def message_shortcut(callback_id: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -1120,6 +1161,7 @@

    Index

  • dialog_suggestion
  • event
  • global_shortcut
  • +
  • message_event
  • message_shortcut
  • options
  • shortcut
  • diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index b2166e22a..36854a0a5 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.11.1"
    +__version__ = "1.11.2"
    From 8c0532dbbbd0c1f3035fa22c5c08903ee02b8826 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Jan 2022 22:22:18 +0900 Subject: [PATCH 442/865] Add org-wide installation test patterns (#578) --- slack_bolt/request/internals.py | 4 + tests/slack_bolt/request/test_request.py | 221 +++++++++++++++++++++++ 2 files changed, 225 insertions(+) diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 2fd0c430e..b520cb821 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -78,6 +78,8 @@ def extract_enterprise_id(payload: Dict[str, Any]) -> Optional[str]: def extract_team_id(payload: Dict[str, Any]) -> Optional[str]: if payload.get("team") is not None: + # With org-wide installations, payload.team in interactivity payloads can be None + # You need to extract either payload.user.team_id or payload.view.team_id as below team = payload.get("team") if isinstance(team, str): return team @@ -93,6 +95,8 @@ def extract_team_id(payload: Dict[str, Any]) -> Optional[str]: return extract_team_id(payload["event"]) if payload.get("user") is not None: return payload.get("user")["team_id"] + if payload.get("view") is not None: + return payload.get("view")["team_id"] return None diff --git a/tests/slack_bolt/request/test_request.py b/tests/slack_bolt/request/test_request.py index b6a81ecca..70ce98e3c 100644 --- a/tests/slack_bolt/request/test_request.py +++ b/tests/slack_bolt/request/test_request.py @@ -1,3 +1,5 @@ +from urllib.parse import quote + from slack_bolt.request.request import BoltRequest @@ -21,3 +23,222 @@ def test_all_none_inputs_socket_mode(self): assert req is not None assert req.raw_body == "" assert req.body == {} + + def test_org_wide_installations_block_actions(self): + payload = """ +{ + "type": "block_actions", + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T_expected" + }, + "api_app_id": "A111", + "token": "fixed-value", + "container": { + "type": "message", + "message_ts": "1643113871.000700", + "channel_id": "C111", + "is_ephemeral": true + }, + "trigger_id": "111.222.xxx", + "team": null, + "enterprise": { + "id": "E111", + "name": "Sandbox Org" + }, + "is_enterprise_install": true, + "channel": { + "id": "C111", + "name": "random" + }, + "state": { + "values": {} + }, + "response_url": "https://hooks.slack.com/actions/E111/111/xxx", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": { + "type": "plain_text", + "text": "Button" + }, + "value": "click_me_123", + "type": "button", + "action_ts": "1643113877.645417" + } + ] +} +""" + req = BoltRequest(body=f"payload={quote(payload)}") + assert req is not None + assert req.context.team_id == "T_expected" + assert req.context.user_id == "W111" + + def test_org_wide_installations_view_submission(self): + payload = """ +{ + "type": "view_submission", + "team": null, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T_expected" + }, + "api_app_id": "A111", + "token": "fixed-value", + "trigger_id": "1111.222.xxx", + "view": { + "id": "V111", + "team_id": "T_expected", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "+5B", + "label": { + "type": "plain_text", + "text": "Label", + "emoji": true + }, + "optional": false, + "dispatch_action": false, + "element": { + "type": "plain_text_input", + "dispatch_action_config": { + "trigger_actions_on": [ + "on_enter_pressed" + ] + }, + "action_id": "MMKH" + } + } + ], + "private_metadata": "", + "callback_id": "view-id", + "state": { + "values": { + "+5B": { + "MMKH": { + "type": "plain_text_input", + "value": "test" + } + } + } + }, + "hash": "111.xxx", + "title": { + "type": "plain_text", + "text": "My App" + }, + "clear_on_close": false, + "notify_on_close": false, + "close": { + "type": "plain_text", + "text": "Cancel" + }, + "submit": { + "type": "plain_text", + "text": "Submit", + "emoji": true + }, + "previous_view_id": null, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "E111", + "bot_id": "B111" + }, + "response_urls": [], + "is_enterprise_install": true, + "enterprise": { + "id": "E111", + "name": "Sandbox Org" + } +} +""" + req = BoltRequest(body=f"payload={quote(payload)}") + assert req is not None + assert req.context.team_id == "T_expected" + assert req.context.user_id == "W111" + + def test_org_wide_installations_view_closed(self): + payload = """ +{ + "type": "view_closed", + "team": null, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T_expected" + }, + "api_app_id": "A111", + "token": "fixed-value", + "view": { + "id": "V111", + "team_id": "T_expected", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "M2r2p", + "label": { + "type": "plain_text", + "text": "Label" + }, + "optional": false, + "dispatch_action": false, + "element": { + "type": "plain_text_input", + "dispatch_action_config": { + "trigger_actions_on": [ + "on_enter_pressed" + ] + }, + "action_id": "xB+" + } + } + ], + "private_metadata": "", + "callback_id": "view-id", + "state": { + "values": {} + }, + "hash": "1643113987.gRY6ROtt", + "title": { + "type": "plain_text", + "text": "My App" + }, + "clear_on_close": false, + "notify_on_close": true, + "close": { + "type": "plain_text", + "text": "Cancel" + }, + "submit": { + "type": "plain_text", + "text": "Submit" + }, + "previous_view_id": null, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "E111", + "bot_id": "B0302M47727" + }, + "is_cleared": false, + "is_enterprise_install": true, + "enterprise": { + "id": "E111", + "name": "Sandbox Org" + } +} +""" + req = BoltRequest(body=f"payload={quote(payload)}") + assert req is not None + assert req.context.team_id == "T_expected" + assert req.context.user_id == "W111" From dc4837837bfd10d10470d4e043f2fc5d55447b50 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 29 Jan 2022 06:20:34 +0900 Subject: [PATCH 443/865] Fix #468 Replying with 0 results for a multi-select external option display previous successful results (#580) --- slack_bolt/context/ack/internals.py | 4 +- tests/scenario_tests/test_block_suggestion.py | 40 ++++++++++++++++++ .../test_block_suggestion.py | 42 +++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/slack_bolt/context/ack/internals.py b/slack_bolt/context/ack/internals.py index 1985e26ca..965033b77 100644 --- a/slack_bolt/context/ack/internals.py +++ b/slack_bolt/context/ack/internals.py @@ -42,10 +42,10 @@ def _set_response( elif blocks and len(blocks) > 0: body.update({"text": text, "blocks": convert_to_dict_list(blocks)}) self.response = BoltResponse(status=200, body=body) - elif options and len(options) > 0: + elif options is not None: body = {"options": convert_to_dict_list(options)} self.response = BoltResponse(status=200, body=body) - elif option_groups and len(option_groups) > 0: + elif option_groups is not None: body = {"option_groups": convert_to_dict_list(option_groups)} self.response = BoltResponse(status=200, body=body) elif response_action: diff --git a/tests/scenario_tests/test_block_suggestion.py b/tests/scenario_tests/test_block_suggestion.py index 9c3a4fb3f..01e831b93 100644 --- a/tests/scenario_tests/test_block_suggestion.py +++ b/tests/scenario_tests/test_block_suggestion.py @@ -180,6 +180,34 @@ def test_failure_multi(self): assert response.status == 404 assert_auth_test_count(self, 1) + def test_empty_options(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.options("mes_a")(show_empty_options) + + request = self.build_valid_multi_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == """{"options": []}""" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_empty_option_groups(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.options("mes_a")(show_empty_option_groups) + + request = self.build_valid_multi_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == """{"option_groups": []}""" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) + body = { "type": "block_suggestion", @@ -296,3 +324,15 @@ def show_multi_options(ack, body, payload, options): assert body == options assert payload == options ack(multi_response) + + +def show_empty_options(ack, body, payload, options): + assert body == options + assert payload == options + ack(options=[]) + + +def show_empty_option_groups(ack, body, payload, options): + assert body == options + assert payload == options + ack(option_groups=[]) diff --git a/tests/scenario_tests_async/test_block_suggestion.py b/tests/scenario_tests_async/test_block_suggestion.py index dc67391e0..17dceb066 100644 --- a/tests/scenario_tests_async/test_block_suggestion.py +++ b/tests/scenario_tests_async/test_block_suggestion.py @@ -195,6 +195,36 @@ async def test_failure_multi(self): assert response.status == 404 await assert_auth_test_count_async(self, 1) + @pytest.mark.asyncio + async def test_empty_options(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.options("mes_a")(show_empty_options) + + request = self.build_valid_multi_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == """{"options": []}""" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_empty_option_groups(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.options("mes_a")(show_empty_option_groups) + + request = self.build_valid_multi_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == """{"option_groups": []}""" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + await assert_auth_test_count_async(self, 1) + body = { "type": "block_suggestion", @@ -311,3 +341,15 @@ async def show_multi_options(ack, body, payload, options): assert body == options assert payload == options await ack(multi_response) + + +async def show_empty_options(ack, body, payload, options): + assert body == options + assert payload == options + await ack(options=[]) + + +async def show_empty_option_groups(ack, body, payload, options): + assert body == options + assert payload == options + await ack(option_groups=[]) From d6dbe64ca25579e2d1f6aa31eb29f34ed64d60ba Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 29 Jan 2022 06:46:50 +0900 Subject: [PATCH 444/865] Upgrade pytype version to the latest (#581) --- scripts/run_pytype.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index 43f43d225..debbc1f68 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -5,5 +5,5 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ pip install -e ".[async]" && \ pip install -e ".[adapter]" && \ - pip install "pytype==2021.12.15" && \ + pip install "pytype==2022.1.13" && \ pytype slack_bolt/ From b4dab2c7a0ad6ef47713b9e71da33cbea8392ea6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 29 Jan 2022 06:48:39 +0900 Subject: [PATCH 445/865] version 1.11.3 --- docs/api-docs/slack_bolt/context/ack/internals.html | 4 ++-- docs/api-docs/slack_bolt/request/internals.html | 8 ++++++++ docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/api-docs/slack_bolt/context/ack/internals.html b/docs/api-docs/slack_bolt/context/ack/internals.html index 535fc4228..56ca58bb2 100644 --- a/docs/api-docs/slack_bolt/context/ack/internals.html +++ b/docs/api-docs/slack_bolt/context/ack/internals.html @@ -70,10 +70,10 @@

    Module slack_bolt.context.ack.internals

    elif blocks and len(blocks) > 0: body.update({"text": text, "blocks": convert_to_dict_list(blocks)}) self.response = BoltResponse(status=200, body=body) - elif options and len(options) > 0: + elif options is not None: body = {"options": convert_to_dict_list(options)} self.response = BoltResponse(status=200, body=body) - elif option_groups and len(option_groups) > 0: + elif option_groups is not None: body = {"option_groups": convert_to_dict_list(option_groups)} self.response = BoltResponse(status=200, body=body) elif response_action: diff --git a/docs/api-docs/slack_bolt/request/internals.html b/docs/api-docs/slack_bolt/request/internals.html index 2df2c8794..9a61d42f7 100644 --- a/docs/api-docs/slack_bolt/request/internals.html +++ b/docs/api-docs/slack_bolt/request/internals.html @@ -106,6 +106,8 @@

    Module slack_bolt.request.internals

    def extract_team_id(payload: Dict[str, Any]) -> Optional[str]: if payload.get("team") is not None: + # With org-wide installations, payload.team in interactivity payloads can be None + # You need to extract either payload.user.team_id or payload.view.team_id as below team = payload.get("team") if isinstance(team, str): return team @@ -121,6 +123,8 @@

    Module slack_bolt.request.internals

    return extract_team_id(payload["event"]) if payload.get("user") is not None: return payload.get("user")["team_id"] + if payload.get("view") is not None: + return payload.get("view")["team_id"] return None @@ -420,6 +424,8 @@

    Functions

    def extract_team_id(payload: Dict[str, Any]) -> Optional[str]:
         if payload.get("team") is not None:
    +        # With org-wide installations, payload.team in interactivity payloads can be None
    +        # You need to extract either payload.user.team_id or payload.view.team_id as below
             team = payload.get("team")
             if isinstance(team, str):
                 return team
    @@ -435,6 +441,8 @@ 

    Functions

    return extract_team_id(payload["event"]) if payload.get("user") is not None: return payload.get("user")["team_id"] + if payload.get("view") is not None: + return payload.get("view")["team_id"] return None
    diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index 36854a0a5..8cc0587df 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.11.2"
    +__version__ = "1.11.3"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 692c2d5b1..61ceb7244 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.11.2" +__version__ = "1.11.3" From 7fa4e88796a79403e3630c701fb774511f21cf6c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 1 Feb 2022 15:32:36 +0900 Subject: [PATCH 446/865] Fix #584 Wrong user_token assigned to new user (affected versions: v1.11.2, v1.11.3) (#586) --- slack_bolt/authorization/async_authorize.py | 3 ++ slack_bolt/authorization/authorize.py | 3 ++ tests/mock_web_api_server.py | 28 +++++----- .../authorization/test_authorize.py | 49 ++++++++++++++++++ .../authorization/test_async_authorize.py | 51 +++++++++++++++++++ 5 files changed, 121 insertions(+), 13 deletions(-) diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index 6d37924c5..00b5458e2 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -194,7 +194,10 @@ async def __call__( if latest_installation.user_id != user_id: # First off, remove the user token as the installer is a different user + user_token = None latest_installation.user_token = None + latest_installation.user_refresh_token = None + latest_installation.user_token_expires_at = None latest_installation.user_scopes = [] # try to fetch the request user's installation diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 5cd9a0193..c701d5efa 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -194,7 +194,10 @@ def __call__( if latest_installation.user_id != user_id: # First off, remove the user token as the installer is a different user + user_token = None latest_installation.user_token = None + latest_installation.user_refresh_token = None + latest_installation.user_token_expires_at = None latest_installation.user_scopes = [] # try to fetch the request user's installation diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index 6c905f8b5..486b5581f 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -155,19 +155,21 @@ def _handle(self): self.logger.info(f"request body: {request_body}") if request_body.get("grant_type") == "refresh_token": - if "bot-valid" in request_body.get("refresh_token"): - self.send_response(200) - self.set_common_headers() - body = self.oauth_v2_access_bot_refresh_response - self.wfile.write(body.encode("utf-8")) - return - if "user-valid" in request_body.get("refresh_token"): - self.send_response(200) - self.set_common_headers() - body = self.oauth_v2_access_user_refresh_response - self.wfile.write(body.encode("utf-8")) - return - if request_body.get("code") is not None: + refresh_token = request_body.get("refresh_token") + if refresh_token is not None: + if "bot-valid" in refresh_token: + self.send_response(200) + self.set_common_headers() + body = self.oauth_v2_access_bot_refresh_response + self.wfile.write(body.encode("utf-8")) + return + if "user-valid" in refresh_token: + self.send_response(200) + self.set_common_headers() + body = self.oauth_v2_access_user_refresh_response + self.wfile.write(body.encode("utf-8")) + return + elif request_body.get("code") is not None: self.send_response(200) self.set_common_headers() self.wfile.write(self.oauth_v2_access_response.encode("utf-8")) diff --git a/tests/slack_bolt/authorization/test_authorize.py b/tests/slack_bolt/authorization/test_authorize.py index c4d64a91d..456af6ae9 100644 --- a/tests/slack_bolt/authorization/test_authorize.py +++ b/tests/slack_bolt/authorization/test_authorize.py @@ -245,6 +245,55 @@ def test_fetch_different_user_token_with_rotation(self): assert result.user_token == "xoxp-valid-refreshed" assert_auth_test_count(self, 1) + def test_remove_latest_user_token_if_it_is_not_relevant(self): + installation_store = ValidUserTokenInstallationStore() + authorize = InstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W333" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid" + assert result.user_token is None + assert_auth_test_count(self, 1) + + def test_rotate_only_bot_token(self): + context = BoltContext() + mock_client = WebClient(base_url=self.mock_api_server_base_url) + context["client"] = mock_client + + installation_store = ValidUserTokenRotationInstallationStore() + invalid_authorize = InstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + with pytest.raises(BoltError): + invalid_authorize( + context=context, + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W333", + ) + + authorize = InstallationStoreAuthorize( + client_id="111.222", + client_secret="secret", + client=mock_client, + logger=installation_store.logger, + installation_store=installation_store, + ) + result = authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W333" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid-refreshed" + assert result.user_token is None + assert_auth_test_count(self, 1) + class LegacyMemoryInstallationStore(InstallationStore): @property diff --git a/tests/slack_bolt_async/authorization/test_async_authorize.py b/tests/slack_bolt_async/authorization/test_async_authorize.py index 495d70302..ccc75df5c 100644 --- a/tests/slack_bolt_async/authorization/test_async_authorize.py +++ b/tests/slack_bolt_async/authorization/test_async_authorize.py @@ -272,6 +272,57 @@ async def test_fetch_different_user_token_with_rotation(self): assert result.user_token == "xoxp-valid-refreshed" await assert_auth_test_count_async(self, 1) + @pytest.mark.asyncio + async def test_remove_latest_user_token_if_it_is_not_relevant(self): + installation_store = ValidUserTokenInstallationStore() + authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + context = AsyncBoltContext() + context["client"] = AsyncWebClient(base_url=self.mock_api_server_base_url) + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W333" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid" + assert result.user_token is None + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_rotate_only_bot_token(self): + context = AsyncBoltContext() + mock_client = AsyncWebClient(base_url=self.mock_api_server_base_url) + context["client"] = mock_client + + installation_store = ValidUserTokenRotationInstallationStore() + invalid_authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + with pytest.raises(BoltError): + await invalid_authorize( + context=context, + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W333", + ) + + authorize = AsyncInstallationStoreAuthorize( + client_id="111.222", + client_secret="secret", + client=mock_client, + logger=installation_store.logger, + installation_store=installation_store, + ) + result = await authorize( + context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W333" + ) + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid-refreshed" + assert result.user_token is None + await assert_auth_test_count_async(self, 1) + class LegacyMemoryInstallationStore(AsyncInstallationStore): @property From 47801c8dbdfeae377d8f7f4a883708df7c127e88 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 1 Feb 2022 15:44:30 +0900 Subject: [PATCH 447/865] version 1.11.4 --- docs/api-docs/slack_bolt/authorization/async_authorize.html | 6 ++++++ docs/api-docs/slack_bolt/authorization/authorize.html | 6 ++++++ docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/api-docs/slack_bolt/authorization/async_authorize.html b/docs/api-docs/slack_bolt/authorization/async_authorize.html index 29af799ee..4621b7b74 100644 --- a/docs/api-docs/slack_bolt/authorization/async_authorize.html +++ b/docs/api-docs/slack_bolt/authorization/async_authorize.html @@ -222,7 +222,10 @@

    Module slack_bolt.authorization.async_authorizeAncestors

    if latest_installation.user_id != user_id: # First off, remove the user token as the installer is a different user + user_token = None latest_installation.user_token = None + latest_installation.user_refresh_token = None + latest_installation.user_token_expires_at = None latest_installation.user_scopes = [] # try to fetch the request user's installation diff --git a/docs/api-docs/slack_bolt/authorization/authorize.html b/docs/api-docs/slack_bolt/authorization/authorize.html index c666154a3..ed28b0913 100644 --- a/docs/api-docs/slack_bolt/authorization/authorize.html +++ b/docs/api-docs/slack_bolt/authorization/authorize.html @@ -222,7 +222,10 @@

    Module slack_bolt.authorization.authorize

    if latest_installation.user_id != user_id: # First off, remove the user token as the installer is a different user + user_token = None latest_installation.user_token = None + latest_installation.user_refresh_token = None + latest_installation.user_token_expires_at = None latest_installation.user_scopes = [] # try to fetch the request user's installation @@ -593,7 +596,10 @@

    Ancestors

    if latest_installation.user_id != user_id: # First off, remove the user token as the installer is a different user + user_token = None latest_installation.user_token = None + latest_installation.user_refresh_token = None + latest_installation.user_token_expires_at = None latest_installation.user_scopes = [] # try to fetch the request user's installation diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index 8cc0587df..be0df1171 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.11.3"
    +__version__ = "1.11.4"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 61ceb7244..e428a0023 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.11.3" +__version__ = "1.11.4" From 1d139698eb88dda1aad40e67eac11bf38c5d02fd Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Tue, 1 Feb 2022 15:46:02 -0800 Subject: [PATCH 448/865] Update ngrok link (#587) --- docs/_tutorials/getting_started_http.md | 2 +- docs/_tutorials/ja_getting_started_http.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_tutorials/getting_started_http.md b/docs/_tutorials/getting_started_http.md index 3882f6d9c..795a1e891 100644 --- a/docs/_tutorials/getting_started_http.md +++ b/docs/_tutorials/getting_started_http.md @@ -137,7 +137,7 @@ Let's enable events for your app: 2. Add your Request URL. Slack will send HTTP POST requests corresponding to events to this [Request URL](https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls) endpoint. Bolt uses the `/slack/events` path to listen to all incoming requests (whether shortcuts, events, or interactivity payloads). When configuring your Request URL within your app configuration, you'll append `/slack/events`, e.g. `https:///slack/events`. 💡 As long as your Bolt app is still running, your URL should become verified. -> 💡 For local development, you can use a proxy service like ngrok to create a public URL and tunnel requests to your development environment. We've written a separate tutorial about [using ngrok with Slack for local development](https://api.slack.com/tutorials/tunneling-with-ngrok) that should help you get everything set up. And when you get to hosting your app, we've collected some of the most common hosting providers Slack developers use to host their apps [on our API site](https://api.slack.com/docs/hosting). +> 💡 For local development, you can use a proxy service like ngrok to create a public URL and tunnel requests to your development environment. Refer to [ngrok's getting started guide](https://ngrok.com/docs#getting-started-expose) on how to create this tunnel. And when you get to hosting your app, we've collected some of the most common hosting providers Slack developers use to host their apps [on our API site](https://api.slack.com/docs/hosting). Finally, it's time to tell Slack what events we'd like to listen for. diff --git a/docs/_tutorials/ja_getting_started_http.md b/docs/_tutorials/ja_getting_started_http.md index 13088add4..cf8bb35fb 100644 --- a/docs/_tutorials/ja_getting_started_http.md +++ b/docs/_tutorials/ja_getting_started_http.md @@ -142,7 +142,7 @@ Slack ワークスペースで発生するイベント(メッセージが投 1. [アプリ管理ページ](https://api.slack.com/apps)でアプリをクリックします。次に、左サイドバーの「**Event Subscriptions**」をクリックします。「**Enable Events**」というラベルのスイッチをオンに切り替えます。 2. リクエストURLを追加します。Slackはイベントに対応するHTTP POSTリクエストをこの [Request URL](https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls) のエンドポイントに送信します。Bolt は `/slack/events` のエンドポイントで、全ての受信リクエストをリッスンします。これらのリクエストにはショートカット、イベント、インタラクションペイロードが含まれます。アプリの設定でエンドポイントを指定するときは、すべての Request URL の末尾に「/slack/events」を追加してください。例えば、 `https:///slack/events` のようになります。Bolt アプリが起動した状態のままなら、URL の検証が成功するはずです。 -> 💡 ローカルでの開発時には、ngrok のような開発用プロキシサービスを利用することができます。開発用プロキシを利用すると、リクエストを開発環境にトンネルするパブリック URL を作成できます。[Slack のローカル開発で ngrok を使用する方法](https://api.slack.com/tutorials/tunneling-with-ngrok)については、別のチュートリアルを用意していますので参考にしてください。また、アプリのホスティングが必要になった場合には、[API サイトに](https://api.slack.com/docs/hosting) Slack開発者達がアプリのホスティングよく利用するホスティングプロバイダーを集めています。 +> 💡 ローカル開発では、[ngrok](https://ngrok.com/)のようなプロキシサービスを使って公開 URL を作成し、リクエストを開発環境にトンネリングすることができます。このトンネリングの方法については、[ngrok のガイド](https://ngrok.com/docs#getting-started-expose)を参照してください。また、アプリのホスティングが必要になった場合には、[API サイトに](https://api.slack.com/docs/hosting) Slack開発者達がアプリのホスティングよく利用するホスティングプロバイダーを集めています。 それでは、Slackにどのイベントをリッスンするかを教えてあげましょう。 From 78b64878b692039e39530888a012bf738ca74058 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 5 Feb 2022 08:25:15 +0900 Subject: [PATCH 449/865] Upgrade test dependencies & fix Falcon warning (#588) * Upgrade test dependencies & fix Falcon warning * Organize dependencies in setup.py --- .github/maintainers_guide.md | 1 + .github/workflows/codecov.yml | 1 + .github/workflows/tests.yml | 7 +++- scripts/install_all_and_run_tests.sh | 14 ++++++- scripts/run_pytype.sh | 2 +- scripts/run_tests.sh | 1 + setup.py | 40 +++++++++++-------- slack_bolt/adapter/falcon/resource.py | 8 +++- .../django/test_django_settings.py | 3 ++ tests/adapter_tests/falcon/test_falcon.py | 17 ++++++-- 10 files changed, 67 insertions(+), 27 deletions(-) diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index 550480f74..03b643aaf 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -89,6 +89,7 @@ If you make changes to `slack_bolt/adapter/*`, please verify if it surely works ```bash # Install all optional dependencies $ pip install -e ".[adapter]" +$ pip install -e ".[adapter_testing]" # Set required env variables $ export SLACK_SIGNING_SECRET=*** diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 37b87fc4c..340b3d22d 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -29,6 +29,7 @@ jobs: pip install -e ".[async]" pip install -e ".[adapter]" pip install -e ".[testing]" + pip install -e ".[adapter_testing]" - name: Run all tests for codecov run: | pytest --cov=slack_bolt/ && bash <(curl -s https://codecov.io/bash) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c32fd1154..8be5e12ed 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,6 +34,7 @@ jobs: - name: Run tests for Socket Mode adapters run: | pip install -e ".[adapter]" + pip install -e ".[adapter_testing]" pytest tests/adapter_tests/socket_mode/ - name: Run tests for HTTP Mode adapters (AWS) run: | @@ -47,9 +48,13 @@ jobs: - name: Run tests for HTTP Mode adapters (Django) run: | pytest tests/adapter_tests/django/ - - name: Run tests for HTTP Mode adapters (Falcon) + - name: Run tests for HTTP Mode adapters (Falcon 3.x) run: | pytest tests/adapter_tests/falcon/ + - name: Run tests for HTTP Mode adapters (Falcon 2.x) + run: | + pip install "falcon<3" + pytest tests/adapter_tests/falcon/ - name: Run tests for HTTP Mode adapters (Flask) run: | pytest tests/adapter_tests/flask/ diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index facbde4aa..cda15a058 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -15,13 +15,23 @@ pip install -e . if [[ $test_target != "" ]] then - pip install -e ".[testing]" && \ + # To fix: Using legacy 'setup.py install' for greenlet, since package 'wheel' is not installed. + pip install -U wheel && \ + pip install -e ".[testing]" && \ pip install -e ".[adapter]" && \ + pip install -e ".[adapter_testing]" && \ + # To avoid errors due to the old versions of click forced by Chalice + pip install -U pip click && \ black slack_bolt/ tests/ && \ pytest $1 else - pip install -e ".[testing]" && \ + # To fix: Using legacy 'setup.py install' for greenlet, since package 'wheel' is not installed. + pip install -U wheel && \ + pip install -e ".[testing]" && \ pip install -e ".[adapter]" && \ + pip install -e ".[adapter_testing]" && \ + # To avoid errors due to the old versions of click forced by Chalice + pip install -U pip click && \ black slack_bolt/ tests/ && \ pytest && \ pip install -U pytype && \ diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index debbc1f68..9c24cca46 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -5,5 +5,5 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ pip install -e ".[async]" && \ pip install -e ".[adapter]" && \ - pip install "pytype==2022.1.13" && \ + pip install "pytype==2022.1.31" && \ pytype slack_bolt/ diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 9931f8236..c380e7684 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -20,6 +20,7 @@ else black slack_bolt/ tests/ \ && pytest -vv \ && pip install -e ".[adapter]" \ + && pip install -e ".[adapter_testing]" \ && pip install -U pip setuptools wheel \ && pip install -U pytype \ && pytype slack_bolt/ diff --git a/setup.py b/setup.py index 8f58c3d0f..8980d7661 100755 --- a/setup.py +++ b/setup.py @@ -15,9 +15,19 @@ test_dependencies = [ "pytest>=6.2.5,<7", "pytest-cov>=3,<4", - "Flask-Sockets>=0.2,<1", - "Werkzeug<2", # TODO: support Flask 2.x - "black==21.12b0", + "Flask-Sockets>=0.2,<1", # TODO: This module is not yet Flask 2.x compatible + "Werkzeug>=1,<2", # TODO: Flask-Sockets is not yet compatible with Flask 2.x + "black==22.1.0", +] + +adapter_test_dependencies = [ + "moto>=3,<4", # For AWS tests + "docker>=5,<6", # Used by moto + "boddle>=0.2,<0.3", # For Bottle app tests + "Flask>=1,<2", # TODO: Flask-Sockets is not yet compatible with Flask 2.x + "Werkzeug>=1,<2", # TODO: Flask-Sockets is not yet compatible with Flask 2.x + "sanic-testing>=0.7" if sys.version_info.minor > 6 else "", + "requests>=2,<3", # For Starlette's TestClient ] async_test_dependencies = test_dependencies + [ @@ -63,36 +73,32 @@ "adapter": [ # used only under src/slack_bolt/adapter "boto3<=2", - # TODO: Upgrade to v2 - "moto<2", # For AWS tests "bottle>=0.12,<1", - "boddle>=0.2,<0.3", # For Bottle app tests - "chalice>=1.26.1,<2", + "chalice>=1.26.5,<2", "click>=7,<8", # for chalice "CherryPy>=18,<19", - "Django>=3,<4", - "falcon>=2,<3", + "Django>=3,<5", + "falcon>=2,<4", "fastapi>=0.70.0,<1", - "Flask>=1,<2", - "Werkzeug<2", # TODO: support Flask 2.x - "pyramid>=1,<2", + "Flask>=1,<3", + "Werkzeug>=2,<3", + "pyramid>=1,<3", "sanic>=21,<22" if sys.version_info.minor > 6 else "sanic>=20,<21", - "sanic-testing>=0.7" if sys.version_info.minor > 6 else "", "starlette>=0.14,<1", - "requests>=2,<3", # For starlette's TestClient "tornado>=6,<7", # server "uvicorn<1", "gunicorn>=20,<21", # Socket Mode 3rd party implementation - # TODO: 1.2.2 has a regression (https://github.com/websocket-client/websocket-client/issues/769) - # ERROR on_error invoked (error: AttributeError, message: 'Dispatcher' object has no attribute 'read') - "websocket_client>=1,<1.2.2", + # Note: 1.2.2 has a regression (https://github.com/websocket-client/websocket-client/issues/769) + "websocket_client>=1.2.3,<2", ], # pip install -e ".[testing_without_asyncio]" "testing_without_asyncio": test_dependencies, # pip install -e ".[testing]" "testing": async_test_dependencies, + # pip install -e ".[adapter_testing]" + "adapter_testing": adapter_test_dependencies, }, classifiers=[ "Programming Language :: Python :: 3.6", diff --git a/slack_bolt/adapter/falcon/resource.py b/slack_bolt/adapter/falcon/resource.py index fb6fd24bf..e14ea71ce 100644 --- a/slack_bolt/adapter/falcon/resource.py +++ b/slack_bolt/adapter/falcon/resource.py @@ -1,7 +1,7 @@ from datetime import datetime # type: ignore from http import HTTPStatus -from falcon import Request, Response +from falcon import Request, Response, version as falcon_version from slack_bolt import BoltResponse from slack_bolt.app import App @@ -50,7 +50,11 @@ def _to_bolt_request(self, req: Request) -> BoltRequest: ) def _write_response(self, bolt_resp: BoltResponse, resp: Response): - resp.body = bolt_resp.body + if falcon_version.__version__.startswith("2."): + resp.body = bolt_resp.body + else: + resp.text = bolt_resp.body + status = HTTPStatus(bolt_resp.status) resp.status = str(f"{status.value} {status.phrase}") resp.set_headers(bolt_resp.first_headers_without_set_cookie()) diff --git a/tests/adapter_tests/django/test_django_settings.py b/tests/adapter_tests/django/test_django_settings.py index 8811343fe..95eecbdd5 100644 --- a/tests/adapter_tests/django/test_django_settings.py +++ b/tests/adapter_tests/django/test_django_settings.py @@ -5,3 +5,6 @@ "NAME": "logs/db.sqlite3", } } +# Django 4 warning: The default value of USE_TZ will change from False to True in Django 5.0. +# Set USE_TZ to False in your project settings if you want to keep the current default behavior. +USE_TZ = False diff --git a/tests/adapter_tests/falcon/test_falcon.py b/tests/adapter_tests/falcon/test_falcon.py index 2cd9cdd59..143b8bb08 100644 --- a/tests/adapter_tests/falcon/test_falcon.py +++ b/tests/adapter_tests/falcon/test_falcon.py @@ -3,6 +3,8 @@ from urllib.parse import quote import falcon + + from falcon import testing from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient @@ -18,6 +20,13 @@ from tests.utils import remove_os_env_temporarily, restore_os_env +def new_falcon_app(): + if falcon.version.__version__.startswith("2."): + return falcon.API() + else: + return falcon.App() + + class TestFalcon: signing_secret = "secret" valid_token = "xoxb-valid" @@ -87,7 +96,7 @@ def event_handler(): } timestamp, body = str(int(time())), json.dumps(input) - api = falcon.API() + api = new_falcon_app() resource = SlackAppResource(app) api.add_route("/slack/events", resource) @@ -128,7 +137,7 @@ def shortcut_handler(ack): timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" - api = falcon.API() + api = new_falcon_app() resource = SlackAppResource(app) api.add_route("/slack/events", resource) @@ -169,7 +178,7 @@ def command_handler(ack): ) timestamp, body = str(int(time())), input - api = falcon.API() + api = new_falcon_app() resource = SlackAppResource(app) api.add_route("/slack/events", resource) @@ -192,7 +201,7 @@ def test_oauth(self): scopes=["chat:write", "commands"], ), ) - api = falcon.API() + api = new_falcon_app() resource = SlackAppResource(app) api.add_route("/slack/install", resource) From c0e98b0150a91df4ae9f85431c9ed2639dbb94ab Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 26 Feb 2022 07:48:07 +0900 Subject: [PATCH 450/865] Fix build failures due to itsdangerous package release --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 8980d7661..e2a1611d5 100755 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ "pytest-cov>=3,<4", "Flask-Sockets>=0.2,<1", # TODO: This module is not yet Flask 2.x compatible "Werkzeug>=1,<2", # TODO: Flask-Sockets is not yet compatible with Flask 2.x + "itsdangerous==2.0.1", # TODO: Flask-Sockets is not yet compatible with Flask 2.x "black==22.1.0", ] From 52b1512e4d89229a313517fe6243df67dff42d5a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 26 Feb 2022 08:03:52 +0900 Subject: [PATCH 451/865] Fix #601 Allow for host option for AsyncSlackAppServer start method (#602) --- slack_bolt/app/async_app.py | 18 +++++++++++++----- slack_bolt/app/async_server.py | 10 ++++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index b80b650c8..92ce70beb 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -446,14 +446,18 @@ def process_before_response(self) -> bool: from .async_server import AsyncSlackAppServer def server( - self, port: int = 3000, path: str = "/slack/events" + self, + port: int = 3000, + path: str = "/slack/events", + host: Optional[str] = None, ) -> AsyncSlackAppServer: """Configure a web server using AIOHTTP. Refer to https://docs.aiohttp.org/ for more details about AIOHTTP. Args: port: The port to listen on (Default: 3000) - path:The path to handle request from Slack (Default: `/slack/events`) + path: The path to handle request from Slack (Default: `/slack/events`) + host: The hostname to serve the web endpoints. (Default: 0.0.0.0) """ if ( self._server is None @@ -464,6 +468,7 @@ def server( port=port, path=path, app=self, + host=host, ) return self._server @@ -488,15 +493,18 @@ def app_factory(): """ return self.server(path=path).web_app - def start(self, port: int = 3000, path: str = "/slack/events") -> None: + def start( + self, port: int = 3000, path: str = "/slack/events", host: Optional[str] = None + ) -> None: """Start a web server using AIOHTTP. Refer to https://docs.aiohttp.org/ for more details about AIOHTTP. Args: port: The port to listen on (Default: 3000) - path:The path to handle request from Slack (Default: `/slack/events`) + path: The path to handle request from Slack (Default: `/slack/events`) + host: The hostname to serve the web endpoints. (Default: 0.0.0.0) """ - self.server(port=port, path=path).start() + self.server(port=port, path=path, host=host).start() # ------------------------- # main dispatcher diff --git a/slack_bolt/app/async_server.py b/slack_bolt/app/async_server.py index e5176fab2..b1e1bca44 100644 --- a/slack_bolt/app/async_server.py +++ b/slack_bolt/app/async_server.py @@ -1,4 +1,5 @@ import logging +from typing import Optional from aiohttp import web @@ -10,6 +11,7 @@ class AsyncSlackAppServer: port: int path: str + host: str bolt_app: "AsyncApp" # type:ignore web_app: web.Application @@ -18,6 +20,7 @@ def __init__( # type:ignore port: int, path: str, app: "AsyncApp", # type:ignore + host: Optional[str] = None, ): """Standalone AIOHTTP Web Server. Refer to https://docs.aiohttp.org/en/stable/web.html for details of AIOHTTP. @@ -26,9 +29,11 @@ def __init__( # type:ignore port: The port to listen on path: The path to receive incoming requests from Slack app: The `AsyncApp` instance that is used for processing requests + host: The hostname to serve the web endpoints. (Default: 0.0.0.0) """ self.port = port self.path = path + self.host = host if host is not None else "0.0.0.0" self.bolt_app: "AsyncApp" = app # type: ignore self.web_app = web.Application() self._bolt_oauth_flow = self.bolt_app.oauth_flow @@ -72,11 +77,12 @@ async def handle_post_requests(self, request: web.Request) -> web.Response: bolt_resp: BoltResponse = await self.bolt_app.async_dispatch(bolt_req) return await to_aiohttp_response(bolt_resp) - def start(self) -> None: + def start(self, host: Optional[str] = None) -> None: """Starts a new web server process.""" if self.bolt_app.logger.level > logging.INFO: print(get_boot_message()) else: self.bolt_app.logger.info(get_boot_message()) - web.run_app(self.web_app, host="0.0.0.0", port=self.port) + _host = host if host is not None else self.host + web.run_app(self.web_app, host=_host, port=self.port) From d3230344c20f182a2c7fee36c375bdcdfa6afa61 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 26 Feb 2022 08:19:11 +0900 Subject: [PATCH 452/865] Upgrade pytype version --- scripts/run_pytype.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index 9c24cca46..273ce37f4 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -5,5 +5,5 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ pip install -e ".[async]" && \ pip install -e ".[adapter]" && \ - pip install "pytype==2022.1.31" && \ + pip install "pytype==2022.2.23" && \ pytype slack_bolt/ From a3858eadb2322c11dd4ca83d11c593a4638b02e9 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 26 Feb 2022 08:20:12 +0900 Subject: [PATCH 453/865] version 1.11.5 --- .../slack_bolt/adapter/falcon/resource.html | 14 +++- docs/api-docs/slack_bolt/app/async_app.html | 72 +++++++++++++------ .../api-docs/slack_bolt/app/async_server.html | 35 ++++++--- docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 5 files changed, 91 insertions(+), 34 deletions(-) diff --git a/docs/api-docs/slack_bolt/adapter/falcon/resource.html b/docs/api-docs/slack_bolt/adapter/falcon/resource.html index 7a59a9c63..47e8ea262 100644 --- a/docs/api-docs/slack_bolt/adapter/falcon/resource.html +++ b/docs/api-docs/slack_bolt/adapter/falcon/resource.html @@ -29,7 +29,7 @@

    Module slack_bolt.adapter.falcon.resource

    from datetime import datetime  # type: ignore
     from http import HTTPStatus
     
    -from falcon import Request, Response
    +from falcon import Request, Response, version as falcon_version
     
     from slack_bolt import BoltResponse
     from slack_bolt.app import App
    @@ -78,7 +78,11 @@ 

    Module slack_bolt.adapter.falcon.resource

    ) def _write_response(self, bolt_resp: BoltResponse, resp: Response): - resp.body = bolt_resp.body + if falcon_version.__version__.startswith("2."): + resp.body = bolt_resp.body + else: + resp.text = bolt_resp.body + status = HTTPStatus(bolt_resp.status) resp.status = str(f"{status.value} {status.phrase}") resp.set_headers(bolt_resp.first_headers_without_set_cookie()) @@ -166,7 +170,11 @@

    Classes

    ) def _write_response(self, bolt_resp: BoltResponse, resp: Response): - resp.body = bolt_resp.body + if falcon_version.__version__.startswith("2."): + resp.body = bolt_resp.body + else: + resp.text = bolt_resp.body + status = HTTPStatus(bolt_resp.status) resp.status = str(f"{status.value} {status.phrase}") resp.set_headers(bolt_resp.first_headers_without_set_cookie()) diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index f9c3059ca..52fe24ac6 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -474,14 +474,18 @@

    Module slack_bolt.app.async_app

    from .async_server import AsyncSlackAppServer def server( - self, port: int = 3000, path: str = "/slack/events" + self, + port: int = 3000, + path: str = "/slack/events", + host: Optional[str] = None, ) -> AsyncSlackAppServer: """Configure a web server using AIOHTTP. Refer to https://docs.aiohttp.org/ for more details about AIOHTTP. Args: port: The port to listen on (Default: 3000) - path:The path to handle request from Slack (Default: `/slack/events`) + path: The path to handle request from Slack (Default: `/slack/events`) + host: The hostname to serve the web endpoints. (Default: 0.0.0.0) """ if ( self._server is None @@ -492,6 +496,7 @@

    Module slack_bolt.app.async_app

    port=port, path=path, app=self, + host=host, ) return self._server @@ -516,15 +521,18 @@

    Module slack_bolt.app.async_app

    """ return self.server(path=path).web_app - def start(self, port: int = 3000, path: str = "/slack/events") -> None: + def start( + self, port: int = 3000, path: str = "/slack/events", host: Optional[str] = None + ) -> None: """Start a web server using AIOHTTP. Refer to https://docs.aiohttp.org/ for more details about AIOHTTP. Args: port: The port to listen on (Default: 3000) - path:The path to handle request from Slack (Default: `/slack/events`) + path: The path to handle request from Slack (Default: `/slack/events`) + host: The hostname to serve the web endpoints. (Default: 0.0.0.0) """ - self.server(port=port, path=path).start() + self.server(port=port, path=path, host=host).start() # ------------------------- # main dispatcher @@ -1866,14 +1874,18 @@

    Args

    from .async_server import AsyncSlackAppServer def server( - self, port: int = 3000, path: str = "/slack/events" + self, + port: int = 3000, + path: str = "/slack/events", + host: Optional[str] = None, ) -> AsyncSlackAppServer: """Configure a web server using AIOHTTP. Refer to https://docs.aiohttp.org/ for more details about AIOHTTP. Args: port: The port to listen on (Default: 3000) - path:The path to handle request from Slack (Default: `/slack/events`) + path: The path to handle request from Slack (Default: `/slack/events`) + host: The hostname to serve the web endpoints. (Default: 0.0.0.0) """ if ( self._server is None @@ -1884,6 +1896,7 @@

    Args

    port=port, path=path, app=self, + host=host, ) return self._server @@ -1908,15 +1921,18 @@

    Args

    """ return self.server(path=path).web_app - def start(self, port: int = 3000, path: str = "/slack/events") -> None: + def start( + self, port: int = 3000, path: str = "/slack/events", host: Optional[str] = None + ) -> None: """Start a web server using AIOHTTP. Refer to https://docs.aiohttp.org/ for more details about AIOHTTP. Args: port: The port to listen on (Default: 3000) - path:The path to handle request from Slack (Default: `/slack/events`) + path: The path to handle request from Slack (Default: `/slack/events`) + host: The hostname to serve the web endpoints. (Default: 0.0.0.0) """ - self.server(port=port, path=path).start() + self.server(port=port, path=path, host=host).start() # ------------------------- # main dispatcher @@ -3856,7 +3872,7 @@

    Args

    -def server(self, port: int = 3000, path: str = '/slack/events') ‑> AsyncSlackAppServer +def server(self, port: int = 3000, path: str = '/slack/events', host: Optional[str] = None) ‑> AsyncSlackAppServer

    Configure a web server using AIOHTTP. @@ -3865,21 +3881,28 @@

    Args

    port
    The port to listen on (Default: 3000)
    -
    -

    path:The path to handle request from Slack (Default: /slack/events)

    +
    path
    +
    The path to handle request from Slack (Default: /slack/events)
    +
    host
    +
    The hostname to serve the web endpoints. (Default: 0.0.0.0)
    +
    Expand source code
    def server(
    -    self, port: int = 3000, path: str = "/slack/events"
    +    self,
    +    port: int = 3000,
    +    path: str = "/slack/events",
    +    host: Optional[str] = None,
     ) -> AsyncSlackAppServer:
         """Configure a web server using AIOHTTP.
         Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
     
         Args:
             port: The port to listen on (Default: 3000)
    -        path:The path to handle request from Slack (Default: `/slack/events`)
    +        path: The path to handle request from Slack (Default: `/slack/events`)
    +        host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
         """
         if (
             self._server is None
    @@ -3890,6 +3913,7 @@ 

    Args

    port=port, path=path, app=self, + host=host, ) return self._server
    @@ -3981,7 +4005,7 @@

    Args

    -def start(self, port: int = 3000, path: str = '/slack/events') ‑> None +def start(self, port: int = 3000, path: str = '/slack/events', host: Optional[str] = None) ‑> None

    Start a web server using AIOHTTP. @@ -3990,21 +4014,27 @@

    Args

    port
    The port to listen on (Default: 3000)
    -
    -

    path:The path to handle request from Slack (Default: /slack/events)

    +
    path
    +
    The path to handle request from Slack (Default: /slack/events)
    +
    host
    +
    The hostname to serve the web endpoints. (Default: 0.0.0.0)
    +
    Expand source code -
    def start(self, port: int = 3000, path: str = "/slack/events") -> None:
    +
    def start(
    +    self, port: int = 3000, path: str = "/slack/events", host: Optional[str] = None
    +) -> None:
         """Start a web server using AIOHTTP.
         Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
     
         Args:
             port: The port to listen on (Default: 3000)
    -        path:The path to handle request from Slack (Default: `/slack/events`)
    +        path: The path to handle request from Slack (Default: `/slack/events`)
    +        host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
         """
    -    self.server(port=port, path=path).start()
    + self.server(port=port, path=path, host=host).start()
    diff --git a/docs/api-docs/slack_bolt/app/async_server.html b/docs/api-docs/slack_bolt/app/async_server.html index 24961f59a..428cab781 100644 --- a/docs/api-docs/slack_bolt/app/async_server.html +++ b/docs/api-docs/slack_bolt/app/async_server.html @@ -27,6 +27,7 @@

    Module slack_bolt.app.async_server

    Expand source code
    import logging
    +from typing import Optional
     
     from aiohttp import web
     
    @@ -38,6 +39,7 @@ 

    Module slack_bolt.app.async_server

    class AsyncSlackAppServer: port: int path: str + host: str bolt_app: "AsyncApp" # type:ignore web_app: web.Application @@ -46,6 +48,7 @@

    Module slack_bolt.app.async_server

    port: int, path: str, app: "AsyncApp", # type:ignore + host: Optional[str] = None, ): """Standalone AIOHTTP Web Server. Refer to https://docs.aiohttp.org/en/stable/web.html for details of AIOHTTP. @@ -54,9 +57,11 @@

    Module slack_bolt.app.async_server

    port: The port to listen on path: The path to receive incoming requests from Slack app: The `AsyncApp` instance that is used for processing requests + host: The hostname to serve the web endpoints. (Default: 0.0.0.0) """ self.port = port self.path = path + self.host = host if host is not None else "0.0.0.0" self.bolt_app: "AsyncApp" = app # type: ignore self.web_app = web.Application() self._bolt_oauth_flow = self.bolt_app.oauth_flow @@ -100,14 +105,15 @@

    Module slack_bolt.app.async_server

    bolt_resp: BoltResponse = await self.bolt_app.async_dispatch(bolt_req) return await to_aiohttp_response(bolt_resp) - def start(self) -> None: + def start(self, host: Optional[str] = None) -> None: """Starts a new web server process.""" if self.bolt_app.logger.level > logging.INFO: print(get_boot_message()) else: self.bolt_app.logger.info(get_boot_message()) - web.run_app(self.web_app, host="0.0.0.0", port=self.port)
    + _host = host if host is not None else self.host + web.run_app(self.web_app, host=_host, port=self.port)
    @@ -121,7 +127,7 @@

    Classes

    class AsyncSlackAppServer -(port: int, path: str, app: AsyncApp) +(port: int, path: str, app: AsyncApp, host: Optional[str] = None)

    Standalone AIOHTTP Web Server. @@ -134,6 +140,8 @@

    Args

    The path to receive incoming requests from Slack
    app
    The AsyncApp instance that is used for processing requests
    +
    host
    +
    The hostname to serve the web endpoints. (Default: 0.0.0.0)
    @@ -142,6 +150,7 @@

    Args

    class AsyncSlackAppServer:
         port: int
         path: str
    +    host: str
         bolt_app: "AsyncApp"  # type:ignore
         web_app: web.Application
     
    @@ -150,6 +159,7 @@ 

    Args

    port: int, path: str, app: "AsyncApp", # type:ignore + host: Optional[str] = None, ): """Standalone AIOHTTP Web Server. Refer to https://docs.aiohttp.org/en/stable/web.html for details of AIOHTTP. @@ -158,9 +168,11 @@

    Args

    port: The port to listen on path: The path to receive incoming requests from Slack app: The `AsyncApp` instance that is used for processing requests + host: The hostname to serve the web endpoints. (Default: 0.0.0.0) """ self.port = port self.path = path + self.host = host if host is not None else "0.0.0.0" self.bolt_app: "AsyncApp" = app # type: ignore self.web_app = web.Application() self._bolt_oauth_flow = self.bolt_app.oauth_flow @@ -204,14 +216,15 @@

    Args

    bolt_resp: BoltResponse = await self.bolt_app.async_dispatch(bolt_req) return await to_aiohttp_response(bolt_resp) - def start(self) -> None: + def start(self, host: Optional[str] = None) -> None: """Starts a new web server process.""" if self.bolt_app.logger.level > logging.INFO: print(get_boot_message()) else: self.bolt_app.logger.info(get_boot_message()) - web.run_app(self.web_app, host="0.0.0.0", port=self.port)
    + _host = host if host is not None else self.host + web.run_app(self.web_app, host=_host, port=self.port)

    Class variables

    @@ -219,6 +232,10 @@

    Class variables

    +
    var host : str
    +
    +
    +
    var path : str
    @@ -279,7 +296,7 @@

    Methods

    -def start(self) ‑> None +def start(self, host: Optional[str] = None) ‑> None

    Starts a new web server process.

    @@ -287,14 +304,15 @@

    Methods

    Expand source code -
    def start(self) -> None:
    +
    def start(self, host: Optional[str] = None) -> None:
         """Starts a new web server process."""
         if self.bolt_app.logger.level > logging.INFO:
             print(get_boot_message())
         else:
             self.bolt_app.logger.info(get_boot_message())
     
    -    web.run_app(self.web_app, host="0.0.0.0", port=self.port)
    + _host = host if host is not None else self.host + web.run_app(self.web_app, host=_host, port=self.port)
    @@ -321,6 +339,7 @@

    bolt_app
  • handle_get_requests
  • handle_post_requests
  • +
  • host
  • path
  • port
  • start
  • diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index be0df1171..dd6dbfd4c 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.11.4"
    +__version__ = "1.11.5"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index e428a0023..6ed6690f9 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.11.4" +__version__ = "1.11.5" From 3508337d91809fdb709499a2391ca4c84263dbae Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 3 Mar 2022 06:54:05 +0900 Subject: [PATCH 454/865] Fix #604 Respect the proxy_url in respond (#608) --- slack_bolt/context/async_context.py | 6 +- slack_bolt/context/context.py | 6 +- slack_bolt/context/respond/async_respond.py | 21 +++++- slack_bolt/context/respond/respond.py | 21 +++++- tests/scenario_tests/test_app.py | 55 +++++++++++++- tests/scenario_tests_async/test_app.py | 84 +++++++++++++++++++++ 6 files changed, 184 insertions(+), 9 deletions(-) diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index 46eb6c69d..81105c6df 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -116,5 +116,9 @@ async def handle_button_clicks(ack, respond): Callable `respond()` function """ if "respond" not in self: - self["respond"] = AsyncRespond(response_url=self.response_url) + self["respond"] = AsyncRespond( + response_url=self.response_url, + proxy=self.client.proxy, + ssl=self.client.ssl, + ) return self["respond"] diff --git a/slack_bolt/context/context.py b/slack_bolt/context/context.py index a92ac05bb..570a7080e 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -118,5 +118,9 @@ def handle_button_clicks(ack, respond): Callable `respond()` function """ if "respond" not in self: - self["respond"] = Respond(response_url=self.response_url) + self["respond"] = Respond( + response_url=self.response_url, + proxy=self.client.proxy, + ssl=self.client.ssl, + ) return self["respond"] diff --git a/slack_bolt/context/respond/async_respond.py b/slack_bolt/context/respond/async_respond.py index 60e4fe473..a589ef792 100644 --- a/slack_bolt/context/respond/async_respond.py +++ b/slack_bolt/context/respond/async_respond.py @@ -1,4 +1,5 @@ from typing import Optional, Union, Sequence +from ssl import SSLContext from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block @@ -9,9 +10,19 @@ class AsyncRespond: response_url: Optional[str] + proxy: Optional[str] + ssl: Optional[SSLContext] - def __init__(self, *, response_url: Optional[str]): - self.response_url: Optional[str] = response_url + def __init__( + self, + *, + response_url: Optional[str], + proxy: Optional[str] = None, + ssl: Optional[SSLContext] = None, + ): + self.response_url = response_url + self.proxy = proxy + self.ssl = ssl async def __call__( self, @@ -25,7 +36,11 @@ async def __call__( unfurl_media: Optional[bool] = None, ) -> WebhookResponse: if self.response_url is not None: - client = AsyncWebhookClient(self.response_url) + client = AsyncWebhookClient( + url=self.response_url, + proxy=self.proxy, + ssl=self.ssl, + ) text_or_whole_response: Union[str, dict] = text if isinstance(text_or_whole_response, str): message = _build_message( diff --git a/slack_bolt/context/respond/respond.py b/slack_bolt/context/respond/respond.py index 21de92263..6cef9c36f 100644 --- a/slack_bolt/context/respond/respond.py +++ b/slack_bolt/context/respond/respond.py @@ -1,4 +1,5 @@ from typing import Optional, Union, Sequence +from ssl import SSLContext from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block @@ -9,9 +10,19 @@ class Respond: response_url: Optional[str] + proxy: Optional[str] + ssl: Optional[SSLContext] - def __init__(self, *, response_url: Optional[str]): - self.response_url: Optional[str] = response_url + def __init__( + self, + *, + response_url: Optional[str], + proxy: Optional[str] = None, + ssl: Optional[SSLContext] = None, + ): + self.response_url = response_url + self.proxy = proxy + self.ssl = ssl def __call__( self, @@ -25,7 +36,11 @@ def __call__( unfurl_media: Optional[bool] = None, ) -> WebhookResponse: if self.response_url is not None: - client = WebhookClient(self.response_url) + client = WebhookClient( + url=self.response_url, + proxy=self.proxy, + ssl=self.ssl, + ) text_or_whole_response: Union[str, dict] = text if isinstance(text_or_whole_response, str): text = text_or_whole_response diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index 936ad68ec..86e89c9e9 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -1,11 +1,12 @@ from concurrent.futures import Executor +from ssl import SSLContext import pytest from slack_sdk import WebClient from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore -from slack_bolt import App, Say, BoltRequest +from slack_bolt import App, Say, BoltRequest, BoltContext from slack_bolt.authorization import AuthorizeResult from slack_bolt.error import BoltError from slack_bolt.oauth import OAuthFlow @@ -240,3 +241,55 @@ def test_none_body_no_middleware(self): response = app.dispatch(req) assert response.status == 404 assert response.body == '{"error": "unhandled request"}' + + def test_proxy_ssl_for_respond(self): + ssl = SSLContext() + web_client = WebClient( + token=self.valid_token, + base_url=self.mock_api_server_base_url, + proxy="http://proxy-host:9000/", + ssl=ssl, + ) + app = App( + signing_secret="valid", + client=web_client, + authorize=lambda: AuthorizeResult( + enterprise_id="E111", + team_id="T111", + ), + ) + + event_body = { + "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, + } + + result = {"called": False} + + @app.event("app_mention") + def handle(context: BoltContext, respond): + assert context.respond.proxy == "http://proxy-host:9000/" + assert context.respond.ssl == ssl + assert respond.proxy == "http://proxy-host:9000/" + assert respond.ssl == ssl + result["called"] = True + + req = BoltRequest(body=event_body, headers={}, mode="socket_mode") + response = app.dispatch(req) + assert response.status == 200 + assert result["called"] is True diff --git a/tests/scenario_tests_async/test_app.py b/tests/scenario_tests_async/test_app.py index 1640087bc..7486027fe 100644 --- a/tests/scenario_tests_async/test_app.py +++ b/tests/scenario_tests_async/test_app.py @@ -1,17 +1,43 @@ +import asyncio +from ssl import SSLContext + import pytest from slack_sdk import WebClient from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.async_app import AsyncApp from slack_bolt.authorization import AuthorizeResult +from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.error import BoltError from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +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 TestAsyncApp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + + @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) + def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -163,3 +189,61 @@ def test_installation_store_conflicts(self): installation_store=store1, ) assert app.installation_store is store1 + + @pytest.mark.asyncio + async def test_proxy_ssl_for_respond(self): + ssl = SSLContext() + web_client = AsyncWebClient( + token=self.valid_token, + base_url=self.mock_api_server_base_url, + proxy="http://proxy-host:9000/", + ssl=ssl, + ) + + async def my_authorize(): + return AuthorizeResult( + enterprise_id="E111", + team_id="T111", + ) + + app = AsyncApp( + signing_secret="valid", + client=web_client, + authorize=my_authorize, + ) + + event_body = { + "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, + } + + result = {"called": False} + + @app.event("app_mention") + async def handle(context: AsyncBoltContext, respond): + assert context.respond.proxy == "http://proxy-host:9000/" + assert context.respond.ssl == ssl + assert respond.proxy == "http://proxy-host:9000/" + assert respond.ssl == ssl + result["called"] = True + + req = AsyncBoltRequest(body=event_body, headers={}, mode="socket_mode") + response = await app.async_dispatch(req) + assert response.status == 200 + await asyncio.sleep(0.5) # wait a bit after auto ack() + assert result["called"] is True From 8c883c667bab47a103e88eb8840b8bc429883f2f Mon Sep 17 00:00:00 2001 From: Fil Maj Date: Wed, 2 Mar 2022 16:54:49 -0500 Subject: [PATCH 455/865] Docs on handling options listeners with a filtering example (#609) Co-authored-by: Kazuhiro Sera --- docs/_basic/ja_listening_responding_options.md | 17 +++++++++++------ docs/_basic/listening_responding_options.md | 8 ++++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/_basic/ja_listening_responding_options.md b/docs/_basic/ja_listening_responding_options.md index e7951eeb3..03f4a1e5b 100644 --- a/docs/_basic/ja_listening_responding_options.md +++ b/docs/_basic/ja_listening_responding_options.md @@ -14,6 +14,8 @@ order: 14 オプションのリクエストに応答するときは、有効なオプションを含む `options` または `option_groups` のリストとともに `ack()` を呼び出す必要があります。API サイトにある[外部データを使用する選択メニューに応答するサンプル例](https://api.slack.com/reference/messaging/block-elements#external-select)と、[ダイアログでの応答例](https://api.slack.com/dialogs#dynamic_select_elements_external)を参考にしてください。 +さらに、ユーザーが入力したキーワードに基づいたオプションを返すようフィルタリングロジックを適用することもできます。 これは `payload` という引数の ` value` の値に基づいて、それぞれのパターンで異なるオプションの一覧を返すように実装することができます。 Bolt for Python のすべてのリスナーやミドルウェアでは、[多くの有用な引数](https://slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html)にアクセスすることができますので、チェックしてみてください。 +
    @@ -21,17 +23,20 @@ order: 14 ```python # 外部データを使用する選択メニューオプションに応答するサンプル例 @app.options("external_action") -def show_options(ack): +def show_options(ack, payload): options = [ { - "text": {"type": "plain_text", "text":"Option 1"}, - "value":"1-1", + "text": {"type": "plain_text", "text": "Option 1"}, + "value": "1-1", }, { - "text": {"type": "plain_text", "text":"Option 2"}, - "value":"1-2", + "text": {"type": "plain_text", "text": "Option 2"}, + "value": "1-2", }, ] + keyword = payload.get("value") + if keyword is not None and len(keyword) > 0: + options = [o for o in options if keyword in o["text"]["text"]] ack(options=options) ``` -
    \ No newline at end of file + diff --git a/docs/_basic/listening_responding_options.md b/docs/_basic/listening_responding_options.md index a50a3f06c..07b9fd12f 100644 --- a/docs/_basic/listening_responding_options.md +++ b/docs/_basic/listening_responding_options.md @@ -13,6 +13,7 @@ While it's recommended to use `action_id` for `external_select` menus, dialogs d To respond to options requests, you'll need to call `ack()` with a valid `options` or `option_groups` list. Both [external select response examples](https://api.slack.com/reference/messaging/block-elements#external-select) and [dialog response examples](https://api.slack.com/dialogs#dynamic_select_elements_external) can be found on our API site. +Additionally, you may want to apply filtering logic to the returned options based on user input. This can be accomplished by using the `payload` argument to your options listener and checking for the contents of the `value` property within it. Based on the `value` you can return different options. All listeners and middleware handlers in Bolt for Python have access to [many useful arguments](https://slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) - be sure to check them out!
    @@ -20,7 +21,7 @@ To respond to options requests, you'll need to call `ack()` with a valid `option ```python # Example of responding to an external_select options request @app.options("external_action") -def show_options(ack): +def show_options(ack, payload): options = [ { "text": {"type": "plain_text", "text": "Option 1"}, @@ -31,6 +32,9 @@ def show_options(ack): "value": "1-2", }, ] + keyword = payload.get("value") + if keyword is not None and len(keyword) > 0: + options = [o for o in options if keyword in o["text"]["text"]] ack(options=options) ``` -
    \ No newline at end of file + From 837c77325451af2e714df828dc6a34a5d9fe8638 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 3 Mar 2022 07:12:18 +0900 Subject: [PATCH 456/865] version 1.11.6 --- .../slack_bolt/context/async_context.html | 18 +++++-- docs/api-docs/slack_bolt/context/context.html | 18 +++++-- .../context/respond/async_respond.html | 53 ++++++++++++++++--- .../slack_bolt/context/respond/respond.html | 53 ++++++++++++++++--- docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 6 files changed, 124 insertions(+), 22 deletions(-) diff --git a/docs/api-docs/slack_bolt/context/async_context.html b/docs/api-docs/slack_bolt/context/async_context.html index f20c1d0f5..d38ac04f1 100644 --- a/docs/api-docs/slack_bolt/context/async_context.html +++ b/docs/api-docs/slack_bolt/context/async_context.html @@ -144,7 +144,11 @@

    Module slack_bolt.context.async_context

    Callable `respond()` function """ if "respond" not in self: - self["respond"] = AsyncRespond(response_url=self.response_url) + self["respond"] = AsyncRespond( + response_url=self.response_url, + proxy=self.client.proxy, + ssl=self.client.ssl, + ) return self["respond"]
    @@ -274,7 +278,11 @@

    Classes

    Callable `respond()` function """ if "respond" not in self: - self["respond"] = AsyncRespond(response_url=self.response_url) + self["respond"] = AsyncRespond( + response_url=self.response_url, + proxy=self.client.proxy, + ssl=self.client.ssl, + ) return self["respond"]

    Ancestors

    @@ -413,7 +421,11 @@

    Returns

    Callable `respond()` function """ if "respond" not in self: - self["respond"] = AsyncRespond(response_url=self.response_url) + self["respond"] = AsyncRespond( + response_url=self.response_url, + proxy=self.client.proxy, + ssl=self.client.ssl, + ) return self["respond"]
    diff --git a/docs/api-docs/slack_bolt/context/context.html b/docs/api-docs/slack_bolt/context/context.html index af4ad5f26..83ca9a177 100644 --- a/docs/api-docs/slack_bolt/context/context.html +++ b/docs/api-docs/slack_bolt/context/context.html @@ -146,7 +146,11 @@

    Module slack_bolt.context.context

    Callable `respond()` function """ if "respond" not in self: - self["respond"] = Respond(response_url=self.response_url) + self["respond"] = Respond( + response_url=self.response_url, + proxy=self.client.proxy, + ssl=self.client.ssl, + ) return self["respond"]
    @@ -277,7 +281,11 @@

    Classes

    Callable `respond()` function """ if "respond" not in self: - self["respond"] = Respond(response_url=self.response_url) + self["respond"] = Respond( + response_url=self.response_url, + proxy=self.client.proxy, + ssl=self.client.ssl, + ) return self["respond"]

    Ancestors

    @@ -416,7 +424,11 @@

    Returns

    Callable `respond()` function """ if "respond" not in self: - self["respond"] = Respond(response_url=self.response_url) + self["respond"] = Respond( + response_url=self.response_url, + proxy=self.client.proxy, + ssl=self.client.ssl, + ) return self["respond"]
    diff --git a/docs/api-docs/slack_bolt/context/respond/async_respond.html b/docs/api-docs/slack_bolt/context/respond/async_respond.html index 0ecb02cc5..55a118436 100644 --- a/docs/api-docs/slack_bolt/context/respond/async_respond.html +++ b/docs/api-docs/slack_bolt/context/respond/async_respond.html @@ -27,6 +27,7 @@

    Module slack_bolt.context.respond.async_respondExpand source code
    from typing import Optional, Union, Sequence
    +from ssl import SSLContext
     
     from slack_sdk.models.attachments import Attachment
     from slack_sdk.models.blocks import Block
    @@ -37,9 +38,19 @@ 

    Module slack_bolt.context.respond.async_respondModule slack_bolt.context.respond.async_respondClasses

    class AsyncRespond -(*, response_url: Optional[str]) +(*, response_url: Optional[str], proxy: Optional[str] = None, ssl: Optional[ssl.SSLContext] = None)
    @@ -98,9 +113,19 @@

    Classes

    class AsyncRespond:
         response_url: Optional[str]
    +    proxy: Optional[str]
    +    ssl: Optional[SSLContext]
     
    -    def __init__(self, *, response_url: Optional[str]):
    -        self.response_url: Optional[str] = response_url
    +    def __init__(
    +        self,
    +        *,
    +        response_url: Optional[str],
    +        proxy: Optional[str] = None,
    +        ssl: Optional[SSLContext] = None,
    +    ):
    +        self.response_url = response_url
    +        self.proxy = proxy
    +        self.ssl = ssl
     
         async def __call__(
             self,
    @@ -114,7 +139,11 @@ 

    Classes

    unfurl_media: Optional[bool] = None, ) -> WebhookResponse: if self.response_url is not None: - client = AsyncWebhookClient(self.response_url) + client = AsyncWebhookClient( + url=self.response_url, + proxy=self.proxy, + ssl=self.ssl, + ) text_or_whole_response: Union[str, dict] = text if isinstance(text_or_whole_response, str): message = _build_message( @@ -139,10 +168,18 @@

    Classes

    Class variables

    +
    var proxy : Optional[str]
    +
    +
    +
    var response_url : Optional[str]
    +
    var ssl : Optional[ssl.SSLContext]
    +
    +
    +
    @@ -164,7 +201,9 @@

    Index

  • AsyncRespond

  • diff --git a/docs/api-docs/slack_bolt/context/respond/respond.html b/docs/api-docs/slack_bolt/context/respond/respond.html index 9eaf73276..d3af708a2 100644 --- a/docs/api-docs/slack_bolt/context/respond/respond.html +++ b/docs/api-docs/slack_bolt/context/respond/respond.html @@ -27,6 +27,7 @@

    Module slack_bolt.context.respond.respond

    Expand source code
    from typing import Optional, Union, Sequence
    +from ssl import SSLContext
     
     from slack_sdk.models.attachments import Attachment
     from slack_sdk.models.blocks import Block
    @@ -37,9 +38,19 @@ 

    Module slack_bolt.context.respond.respond

    class Respond: response_url: Optional[str] + proxy: Optional[str] + ssl: Optional[SSLContext] - def __init__(self, *, response_url: Optional[str]): - self.response_url: Optional[str] = response_url + def __init__( + self, + *, + response_url: Optional[str], + proxy: Optional[str] = None, + ssl: Optional[SSLContext] = None, + ): + self.response_url = response_url + self.proxy = proxy + self.ssl = ssl def __call__( self, @@ -53,7 +64,11 @@

    Module slack_bolt.context.respond.respond

    unfurl_media: Optional[bool] = None, ) -> WebhookResponse: if self.response_url is not None: - client = WebhookClient(self.response_url) + client = WebhookClient( + url=self.response_url, + proxy=self.proxy, + ssl=self.ssl, + ) text_or_whole_response: Union[str, dict] = text if isinstance(text_or_whole_response, str): text = text_or_whole_response @@ -90,7 +105,7 @@

    Classes

    class Respond -(*, response_url: Optional[str]) +(*, response_url: Optional[str], proxy: Optional[str] = None, ssl: Optional[ssl.SSLContext] = None)
    @@ -100,9 +115,19 @@

    Classes

    class Respond:
         response_url: Optional[str]
    +    proxy: Optional[str]
    +    ssl: Optional[SSLContext]
     
    -    def __init__(self, *, response_url: Optional[str]):
    -        self.response_url: Optional[str] = response_url
    +    def __init__(
    +        self,
    +        *,
    +        response_url: Optional[str],
    +        proxy: Optional[str] = None,
    +        ssl: Optional[SSLContext] = None,
    +    ):
    +        self.response_url = response_url
    +        self.proxy = proxy
    +        self.ssl = ssl
     
         def __call__(
             self,
    @@ -116,7 +141,11 @@ 

    Classes

    unfurl_media: Optional[bool] = None, ) -> WebhookResponse: if self.response_url is not None: - client = WebhookClient(self.response_url) + client = WebhookClient( + url=self.response_url, + proxy=self.proxy, + ssl=self.ssl, + ) text_or_whole_response: Union[str, dict] = text if isinstance(text_or_whole_response, str): text = text_or_whole_response @@ -143,10 +172,18 @@

    Classes

    Class variables

    +
    var proxy : Optional[str]
    +
    +
    +
    var response_url : Optional[str]
    +
    var ssl : Optional[ssl.SSLContext]
    +
    +
    +
    @@ -168,7 +205,9 @@

    Index

  • Respond

  • diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index dd6dbfd4c..c24e4bed2 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.11.5"
    +__version__ = "1.11.6"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 6ed6690f9..d96b2b137 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.11.5" +__version__ = "1.11.6" From 911e21d0040b891e6038aa50fe27e6579ea54b8e Mon Sep 17 00:00:00 2001 From: sarayourfriend <24264157+sarayourfriend@users.noreply.github.com> Date: Tue, 15 Mar 2022 12:41:59 +0000 Subject: [PATCH 457/865] Add ASGI Falcon App support (#614) Co-authored-by: Kazuhiro Sera --- .github/workflows/tests.yml | 1 + examples/falcon/async_app.py | 100 +++++++++ examples/falcon/async_oauth_app.py | 102 +++++++++ examples/falcon/requirements.txt | 3 +- slack_bolt/adapter/falcon/__init__.py | 1 + slack_bolt/adapter/falcon/async_resource.py | 84 +++++++ .../adapter_tests_async/test_async_falcon.py | 209 ++++++++++++++++++ 7 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 examples/falcon/async_app.py create mode 100644 examples/falcon/async_oauth_app.py create mode 100644 slack_bolt/adapter/falcon/async_resource.py create mode 100644 tests/adapter_tests_async/test_async_falcon.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8be5e12ed..85921dadb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -70,4 +70,5 @@ jobs: - name: Run tests for HTTP Mode adapters (asyncio-based libraries) run: | pip install -e ".[async]" + pip install "falcon>=3,<4" pytest tests/adapter_tests_async/ diff --git a/examples/falcon/async_app.py b/examples/falcon/async_app.py new file mode 100644 index 000000000..44989d4fa --- /dev/null +++ b/examples/falcon/async_app.py @@ -0,0 +1,100 @@ +import falcon +import logging +import re +from slack_bolt.async_app import AsyncApp, AsyncRespond, AsyncAck +from slack_bolt.adapter.falcon import AsyncSlackAppResource + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() + + +# @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"]) +async def test_command(logger: logging.Logger, body: dict, ack: AsyncAck, respond: AsyncRespond): + logger.info(body) + await ack("thanks!") + await respond( + blocks=[ + { + "type": "section", + "block_id": "b", + "text": { + "type": "mrkdwn", + "text": "You can add a button alongside text in your message. ", + }, + "accessory": { + "type": "button", + "action_id": "a", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + }, + } + ] + ) + + +app.command(re.compile(r"/hello-bolt-.+"))(test_command) + + +@app.shortcut("test-shortcut") +async def test_shortcut(ack, client, logger, body): + logger.info(body) + await ack() + res = await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "view-id", + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "blocks": [ + { + "type": "input", + "element": {"type": "plain_text_input"}, + "label": { + "type": "plain_text", + "text": "Label", + }, + } + ], + }, + ) + logger.info(res) + + +@app.view("view-id") +async def view_submission(ack, body, logger): + logger.info(body) + await ack() + + +@app.action("a") +async def button_click(logger, action, ack, respond): + logger.info(action) + await ack() + await respond("Here is my response") + + +@app.event("app_mention") +async def handle_app_mentions(body, say, logger): + logger.info(body) + await say("What's up?") + + +api = falcon.asgi.App() +resource = AsyncSlackAppResource(app) +api.add_route("/slack/events", resource) + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# uvicorn --reload -h 0.0.0.0 -p 3000 async_app:api diff --git a/examples/falcon/async_oauth_app.py b/examples/falcon/async_oauth_app.py new file mode 100644 index 000000000..df4d96933 --- /dev/null +++ b/examples/falcon/async_oauth_app.py @@ -0,0 +1,102 @@ +import falcon +import logging +import re +from slack_bolt.async_app import AsyncApp, AsyncRespond, AsyncAck +from slack_bolt.adapter.falcon import AsyncSlackAppResource + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() + + +# @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"]) +async def test_command(logger: logging.Logger, body: dict, ack: AsyncAck, respond: AsyncRespond): + logger.info(body) + await ack("thanks!") + await respond( + blocks=[ + { + "type": "section", + "block_id": "b", + "text": { + "type": "mrkdwn", + "text": "You can add a button alongside text in your message. ", + }, + "accessory": { + "type": "button", + "action_id": "a", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + }, + } + ] + ) + + +app.command(re.compile(r"/hello-bolt-.+"))(test_command) + + +@app.shortcut("test-shortcut") +async def test_shortcut(ack, client, logger, body): + logger.info(body) + await ack() + res = await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "view-id", + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "blocks": [ + { + "type": "input", + "element": {"type": "plain_text_input"}, + "label": { + "type": "plain_text", + "text": "Label", + }, + } + ], + }, + ) + logger.info(res) + + +@app.view("view-id") +async def view_submission(ack, body, logger): + logger.info(body) + await ack() + + +@app.action("a") +async def button_click(logger, action, ack, respond): + logger.info(action) + await ack() + await respond("Here is my response") + + +@app.event("app_mention") +async def handle_app_mentions(body, say, logger): + logger.info(body) + await say("What's up?") + + +api = falcon.asgi.App() +resource = AsyncSlackAppResource(app) +api.add_route("/slack/events", resource) + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# uvicorn --reload -h 0.0.0.0 -p 3000 async_oauth_app:api +api.add_route("/slack/install", resource) +api.add_route("/slack/oauth_redirect", resource) diff --git a/examples/falcon/requirements.txt b/examples/falcon/requirements.txt index 20b4e2937..d72e1e1d0 100644 --- a/examples/falcon/requirements.txt +++ b/examples/falcon/requirements.txt @@ -1,2 +1,3 @@ falcon>=2,<3 -gunicorn>=20,<21 \ No newline at end of file +gunicorn>=20,<21 +uvicorn diff --git a/slack_bolt/adapter/falcon/__init__.py b/slack_bolt/adapter/falcon/__init__.py index 01dcc736f..e1a06662c 100644 --- a/slack_bolt/adapter/falcon/__init__.py +++ b/slack_bolt/adapter/falcon/__init__.py @@ -1 +1,2 @@ +# Don't add async module imports here from .resource import SlackAppResource diff --git a/slack_bolt/adapter/falcon/async_resource.py b/slack_bolt/adapter/falcon/async_resource.py new file mode 100644 index 000000000..5009a222e --- /dev/null +++ b/slack_bolt/adapter/falcon/async_resource.py @@ -0,0 +1,84 @@ +from datetime import datetime # type: ignore +from http import HTTPStatus + +from falcon import version as falcon_version +from falcon.asgi import Request, Response +from slack_bolt import BoltResponse +from slack_bolt.async_app import AsyncApp +from slack_bolt.error import BoltError +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.request.async_request import AsyncBoltRequest + + +class AsyncSlackAppResource: + """ + For use with ASGI Falcon Apps. + + from slack_bolt.async_app import AsyncApp + app = AsyncApp() + + import falcon + app = falcon.asgi.App() + app.add_route("/slack/events", AsyncSlackAppResource(app)) + """ + + def __init__(self, app: AsyncApp): # type: ignore + if falcon_version.__version__.startswith("2."): + raise BoltError("This ASGI compatible adapter requires Falcon version >= 3.0") + + self.app = app + + async def on_get(self, req: Request, resp: Response): + if self.app.oauth_flow is not None: + oauth_flow: AsyncOAuthFlow = self.app.oauth_flow + if req.path == oauth_flow.install_path: + bolt_resp = await oauth_flow.handle_installation( + await self._to_bolt_request(req) + ) + await self._write_response(bolt_resp, resp) + return + elif req.path == oauth_flow.redirect_uri_path: + bolt_resp = await oauth_flow.handle_callback( + await self._to_bolt_request(req) + ) + await self._write_response(bolt_resp, resp) + return + + resp.status = "404" + resp.body = "The page is not found..." + + async def on_post(self, req: Request, resp: Response): + bolt_req = await self._to_bolt_request(req) + bolt_resp = await self.app.async_dispatch(bolt_req) + await self._write_response(bolt_resp, resp) + + async def _to_bolt_request(self, req: Request) -> AsyncBoltRequest: + return AsyncBoltRequest( + body=(await req.stream.read(req.content_length or 0)).decode("utf-8"), + query=req.query_string, + headers={k.lower(): v for k, v in req.headers.items()}, + ) + + async def _write_response(self, bolt_resp: BoltResponse, resp: Response): + resp.text = bolt_resp.body + status = HTTPStatus(bolt_resp.status) + resp.status = str(f"{status.value} {status.phrase}") + resp.set_headers(bolt_resp.first_headers_without_set_cookie()) + for cookie in bolt_resp.cookies(): + for name, c in cookie.items(): + expire_value = c.get("expires") + expire = ( + datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") + if expire_value + else None + ) + resp.set_cookie( + name=name, + value=c.value, + expires=expire, + max_age=c.get("max-age"), + domain=c.get("domain"), + path=c.get("path"), + secure=True, + http_only=True, + ) diff --git a/tests/adapter_tests_async/test_async_falcon.py b/tests/adapter_tests_async/test_async_falcon.py new file mode 100644 index 000000000..0acbfd792 --- /dev/null +++ b/tests/adapter_tests_async/test_async_falcon.py @@ -0,0 +1,209 @@ +import json +from time import time +from urllib.parse import quote + +import falcon +from falcon import testing + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.adapter.falcon.async_resource import AsyncSlackAppResource +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +def new_falcon_app(): + return falcon.asgi.App() + + +class TestAsyncFalcon: + 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 test_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(input) + + api = new_falcon_app() + resource = AsyncSlackAppResource(app) + api.add_route("/slack/events", resource) + + client = testing.TestClient(api) + response = client.simulate_post( + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + 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))}" + + api = new_falcon_app() + resource = AsyncSlackAppResource(app) + api.add_route("/slack/events", resource) + + client = testing.TestClient(api) + response = client.simulate_post( + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_commands(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + 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 + + api = new_falcon_app() + resource = AsyncSlackAppResource(app) + api.add_route("/slack/events", resource) + + client = testing.TestClient(api) + response = client.simulate_post( + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + api = new_falcon_app() + resource = AsyncSlackAppResource(app) + api.add_route("/slack/install", resource) + + client = testing.TestClient(api) + response = client.simulate_get("/slack/install") + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert response.headers.get("content-length") == "597" + assert "https://slack.com/oauth/v2/authorize?state=" in response.text From 54114cad0ff0550d0e51c1492d8830ff87b48968 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 17 Mar 2022 14:34:12 +0900 Subject: [PATCH 458/865] version 1.12.0 --- .../adapter/falcon/async_resource.html | 296 ++++++++++++++++++ .../slack_bolt/adapter/falcon/index.html | 8 +- docs/api-docs/slack_bolt/version.html | 2 +- examples/falcon/async_app.py | 4 +- examples/falcon/async_oauth_app.py | 4 +- setup.py | 2 +- slack_bolt/adapter/falcon/async_resource.py | 4 +- slack_bolt/version.py | 2 +- 8 files changed, 315 insertions(+), 7 deletions(-) create mode 100644 docs/api-docs/slack_bolt/adapter/falcon/async_resource.html diff --git a/docs/api-docs/slack_bolt/adapter/falcon/async_resource.html b/docs/api-docs/slack_bolt/adapter/falcon/async_resource.html new file mode 100644 index 000000000..577d608da --- /dev/null +++ b/docs/api-docs/slack_bolt/adapter/falcon/async_resource.html @@ -0,0 +1,296 @@ + + + + + + +slack_bolt.adapter.falcon.async_resource API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.adapter.falcon.async_resource

    +
    +
    +
    + +Expand source code + +
    from datetime import datetime  # type: ignore
    +from http import HTTPStatus
    +
    +from falcon import version as falcon_version
    +from falcon.asgi import Request, Response
    +from slack_bolt import BoltResponse
    +from slack_bolt.async_app import AsyncApp
    +from slack_bolt.error import BoltError
    +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow
    +from slack_bolt.request.async_request import AsyncBoltRequest
    +
    +
    +class AsyncSlackAppResource:
    +    """
    +    For use with ASGI Falcon Apps.
    +
    +    from slack_bolt.async_app import AsyncApp
    +    app = AsyncApp()
    +
    +    import falcon
    +    app = falcon.asgi.App()
    +    app.add_route("/slack/events", AsyncSlackAppResource(app))
    +    """
    +
    +    def __init__(self, app: AsyncApp):  # type: ignore
    +        if falcon_version.__version__.startswith("2."):
    +            raise BoltError(
    +                "This ASGI compatible adapter requires Falcon version >= 3.0"
    +            )
    +
    +        self.app = app
    +
    +    async def on_get(self, req: Request, resp: Response):
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
    +            if req.path == oauth_flow.install_path:
    +                bolt_resp = await oauth_flow.handle_installation(
    +                    await self._to_bolt_request(req)
    +                )
    +                await self._write_response(bolt_resp, resp)
    +                return
    +            elif req.path == oauth_flow.redirect_uri_path:
    +                bolt_resp = await oauth_flow.handle_callback(
    +                    await self._to_bolt_request(req)
    +                )
    +                await self._write_response(bolt_resp, resp)
    +                return
    +
    +        resp.status = "404"
    +        resp.body = "The page is not found..."
    +
    +    async def on_post(self, req: Request, resp: Response):
    +        bolt_req = await self._to_bolt_request(req)
    +        bolt_resp = await self.app.async_dispatch(bolt_req)
    +        await self._write_response(bolt_resp, resp)
    +
    +    async def _to_bolt_request(self, req: Request) -> AsyncBoltRequest:
    +        return AsyncBoltRequest(
    +            body=(await req.stream.read(req.content_length or 0)).decode("utf-8"),
    +            query=req.query_string,
    +            headers={k.lower(): v for k, v in req.headers.items()},
    +        )
    +
    +    async def _write_response(self, bolt_resp: BoltResponse, resp: Response):
    +        resp.text = bolt_resp.body
    +        status = HTTPStatus(bolt_resp.status)
    +        resp.status = str(f"{status.value} {status.phrase}")
    +        resp.set_headers(bolt_resp.first_headers_without_set_cookie())
    +        for cookie in bolt_resp.cookies():
    +            for name, c in cookie.items():
    +                expire_value = c.get("expires")
    +                expire = (
    +                    datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z")
    +                    if expire_value
    +                    else None
    +                )
    +                resp.set_cookie(
    +                    name=name,
    +                    value=c.value,
    +                    expires=expire,
    +                    max_age=c.get("max-age"),
    +                    domain=c.get("domain"),
    +                    path=c.get("path"),
    +                    secure=True,
    +                    http_only=True,
    +                )
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class AsyncSlackAppResource +(app: AsyncApp) +
    +
    +

    For use with ASGI Falcon Apps.

    +

    from slack_bolt.async_app import AsyncApp +app = AsyncApp()

    +

    import falcon +app = falcon.asgi.App() +app.add_route("/slack/events", AsyncSlackAppResource(app))

    +
    + +Expand source code + +
    class AsyncSlackAppResource:
    +    """
    +    For use with ASGI Falcon Apps.
    +
    +    from slack_bolt.async_app import AsyncApp
    +    app = AsyncApp()
    +
    +    import falcon
    +    app = falcon.asgi.App()
    +    app.add_route("/slack/events", AsyncSlackAppResource(app))
    +    """
    +
    +    def __init__(self, app: AsyncApp):  # type: ignore
    +        if falcon_version.__version__.startswith("2."):
    +            raise BoltError(
    +                "This ASGI compatible adapter requires Falcon version >= 3.0"
    +            )
    +
    +        self.app = app
    +
    +    async def on_get(self, req: Request, resp: Response):
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
    +            if req.path == oauth_flow.install_path:
    +                bolt_resp = await oauth_flow.handle_installation(
    +                    await self._to_bolt_request(req)
    +                )
    +                await self._write_response(bolt_resp, resp)
    +                return
    +            elif req.path == oauth_flow.redirect_uri_path:
    +                bolt_resp = await oauth_flow.handle_callback(
    +                    await self._to_bolt_request(req)
    +                )
    +                await self._write_response(bolt_resp, resp)
    +                return
    +
    +        resp.status = "404"
    +        resp.body = "The page is not found..."
    +
    +    async def on_post(self, req: Request, resp: Response):
    +        bolt_req = await self._to_bolt_request(req)
    +        bolt_resp = await self.app.async_dispatch(bolt_req)
    +        await self._write_response(bolt_resp, resp)
    +
    +    async def _to_bolt_request(self, req: Request) -> AsyncBoltRequest:
    +        return AsyncBoltRequest(
    +            body=(await req.stream.read(req.content_length or 0)).decode("utf-8"),
    +            query=req.query_string,
    +            headers={k.lower(): v for k, v in req.headers.items()},
    +        )
    +
    +    async def _write_response(self, bolt_resp: BoltResponse, resp: Response):
    +        resp.text = bolt_resp.body
    +        status = HTTPStatus(bolt_resp.status)
    +        resp.status = str(f"{status.value} {status.phrase}")
    +        resp.set_headers(bolt_resp.first_headers_without_set_cookie())
    +        for cookie in bolt_resp.cookies():
    +            for name, c in cookie.items():
    +                expire_value = c.get("expires")
    +                expire = (
    +                    datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z")
    +                    if expire_value
    +                    else None
    +                )
    +                resp.set_cookie(
    +                    name=name,
    +                    value=c.value,
    +                    expires=expire,
    +                    max_age=c.get("max-age"),
    +                    domain=c.get("domain"),
    +                    path=c.get("path"),
    +                    secure=True,
    +                    http_only=True,
    +                )
    +
    +

    Methods

    +
    +
    +async def on_get(self, req: falcon.asgi.request.Request, resp: falcon.asgi.response.Response) +
    +
    +
    +
    + +Expand source code + +
    async def on_get(self, req: Request, resp: Response):
    +    if self.app.oauth_flow is not None:
    +        oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
    +        if req.path == oauth_flow.install_path:
    +            bolt_resp = await oauth_flow.handle_installation(
    +                await self._to_bolt_request(req)
    +            )
    +            await self._write_response(bolt_resp, resp)
    +            return
    +        elif req.path == oauth_flow.redirect_uri_path:
    +            bolt_resp = await oauth_flow.handle_callback(
    +                await self._to_bolt_request(req)
    +            )
    +            await self._write_response(bolt_resp, resp)
    +            return
    +
    +    resp.status = "404"
    +    resp.body = "The page is not found..."
    +
    +
    +
    +async def on_post(self, req: falcon.asgi.request.Request, resp: falcon.asgi.response.Response) +
    +
    +
    +
    + +Expand source code + +
    async def on_post(self, req: Request, resp: Response):
    +    bolt_req = await self._to_bolt_request(req)
    +    bolt_resp = await self.app.async_dispatch(bolt_req)
    +    await self._write_response(bolt_resp, resp)
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/falcon/index.html b/docs/api-docs/slack_bolt/adapter/falcon/index.html index 10e949002..85a95e9f3 100644 --- a/docs/api-docs/slack_bolt/adapter/falcon/index.html +++ b/docs/api-docs/slack_bolt/adapter/falcon/index.html @@ -26,12 +26,17 @@

    Module slack_bolt.adapter.falcon

    Expand source code -
    from .resource import SlackAppResource
    +
    # Don't add async module imports here
    +from .resource import SlackAppResource

    Sub-modules

    +
    slack_bolt.adapter.falcon.async_resource
    +
    +
    +
    slack_bolt.adapter.falcon.resource
    @@ -58,6 +63,7 @@

    Index

  • Sub-modules

  • diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index c24e4bed2..aeabb6323 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.11.6"
    +__version__ = "1.12.0"
    diff --git a/examples/falcon/async_app.py b/examples/falcon/async_app.py index 44989d4fa..e0fd11f51 100644 --- a/examples/falcon/async_app.py +++ b/examples/falcon/async_app.py @@ -9,7 +9,9 @@ # @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"]) -async def test_command(logger: logging.Logger, body: dict, ack: AsyncAck, respond: AsyncRespond): +async def test_command( + logger: logging.Logger, body: dict, ack: AsyncAck, respond: AsyncRespond +): logger.info(body) await ack("thanks!") await respond( diff --git a/examples/falcon/async_oauth_app.py b/examples/falcon/async_oauth_app.py index df4d96933..f9be899b2 100644 --- a/examples/falcon/async_oauth_app.py +++ b/examples/falcon/async_oauth_app.py @@ -9,7 +9,9 @@ # @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"]) -async def test_command(logger: logging.Logger, body: dict, ack: AsyncAck, respond: AsyncRespond): +async def test_command( + logger: logging.Logger, body: dict, ack: AsyncAck, respond: AsyncRespond +): logger.info(body) await ack("thanks!") await respond( diff --git a/setup.py b/setup.py index e2a1611d5..758953f86 100755 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ ), include_package_data=True, # MANIFEST.in install_requires=[ - "slack_sdk>=3.9.0,<4", + "slack_sdk>=3.15.2,<4", ], setup_requires=["pytest-runner==5.2"], tests_require=async_test_dependencies, diff --git a/slack_bolt/adapter/falcon/async_resource.py b/slack_bolt/adapter/falcon/async_resource.py index 5009a222e..1c779c5c0 100644 --- a/slack_bolt/adapter/falcon/async_resource.py +++ b/slack_bolt/adapter/falcon/async_resource.py @@ -24,7 +24,9 @@ class AsyncSlackAppResource: def __init__(self, app: AsyncApp): # type: ignore if falcon_version.__version__.startswith("2."): - raise BoltError("This ASGI compatible adapter requires Falcon version >= 3.0") + raise BoltError( + "This ASGI compatible adapter requires Falcon version >= 3.0" + ) self.app = app diff --git a/slack_bolt/version.py b/slack_bolt/version.py index d96b2b137..aada8e7e5 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.11.6" +__version__ = "1.12.0" From 851376e0e9e01c75271d07de2d777c831c496984 Mon Sep 17 00:00:00 2001 From: Alexander Rashed <2796604+alexrashed@users.noreply.github.com> Date: Thu, 17 Mar 2022 11:01:22 +0100 Subject: [PATCH 459/865] fix type hint for event constraint to allow None subtypes (#616) * fix type hint for message constraint to allow None subtypes Signed-off-by: Alexander Rashed * revert changes to auto-generated docs Signed-off-by: Alexander Rashed --- docs/_basic/listening_events.md | 1 + slack_bolt/app/app.py | 4 +++- slack_bolt/app/async_app.py | 4 +++- slack_bolt/listener_matcher/builtins.py | 12 ++++++++---- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/_basic/listening_events.md b/docs/_basic/listening_events.md index e5df72952..fe65fd7cf 100644 --- a/docs/_basic/listening_events.md +++ b/docs/_basic/listening_events.md @@ -36,6 +36,7 @@ def ask_for_introduction(event, say): The `message()` listener is equivalent to `event("message")`. You can filter on subtypes of events by passing in the additional key `subtype`. Common message subtypes like `bot_message` and `message_replied` can be found [on the message event page](https://api.slack.com/events/message#message_subtypes). +You can explicitly filter for events without a subtype by explicitly setting `None`. diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 2679dca01..baaf85fca 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -724,7 +724,9 @@ def custom_error_handler(error, body, logger): def event( self, event: Union[ - str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]] + str, + Pattern, + Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]], ], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 92ce70beb..0a6939390 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -782,7 +782,9 @@ async def custom_error_handler(error, body, logger): def event( self, event: Union[ - str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]] + str, + Pattern, + Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]], ], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 1b46183a6..2bf582f67 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -78,7 +78,9 @@ async def async_fun(body: Dict[str, Any]) -> bool: def event( constraints: Union[ - str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]] + str, + Pattern, + Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]], ], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: @@ -110,7 +112,9 @@ def func(body: Dict[str, Any]) -> bool: def message_event( - constraints: Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]], + constraints: Dict[ + str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]] + ], keyword: Union[str, Pattern], asyncio: bool = False, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: @@ -140,8 +144,8 @@ def _check_event_subtype(event_payload: dict, constraints: dict) -> bool: if not _matches(constraints["type"], event_payload["type"]): return False if "subtype" in constraints: - expected_subtype: Union[ - str, Sequence[Optional[Union[str, Pattern]]] + expected_subtype: Optional[ + Union[str, Sequence[Optional[Union[str, Pattern]]]] ] = constraints["subtype"] if expected_subtype is None: # "subtype" in constraints is intentionally None for this pattern From 68eb2fe9cca9150bfce2f34c8fa2ad1c8aca4012 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 17 Mar 2022 19:06:59 +0900 Subject: [PATCH 460/865] Apply #616 doc change to its Japanese page --- docs/_basic/ja_listening_events.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_basic/ja_listening_events.md b/docs/_basic/ja_listening_events.md index b491af6fe..0954c2ce7 100644 --- a/docs/_basic/ja_listening_events.md +++ b/docs/_basic/ja_listening_events.md @@ -35,7 +35,7 @@ def ask_for_introduction(event, say):
    `message()` リスナーは `event("message")` と等価の機能を提供します。 -`subtype` という追加のキーを指定して、イベントのサブタイプでフィルタリングすることもできます。よく使われるサブタイプには、`bot_message` や `message_replied` があります。詳しくは[メッセージイベントページ](https://api.slack.com/events/message#message_subtypes)を参照してください。 +`subtype` という追加のキーを指定して、イベントのサブタイプでフィルタリングすることもできます。よく使われるサブタイプには、`bot_message` や `message_replied` があります。詳しくは[メッセージイベントページ](https://api.slack.com/events/message#message_subtypes)を参照してください。サブタイプなしのイベントだけにフルターするために明に `None` を指定することもできます。
    From eda5574774280e08b78bc696be9850238aad5cae Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 18 Mar 2022 20:22:59 +0900 Subject: [PATCH 461/865] Fix #617 Respect the configuration of logger parameter across App/AsyncApp loggers (#618) --- .github/workflows/pytype.yml | 4 +- pytest.ini | 1 + scripts/run_pytype.sh | 2 +- .../adapter/aws_lambda/chalice_handler.py | 4 +- slack_bolt/adapter/aws_lambda/handler.py | 2 +- slack_bolt/app/app.py | 124 +++++++++++---- slack_bolt/app/async_app.py | 123 +++++++++++---- slack_bolt/listener/async_listener.py | 3 +- slack_bolt/listener/custom_listener.py | 3 +- .../async_listener_matcher.py | 12 +- slack_bolt/listener_matcher/builtins.py | 75 ++++++--- .../custom_listener_matcher.py | 12 +- slack_bolt/logger/__init__.py | 49 ++++-- .../middleware/async_custom_middleware.py | 12 +- .../async_multi_teams_authorization.py | 8 +- .../async_single_team_authorization.py | 7 +- .../multi_teams_authorization.py | 5 +- .../single_team_authorization.py | 11 +- slack_bolt/middleware/custom_middleware.py | 8 +- .../ignoring_self_events.py | 6 +- .../request_verification.py | 8 +- slack_bolt/middleware/ssl_check/ssl_check.py | 9 +- .../async_url_verification.py | 7 +- .../url_verification/url_verification.py | 10 +- slack_bolt/workflows/step/async_step.py | 113 +++++++++++--- slack_bolt/workflows/step/step.py | 146 ++++++++++++++---- tests/scenario_tests/test_app.py | 101 +++++++++--- tests/scenario_tests/test_workflow_steps.py | 60 +++++++ ...test_workflow_steps_decorator_with_args.py | 104 +++++++++++++ tests/scenario_tests_async/test_app.py | 137 +++++++++++----- .../test_workflow_steps.py | 63 ++++++++ ...test_workflow_steps_decorator_with_args.py | 105 +++++++++++++ 32 files changed, 1093 insertions(+), 241 deletions(-) diff --git a/.github/workflows/pytype.yml b/.github/workflows/pytype.yml index 6acf4b2b5..f13674516 100644 --- a/.github/workflows/pytype.yml +++ b/.github/workflows/pytype.yml @@ -8,10 +8,10 @@ on: jobs: build: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 strategy: matrix: - python-version: ['3.8'] + python-version: ['3.9'] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/pytest.ini b/pytest.ini index 48ce6a5fe..27f7ad257 100644 --- a/pytest.ini +++ b/pytest.ini @@ -6,3 +6,4 @@ log_date_format = %Y-%m-%d %H:%M:%S filterwarnings = ignore:"@coroutine" decorator is deprecated since Python 3.8, use "async def" instead:DeprecationWarning ignore:The loop argument is deprecated since Python 3.8, and scheduled for removal in Python 3.10.:DeprecationWarning +asyncio_mode = auto \ No newline at end of file diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index 273ce37f4..267fb8efe 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -5,5 +5,5 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ pip install -e ".[async]" && \ pip install -e ".[adapter]" && \ - pip install "pytype==2022.2.23" && \ + pip install "pytype==2022.3.8" && \ pytype slack_bolt/ diff --git a/slack_bolt/adapter/aws_lambda/chalice_handler.py b/slack_bolt/adapter/aws_lambda/chalice_handler.py index a1edd158c..cad222a72 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_handler.py +++ b/slack_bolt/adapter/aws_lambda/chalice_handler.py @@ -21,7 +21,9 @@ class ChaliceSlackRequestHandler: def __init__(self, app: App, chalice: Chalice, lambda_client: Optional[BaseClient] = None): # type: ignore self.app = app self.chalice = chalice - self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler) + self.logger = get_bolt_app_logger( + app.name, ChaliceSlackRequestHandler, app.logger + ) if getenv("AWS_CHALICE_CLI_MODE") == "true" and lambda_client is None: try: diff --git a/slack_bolt/adapter/aws_lambda/handler.py b/slack_bolt/adapter/aws_lambda/handler.py index 9037ac397..bb24742e6 100644 --- a/slack_bolt/adapter/aws_lambda/handler.py +++ b/slack_bolt/adapter/aws_lambda/handler.py @@ -14,7 +14,7 @@ class SlackRequestHandler: def __init__(self, app: App): # type: ignore self.app = app - self.logger = get_bolt_app_logger(app.name, SlackRequestHandler) + self.logger = get_bolt_app_logger(app.name, SlackRequestHandler, app.logger) self.app.listener_runner.lazy_listener_runner = LambdaLazyListenerRunner( self.logger ) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index baaf85fca..7ee3fb096 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -189,6 +189,11 @@ def message_hello(message, say): self._verification_token: Optional[str] = verification_token or os.environ.get( "SLACK_VERIFICATION_TOKEN", None ) + # If a logger is explicitly passed when initializing, the logger works as the base logger. + # The base logger's logging settings will be propagated to all the loggers created by bolt-python. + self._base_logger = logger + # The framework logger is supposed to be used for the internal logging. + # Also, it's accessible via `app.logger` as the app's singleton logger. self._framework_logger = logger or get_bolt_logger(App) self._raise_error_for_unhandled_request = raise_error_for_unhandled_request @@ -356,10 +361,15 @@ def _init_middleware_list( return if ssl_check_enabled is True: self._middleware_list.append( - SslCheck(verification_token=self._verification_token) + SslCheck( + verification_token=self._verification_token, + base_logger=self._base_logger, + ) ) if request_verification_enabled is True: - self._middleware_list.append(RequestVerification(self._signing_secret)) + self._middleware_list.append( + RequestVerification(self._signing_secret, base_logger=self._base_logger) + ) # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._oauth_flow is None: @@ -370,24 +380,33 @@ def _init_middleware_list( # This API call is for eagerly validating the token auth_test_result = self._client.auth_test(token=self._token) self._middleware_list.append( - SingleTeamAuthorization(auth_test_result=auth_test_result) + SingleTeamAuthorization( + auth_test_result=auth_test_result, + base_logger=self._base_logger, + ) ) except SlackApiError as err: raise BoltError(error_auth_test_failure(err.response)) elif self._authorize is not None: self._middleware_list.append( - MultiTeamsAuthorization(authorize=self._authorize) + MultiTeamsAuthorization( + authorize=self._authorize, base_logger=self._base_logger + ) ) else: raise BoltError(error_token_required()) else: self._middleware_list.append( - MultiTeamsAuthorization(authorize=self._authorize) + MultiTeamsAuthorization( + authorize=self._authorize, base_logger=self._base_logger + ) ) if ignoring_self_events_enabled is True: - self._middleware_list.append(IgnoringSelfEvents()) + self._middleware_list.append( + IgnoringSelfEvents(base_logger=self._base_logger) + ) if url_verification_enabled is True: - self._middleware_list.append(UrlVerification()) + self._middleware_list.append(UrlVerification(base_logger=self._base_logger)) self._init_middleware_list_done = True # ------------------------- @@ -616,7 +635,11 @@ def middleware_func(logger, body, next): self._middleware_list.append(middleware) elif isinstance(middleware_or_callable, Callable): self._middleware_list.append( - CustomMiddleware(app_name=self.name, func=middleware_or_callable) + CustomMiddleware( + app_name=self.name, + func=middleware_or_callable, + base_logger=self._base_logger, + ) ) return middleware_or_callable else: @@ -677,9 +700,10 @@ def step( edit=edit, save=save, execute=execute, + base_logger=self._base_logger, ) elif isinstance(step, WorkflowStepBuilder): - step = step.build() + step = step.build(base_logger=self._base_logger) elif not isinstance(step, WorkflowStep): raise BoltError(f"Invalid step object ({type(step)})") @@ -759,7 +783,9 @@ def ask_for_introduction(event, say): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.event(event) + primary_matcher = builtin_matchers.event( + event, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True ) @@ -814,7 +840,7 @@ def __call__(*args, **kwargs): ), } primary_matcher = builtin_matchers.message_event( - keyword=keyword, constraints=constraints + keyword=keyword, constraints=constraints, base_logger=self._base_logger ) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener( @@ -859,7 +885,9 @@ def repeat_text(ack, say, command): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.command(command) + primary_matcher = builtin_matchers.command( + command, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -908,7 +936,9 @@ def open_modal(ack, body, client): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.shortcut(constraints) + primary_matcher = builtin_matchers.shortcut( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -925,7 +955,9 @@ def global_shortcut( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.global_shortcut(callback_id) + primary_matcher = builtin_matchers.global_shortcut( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -942,7 +974,9 @@ def message_shortcut( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.message_shortcut(callback_id) + primary_matcher = builtin_matchers.message_shortcut( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -984,7 +1018,9 @@ def update_message(ack): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.action(constraints) + primary_matcher = builtin_matchers.action( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1003,7 +1039,9 @@ def block_action( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_action(constraints) + primary_matcher = builtin_matchers.block_action( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1021,7 +1059,9 @@ def attachment_action( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.attachment_action(callback_id) + primary_matcher = builtin_matchers.attachment_action( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1039,7 +1079,9 @@ def dialog_submission( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_submission(callback_id) + primary_matcher = builtin_matchers.dialog_submission( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1057,7 +1099,9 @@ def dialog_cancellation( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_cancellation(callback_id) + primary_matcher = builtin_matchers.dialog_cancellation( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1110,7 +1154,9 @@ def handle_submission(ack, body, client, view): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view(constraints) + primary_matcher = builtin_matchers.view( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1128,7 +1174,9 @@ def view_submission( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_submission(constraints) + primary_matcher = builtin_matchers.view_submission( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1146,7 +1194,9 @@ def view_closed( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_closed(constraints) + primary_matcher = builtin_matchers.view_closed( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1199,7 +1249,9 @@ def show_menu_options(ack): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.options(constraints) + primary_matcher = builtin_matchers.options( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1216,7 +1268,9 @@ def block_suggestion( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_suggestion(action_id) + primary_matcher = builtin_matchers.block_suggestion( + action_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1234,7 +1288,9 @@ def dialog_suggestion( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_suggestion(callback_id) + primary_matcher = builtin_matchers.dialog_suggestion( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1265,7 +1321,9 @@ def enable_token_revocation_listeners(self) -> None: # ------------------------- def _init_context(self, req: BoltRequest): - req.context["logger"] = get_bolt_app_logger(self.name) + req.context["logger"] = get_bolt_app_logger( + app_name=self.name, base_logger=self._base_logger + ) req.context["token"] = self._token if self._token is not None: # This WebClient instance can be safely singleton @@ -1311,7 +1369,10 @@ def _register_listener( value_to_return = functions[0] listener_matchers = [ - CustomListenerMatcher(app_name=self.name, func=f) for f in (matchers or []) + CustomListenerMatcher( + app_name=self.name, func=f, base_logger=self._base_logger + ) + for f in (matchers or []) ] listener_matchers.insert(0, primary_matcher) listener_middleware = [] @@ -1319,7 +1380,11 @@ def _register_listener( if isinstance(m, Middleware): listener_middleware.append(m) elif isinstance(m, Callable): - listener_middleware.append(CustomMiddleware(app_name=self.name, func=m)) + listener_middleware.append( + CustomMiddleware( + app_name=self.name, func=m, base_logger=self._base_logger + ) + ) else: raise ValueError(error_unexpected_listener_middleware(type(m))) @@ -1331,6 +1396,7 @@ def _register_listener( matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + base_logger=self._base_logger, ) ) return value_to_return diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 0a6939390..aafdfb4b0 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -194,6 +194,11 @@ async def message_hello(message, say): # async function self._verification_token: Optional[str] = verification_token or os.environ.get( "SLACK_VERIFICATION_TOKEN", None ) + # If a logger is explicitly passed when initializing, the logger works as the base logger. + # The base logger's logging settings will be propagated to all the loggers created by bolt-python. + self._base_logger = logger + # The framework logger is supposed to be used for the internal logging. + # Also, it's accessible via `app.logger` as the app's singleton logger. self._framework_logger = logger or get_bolt_logger(AsyncApp) self._raise_error_for_unhandled_request = raise_error_for_unhandled_request @@ -376,31 +381,46 @@ def _init_async_middleware_list( return if ssl_check_enabled is True: self._async_middleware_list.append( - AsyncSslCheck(verification_token=self._verification_token) + AsyncSslCheck( + verification_token=self._verification_token, + base_logger=self._base_logger, + ) ) if request_verification_enabled is True: self._async_middleware_list.append( - AsyncRequestVerification(self._signing_secret) + AsyncRequestVerification( + self._signing_secret, base_logger=self._base_logger + ) ) # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: - self._async_middleware_list.append(AsyncSingleTeamAuthorization()) + self._async_middleware_list.append( + AsyncSingleTeamAuthorization(base_logger=self._base_logger) + ) elif self._async_authorize is not None: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, base_logger=self._base_logger + ) ) else: raise BoltError(error_token_required()) else: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, base_logger=self._base_logger + ) ) if ignoring_self_events_enabled is True: - self._async_middleware_list.append(AsyncIgnoringSelfEvents()) + self._async_middleware_list.append( + AsyncIgnoringSelfEvents(base_logger=self._base_logger) + ) if url_verification_enabled is True: - self._async_middleware_list.append(AsyncUrlVerification()) + self._async_middleware_list.append( + AsyncUrlVerification(base_logger=self._base_logger) + ) self._init_middleware_list_done = True # ------------------------- @@ -662,7 +682,9 @@ async def middleware_func(logger, body, next): elif isinstance(middleware_or_callable, Callable): self._async_middleware_list.append( AsyncCustomMiddleware( - app_name=self.name, func=middleware_or_callable + app_name=self.name, + func=middleware_or_callable, + base_logger=self._base_logger, ) ) return middleware_or_callable @@ -730,9 +752,10 @@ def step( edit=edit, save=save, execute=execute, + base_logger=self._base_logger, ) elif isinstance(step, AsyncWorkflowStepBuilder): - step = step.build() + step = step.build(base_logger=self._base_logger) elif not isinstance(step, AsyncWorkflowStep): raise BoltError(f"Invalid step object ({type(step)})") @@ -817,7 +840,9 @@ async def ask_for_introduction(event, say): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.event(event, True) + primary_matcher = builtin_matchers.event( + event, True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True ) @@ -872,7 +897,10 @@ def __call__(*args, **kwargs): ), } primary_matcher = builtin_matchers.message_event( - constraints=constraints, keyword=keyword, asyncio=True + constraints=constraints, + keyword=keyword, + asyncio=True, + base_logger=self._base_logger, ) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener( @@ -917,7 +945,9 @@ async def repeat_text(ack, say, command): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.command(command, True) + primary_matcher = builtin_matchers.command( + command, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -966,7 +996,9 @@ async def open_modal(ack, body, client): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.shortcut(constraints, True) + primary_matcher = builtin_matchers.shortcut( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -983,7 +1015,9 @@ def global_shortcut( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.global_shortcut(callback_id, True) + primary_matcher = builtin_matchers.global_shortcut( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1000,7 +1034,9 @@ def message_shortcut( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.message_shortcut(callback_id, True) + primary_matcher = builtin_matchers.message_shortcut( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1042,7 +1078,9 @@ async def update_message(ack): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.action(constraints, True) + primary_matcher = builtin_matchers.action( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1061,7 +1099,9 @@ def block_action( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_action(constraints, True) + primary_matcher = builtin_matchers.block_action( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1079,7 +1119,9 @@ def attachment_action( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.attachment_action(callback_id, True) + primary_matcher = builtin_matchers.attachment_action( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1097,7 +1139,9 @@ def dialog_submission( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_submission(callback_id, True) + primary_matcher = builtin_matchers.dialog_submission( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1115,7 +1159,9 @@ def dialog_cancellation( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_cancellation(callback_id, True) + primary_matcher = builtin_matchers.dialog_cancellation( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1168,7 +1214,9 @@ async def handle_submission(ack, body, client, view): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view(constraints, True) + primary_matcher = builtin_matchers.view( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1186,7 +1234,9 @@ def view_submission( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_submission(constraints, True) + primary_matcher = builtin_matchers.view_submission( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1204,7 +1254,9 @@ def view_closed( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_closed(constraints, True) + primary_matcher = builtin_matchers.view_closed( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1257,7 +1309,9 @@ async def show_menu_options(ack): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.options(constraints, True) + primary_matcher = builtin_matchers.options( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1274,7 +1328,9 @@ def block_suggestion( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_suggestion(action_id, True) + primary_matcher = builtin_matchers.block_suggestion( + action_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1292,7 +1348,9 @@ def dialog_suggestion( def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_suggestion(callback_id, True) + primary_matcher = builtin_matchers.dialog_suggestion( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1323,7 +1381,9 @@ def enable_token_revocation_listeners(self) -> None: # ------------------------- def _init_context(self, req: AsyncBoltRequest): - req.context["logger"] = get_bolt_app_logger(self.name) + req.context["logger"] = get_bolt_app_logger( + app_name=self.name, base_logger=self._base_logger + ) req.context["token"] = self._token if self._token is not None: # This AsyncWebClient instance can be safely singleton @@ -1376,7 +1436,9 @@ def _register_listener( raise BoltError(error_listener_function_must_be_coro_func(name)) listener_matchers = [ - AsyncCustomListenerMatcher(app_name=self.name, func=f) + AsyncCustomListenerMatcher( + app_name=self.name, func=f, base_logger=self._base_logger + ) for f in (matchers or []) ] listener_matchers.insert(0, primary_matcher) @@ -1386,7 +1448,9 @@ def _register_listener( listener_middleware.append(m) elif isinstance(m, Callable) and inspect.iscoroutinefunction(m): listener_middleware.append( - AsyncCustomMiddleware(app_name=self.name, func=m) + AsyncCustomMiddleware( + app_name=self.name, func=m, base_logger=self._base_logger + ) ) else: raise ValueError(error_unexpected_listener_middleware(type(m))) @@ -1399,6 +1463,7 @@ def _register_listener( matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + base_logger=self._base_logger, ) ) diff --git a/slack_bolt/listener/async_listener.py b/slack_bolt/listener/async_listener.py index 9102ec160..249567c7e 100644 --- a/slack_bolt/listener/async_listener.py +++ b/slack_bolt/listener/async_listener.py @@ -101,6 +101,7 @@ def __init__( matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False, + base_logger: Optional[Logger] = None, ): self.app_name = app_name self.ack_function = ack_function @@ -109,7 +110,7 @@ def __init__( self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement self.arg_names = inspect.getfullargspec(ack_function).args - self.logger = get_bolt_app_logger(app_name, self.ack_function) + self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) async def run_ack_function( self, diff --git a/slack_bolt/listener/custom_listener.py b/slack_bolt/listener/custom_listener.py index b38e80324..9822a4fa8 100644 --- a/slack_bolt/listener/custom_listener.py +++ b/slack_bolt/listener/custom_listener.py @@ -30,6 +30,7 @@ def __init__( matchers: Sequence[ListenerMatcher], middleware: Sequence[Middleware], # type: ignore auto_acknowledgement: bool = False, + base_logger: Optional[Logger] = None, ): self.app_name = app_name self.ack_function = ack_function @@ -38,7 +39,7 @@ def __init__( self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement self.arg_names = inspect.getfullargspec(ack_function).args - self.logger = get_bolt_app_logger(app_name, self.ack_function) + self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) def run_ack_function( self, diff --git a/slack_bolt/listener_matcher/async_listener_matcher.py b/slack_bolt/listener_matcher/async_listener_matcher.py index 7673081fc..19995b523 100644 --- a/slack_bolt/listener_matcher/async_listener_matcher.py +++ b/slack_bolt/listener_matcher/async_listener_matcher.py @@ -21,7 +21,7 @@ async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool import inspect from logging import Logger -from typing import Callable, Awaitable, Sequence +from typing import Callable, Awaitable, Sequence, Optional from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs from slack_bolt.logger import get_bolt_app_logger @@ -35,11 +35,17 @@ class AsyncCustomListenerMatcher(AsyncListenerMatcher): arg_names: Sequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable[..., Awaitable[bool]]): + def __init__( + self, + *, + app_name: str, + func: Callable[..., Awaitable[bool]], + base_logger: Optional[Logger] = None + ): self.app_name = app_name self.func = func self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool: return await self.func( diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 2bf582f67..043a497fa 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -2,6 +2,7 @@ import inspect import re import sys +from logging import Logger from slack_bolt.error import BoltError from slack_bolt.request.payload_utils import ( @@ -40,10 +41,15 @@ # a.k.a Union[ListenerMatcher, "AsyncListenerMatcher"] class BuiltinListenerMatcher(ListenerMatcher): - def __init__(self, *, func: Callable[..., Union[bool, Awaitable[bool]]]): + def __init__( + self, + *, + func: Callable[..., Union[bool, Awaitable[bool]]], + base_logger: Optional[Logger] = None, + ): self.func = func self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_logger(self.func) + self.logger = get_bolt_logger(self.func, base_logger) def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: return self.func( @@ -60,6 +66,7 @@ def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: def build_listener_matcher( func: Callable[..., bool], asyncio: bool, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if asyncio: from .async_builtins import AsyncBuiltinListenerMatcher @@ -67,9 +74,9 @@ def build_listener_matcher( async def async_fun(body: Dict[str, Any]) -> bool: return func(body) - return AsyncBuiltinListenerMatcher(func=async_fun) + return AsyncBuiltinListenerMatcher(func=async_fun, base_logger=base_logger) else: - return BuiltinListenerMatcher(func=func) + return BuiltinListenerMatcher(func=func, base_logger=base_logger) # ------------- @@ -83,6 +90,7 @@ def event( Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]], ], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): event_type: Union[str, Pattern] = constraints @@ -91,7 +99,7 @@ def event( def func(body: Dict[str, Any]) -> bool: return is_event(body) and _matches(event_type, body["event"]["type"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) elif "type" in constraints: _verify_message_event_type(constraints["type"]) @@ -104,7 +112,7 @@ def func(body: Dict[str, Any]) -> bool: ) return False - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) raise BoltError( f"event ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict" @@ -117,6 +125,7 @@ def message_event( ], keyword: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if "type" in constraints and keyword is not None: _verify_message_event_type(constraints["type"]) @@ -135,7 +144,7 @@ def func(body: Dict[str, Any]) -> bool: return True return False - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) raise BoltError(f"event ({constraints}: {type(constraints)}) must be dict") @@ -181,6 +190,7 @@ def _verify_message_event_type(event_type: str) -> None: def workflow_step_execute( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return ( @@ -190,7 +200,7 @@ def func(body: Dict[str, Any]) -> bool: and _matches(callback_id, body["event"]["callback_id"]) ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -200,11 +210,12 @@ def func(body: Dict[str, Any]) -> bool: def command( command: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_slash_command(body) and _matches(command, body["command"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -214,6 +225,7 @@ def func(body: Dict[str, Any]) -> bool: def shortcut( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): callback_id: Union[str, Pattern] = constraints @@ -221,7 +233,7 @@ def shortcut( def func(body: Dict[str, Any]) -> bool: return is_shortcut(body) and _matches(callback_id, body["callback_id"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) elif "type" in constraints and "callback_id" in constraints: if constraints["type"] == "shortcut": @@ -237,21 +249,23 @@ def func(body: Dict[str, Any]) -> bool: def global_shortcut( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_global_shortcut(body) and _matches(callback_id, body["callback_id"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def message_shortcut( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_message_shortcut(body) and _matches(callback_id, body["callback_id"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -261,6 +275,7 @@ def func(body: Dict[str, Any]) -> bool: def action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): @@ -273,7 +288,7 @@ def func(body: Dict[str, Any]) -> bool: or _workflow_step_edit(constraints, body) ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) elif "type" in constraints: action_type = constraints["type"] @@ -323,11 +338,12 @@ def _block_action( def block_action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _block_action(constraints, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def _attachment_action( @@ -340,11 +356,12 @@ def _attachment_action( def attachment_action( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _attachment_action(callback_id, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def _dialog_submission( @@ -357,11 +374,12 @@ def _dialog_submission( def dialog_submission( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _dialog_submission(callback_id, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def _dialog_cancellation( @@ -374,11 +392,12 @@ def _dialog_cancellation( def dialog_cancellation( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _dialog_cancellation(callback_id, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def _workflow_step_edit( @@ -391,11 +410,12 @@ def _workflow_step_edit( def workflow_step_edit( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _workflow_step_edit(callback_id, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------------------- @@ -405,6 +425,7 @@ def func(body: Dict[str, Any]) -> bool: def view( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): return view_submission(constraints, asyncio) @@ -422,37 +443,40 @@ def view( def view_submission( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_view_submission(body) and _matches( callback_id, body["view"]["callback_id"] ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def view_closed( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_view_closed(body) and _matches( callback_id, body["view"]["callback_id"] ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def workflow_step_save( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_workflow_step_save(body) and _matches( callback_id, body["view"]["callback_id"] ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -462,6 +486,7 @@ def func(body: Dict[str, Any]) -> bool: def options( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): @@ -470,7 +495,7 @@ def func(body: Dict[str, Any]) -> bool: constraints, body ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) if "action_id" in constraints: return block_suggestion(constraints["action_id"], asyncio) @@ -492,11 +517,12 @@ def _block_suggestion( def block_suggestion( action_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _block_suggestion(action_id, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def _dialog_suggestion( @@ -509,11 +535,12 @@ def _dialog_suggestion( def dialog_suggestion( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _dialog_suggestion(callback_id, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------------------- diff --git a/slack_bolt/listener_matcher/custom_listener_matcher.py b/slack_bolt/listener_matcher/custom_listener_matcher.py index 4e07006d4..dff1c1e7b 100644 --- a/slack_bolt/listener_matcher/custom_listener_matcher.py +++ b/slack_bolt/listener_matcher/custom_listener_matcher.py @@ -1,6 +1,6 @@ import inspect from logging import Logger -from typing import Callable, Sequence +from typing import Callable, Sequence, Optional from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.logger import get_bolt_app_logger @@ -15,11 +15,17 @@ class CustomListenerMatcher(ListenerMatcher): arg_names: Sequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable[..., bool]): + def __init__( + self, + *, + app_name: str, + func: Callable[..., bool], + base_logger: Optional[Logger] = None + ): self.app_name = app_name self.func = func self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: return self.func( diff --git a/slack_bolt/logger/__init__.py b/slack_bolt/logger/__init__.py index 25f076a9a..77e26a983 100644 --- a/slack_bolt/logger/__init__.py +++ b/slack_bolt/logger/__init__.py @@ -2,24 +2,45 @@ import logging from logging import Logger -from typing import Any +from typing import Any, Optional -def get_bolt_logger(cls: Any) -> Logger: +def get_bolt_logger(cls: Any, base_logger: Optional[Logger] = None) -> Logger: logger = logging.getLogger(f"slack_bolt.{cls.__name__}") - logger.disabled = logging.root.disabled - logger.level = logging.root.level + if base_logger is not None: + _configure_from_base_logger(logger, base_logger) + else: + _configure_from_root(logger) return logger -def get_bolt_app_logger(app_name: str, cls: object = None) -> Logger: - if cls and hasattr(cls, "__name__"): - logger = logging.getLogger(f"{app_name}:{cls.__name__}") - logger.disabled = logging.root.disabled - logger.level = logging.root.level - return logger +def get_bolt_app_logger( + app_name: str, cls: object = None, base_logger: Optional[Logger] = None +) -> Logger: + logger: Logger = ( + logging.getLogger(f"{app_name}:{cls.__name__}") + if cls and hasattr(cls, "__name__") + else logging.getLogger(app_name) + ) + + if base_logger is not None: + _configure_from_base_logger(logger, base_logger) else: - logger = logging.getLogger(app_name) - logger.disabled = logging.root.disabled - logger.level = logging.root.level - return logger + _configure_from_root(logger) + return logger + + +def _configure_from_base_logger(new_logger: Logger, base_logger: Logger): + new_logger.disabled = base_logger.disabled + new_logger.level = base_logger.level + if len(new_logger.handlers) == 0: + for h in base_logger.handlers: + new_logger.addHandler(h) + if len(new_logger.filters) == 0: + for f in base_logger.filters: + new_logger.addFilter(f) + + +def _configure_from_root(new_logger: Logger): + new_logger.disabled = logging.root.disabled + new_logger.level = logging.root.level diff --git a/slack_bolt/middleware/async_custom_middleware.py b/slack_bolt/middleware/async_custom_middleware.py index d967f188e..a46219917 100644 --- a/slack_bolt/middleware/async_custom_middleware.py +++ b/slack_bolt/middleware/async_custom_middleware.py @@ -1,6 +1,6 @@ import inspect from logging import Logger -from typing import Callable, Awaitable, Any, Sequence +from typing import Callable, Awaitable, Any, Sequence, Optional from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs from slack_bolt.logger import get_bolt_app_logger @@ -16,7 +16,13 @@ class AsyncCustomMiddleware(AsyncMiddleware): arg_names: Sequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable[..., Awaitable[Any]]): + def __init__( + self, + *, + app_name: str, + func: Callable[..., Awaitable[Any]], + base_logger: Optional[Logger] = None, + ): self.app_name = app_name if inspect.iscoroutinefunction(func): self.func = func @@ -24,7 +30,7 @@ def __init__(self, *, app_name: str, func: Callable[..., Awaitable[Any]]): raise ValueError("Async middleware function must be an async function") self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) async def async_process( self, diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index 85c56cc33..1a62b5b5a 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -1,3 +1,4 @@ +from logging import Logger from typing import Callable, Optional, Awaitable from slack_sdk.errors import SlackApiError @@ -14,14 +15,17 @@ class AsyncMultiTeamsAuthorization(AsyncAuthorization): authorize: AsyncAuthorize - def __init__(self, authorize: AsyncAuthorize): + def __init__(self, authorize: AsyncAuthorize, base_logger: Optional[Logger] = None): """Multi-workspace authorization. Args: authorize: The function to authorize incoming requests from Slack. + base_logger: The base logger """ self.authorize = authorize - self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization) + self.logger = get_bolt_logger( + AsyncMultiTeamsAuthorization, base_logger=base_logger + ) async def async_process( self, diff --git a/slack_bolt/middleware/authorization/async_single_team_authorization.py b/slack_bolt/middleware/authorization/async_single_team_authorization.py index 0c9ff9cd9..72f4992cc 100644 --- a/slack_bolt/middleware/authorization/async_single_team_authorization.py +++ b/slack_bolt/middleware/authorization/async_single_team_authorization.py @@ -1,3 +1,4 @@ +from logging import Logger from typing import Callable, Awaitable, Optional from slack_bolt.logger import get_bolt_logger @@ -12,10 +13,12 @@ class AsyncSingleTeamAuthorization(AsyncAuthorization): - def __init__(self): + def __init__(self, base_logger: Optional[Logger] = None): """Single-workspace authorization.""" self.auth_test_result: Optional[AsyncSlackResponse] = None - self.logger = get_bolt_logger(AsyncSingleTeamAuthorization) + self.logger = get_bolt_logger( + AsyncSingleTeamAuthorization, base_logger=base_logger + ) async def async_process( self, diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index 7aeacf259..dd7c9ad3e 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -1,3 +1,4 @@ +from logging import Logger from typing import Callable, Optional from slack_sdk.errors import SlackApiError @@ -22,14 +23,16 @@ def __init__( self, *, authorize: Authorize, + base_logger: Optional[Logger] = None, ): """Multi-workspace authorization. Args: authorize: The function to authorize incoming requests from Slack. + base_logger: The base logger """ self.authorize = authorize - self.logger = get_bolt_logger(MultiTeamsAuthorization) + self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger) def process( self, diff --git a/slack_bolt/middleware/authorization/single_team_authorization.py b/slack_bolt/middleware/authorization/single_team_authorization.py index 084f21141..b567b4299 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -1,3 +1,4 @@ +from logging import Logger from typing import Callable, Optional from slack_bolt.logger import get_bolt_logger @@ -16,14 +17,20 @@ class SingleTeamAuthorization(Authorization): - def __init__(self, *, auth_test_result: Optional[SlackResponse] = None): + def __init__( + self, + *, + auth_test_result: Optional[SlackResponse] = None, + base_logger: Optional[Logger] = None, + ): """Single-workspace authorization. Args: auth_test_result: The initial `auth.test` API call result. + base_logger: The base logger """ self.auth_test_result = auth_test_result - self.logger = get_bolt_logger(SingleTeamAuthorization) + self.logger = get_bolt_logger(SingleTeamAuthorization, base_logger=base_logger) def process( self, diff --git a/slack_bolt/middleware/custom_middleware.py b/slack_bolt/middleware/custom_middleware.py index 6b52b5897..3b7699cfd 100644 --- a/slack_bolt/middleware/custom_middleware.py +++ b/slack_bolt/middleware/custom_middleware.py @@ -1,6 +1,6 @@ import inspect from logging import Logger -from typing import Callable, Any, Sequence +from typing import Callable, Any, Sequence, Optional from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.logger import get_bolt_app_logger @@ -16,11 +16,13 @@ class CustomMiddleware(Middleware): arg_names: Sequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable): + def __init__( + self, *, app_name: str, func: Callable, base_logger: Optional[Logger] = None + ): self.app_name = app_name self.func = func self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) def process( self, diff --git a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py index 06642b301..bd825330d 100644 --- a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -1,5 +1,5 @@ import logging -from typing import Callable, Dict, Any +from typing import Callable, Dict, Any, Optional from slack_bolt.authorization import AuthorizeResult from slack_bolt.logger import get_bolt_logger @@ -9,9 +9,9 @@ class IgnoringSelfEvents(Middleware): - def __init__(self): + def __init__(self, base_logger: Optional[logging.Logger] = None): """Ignores the events generated by this bot user itself.""" - self.logger = get_bolt_logger(IgnoringSelfEvents) + self.logger = get_bolt_logger(IgnoringSelfEvents, base_logger=base_logger) def process( self, diff --git a/slack_bolt/middleware/request_verification/request_verification.py b/slack_bolt/middleware/request_verification/request_verification.py index ebbf803e3..072901521 100644 --- a/slack_bolt/middleware/request_verification/request_verification.py +++ b/slack_bolt/middleware/request_verification/request_verification.py @@ -1,4 +1,5 @@ -from typing import Callable, Dict, Any +from logging import Logger +from typing import Callable, Dict, Any, Optional from slack_sdk.signature import SignatureVerifier @@ -9,7 +10,7 @@ class RequestVerification(Middleware): # type: ignore - def __init__(self, signing_secret: str): + def __init__(self, signing_secret: str, base_logger: Optional[Logger] = None): """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. @@ -17,9 +18,10 @@ def __init__(self, signing_secret: str): Args: signing_secret: The signing secret + base_logger: The base logger """ self.verifier = SignatureVerifier(signing_secret=signing_secret) - self.logger = get_bolt_logger(RequestVerification) + self.logger = get_bolt_logger(RequestVerification, base_logger=base_logger) def process( self, diff --git a/slack_bolt/middleware/ssl_check/ssl_check.py b/slack_bolt/middleware/ssl_check/ssl_check.py index e02ec7881..d8c92c72d 100644 --- a/slack_bolt/middleware/ssl_check/ssl_check.py +++ b/slack_bolt/middleware/ssl_check/ssl_check.py @@ -11,16 +11,21 @@ class SslCheck(Middleware): # type: ignore verification_token: Optional[str] logger: Logger - def __init__(self, verification_token: Optional[str] = None): + def __init__( + self, + verification_token: Optional[str] = None, + base_logger: Optional[Logger] = None, + ): """Handles `ssl_check` requests. Refer to https://api.slack.com/interactivity/slash-commands for details. Args: verification_token: The verification token to check (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) + base_logger: The base logger """ self.verification_token = verification_token - self.logger = get_bolt_logger(SslCheck) + self.logger = get_bolt_logger(SslCheck, base_logger=base_logger) def process( self, diff --git a/slack_bolt/middleware/url_verification/async_url_verification.py b/slack_bolt/middleware/url_verification/async_url_verification.py index 91b4f2b63..2371bc3a4 100644 --- a/slack_bolt/middleware/url_verification/async_url_verification.py +++ b/slack_bolt/middleware/url_verification/async_url_verification.py @@ -1,4 +1,5 @@ -from typing import Callable, Awaitable +from logging import Logger +from typing import Callable, Awaitable, Optional from slack_bolt.logger import get_bolt_logger from .url_verification import UrlVerification @@ -8,8 +9,8 @@ class AsyncUrlVerification(UrlVerification, AsyncMiddleware): - def __init__(self): - self.logger = get_bolt_logger(AsyncUrlVerification) + def __init__(self, base_logger: Optional[Logger] = None): + self.logger = get_bolt_logger(AsyncUrlVerification, base_logger=base_logger) async def async_process( self, diff --git a/slack_bolt/middleware/url_verification/url_verification.py b/slack_bolt/middleware/url_verification/url_verification.py index 591838e31..a8afc1baa 100644 --- a/slack_bolt/middleware/url_verification/url_verification.py +++ b/slack_bolt/middleware/url_verification/url_verification.py @@ -1,4 +1,5 @@ -from typing import Callable +from logging import Logger +from typing import Callable, Optional from slack_bolt.logger import get_bolt_logger from slack_bolt.middleware.middleware import Middleware @@ -7,12 +8,15 @@ class UrlVerification(Middleware): # type: ignore - def __init__(self): + def __init__(self, base_logger: Optional[Logger] = None): """Handles url_verification requests. Refer to https://api.slack.com/events/url_verification for details. + + Args: + base_logger: The base logger """ - self.logger = get_bolt_logger(UrlVerification) + self.logger = get_bolt_logger(UrlVerification, base_logger=base_logger) def process( self, diff --git a/slack_bolt/workflows/step/async_step.py b/slack_bolt/workflows/step/async_step.py index b445962d0..113c6c346 100644 --- a/slack_bolt/workflows/step/async_step.py +++ b/slack_bolt/workflows/step/async_step.py @@ -1,4 +1,5 @@ from functools import wraps +from logging import Logger from typing import Callable, Union, Optional, Awaitable, Sequence, List, Pattern from slack_sdk.web.async_client import AsyncWebClient @@ -31,6 +32,7 @@ class AsyncWorkflowStepBuilder: """ callback_id: Union[str, Pattern] + _base_logger: Optional[Logger] _edit: Optional[AsyncListener] _save: Optional[AsyncListener] _execute: Optional[AsyncListener] @@ -39,6 +41,7 @@ def __init__( self, callback_id: Union[str, Pattern], app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, ): """This builder is supposed to be used as decorator. @@ -61,9 +64,11 @@ async def execute_my_step(step, complete, fail): Args: callback_id: The callback_id for the workflow app_name: The application name mainly for logging + base_logger: The base logger """ self.callback_id = callback_id self.app_name = app_name or __name__ + self._base_logger = base_logger self._edit = None self._save = None self._execute = None @@ -217,7 +222,7 @@ async def _wrapper(*args, **kwargs): return _inner - def build(self) -> "AsyncWorkflowStep": + def build(self, base_logger: Optional[Logger] = None) -> "AsyncWorkflowStep": """Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -237,6 +242,7 @@ def build(self) -> "AsyncWorkflowStep": save=self._save, execute=self._execute, app_name=self.app_name, + base_logger=base_logger, ) # --------------------------------------- @@ -257,6 +263,7 @@ def _to_listener( name=name, matchers=self.to_listener_matchers(self.app_name, matchers), middleware=self.to_listener_middleware(self.app_name, middleware), + base_logger=self._base_logger, ) @staticmethod @@ -319,16 +326,39 @@ def __init__( Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable] ], app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, ): self.callback_id = callback_id app_name = app_name or __name__ - self.edit = self.build_listener(callback_id, app_name, edit, "edit") - self.save = self.build_listener(callback_id, app_name, save, "save") - self.execute = self.build_listener(callback_id, app_name, execute, "execute") + self.edit = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=edit, + name="edit", + base_logger=base_logger, + ) + self.save = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=save, + name="save", + base_logger=base_logger, + ) + self.execute = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=execute, + name="execute", + base_logger=base_logger, + ) @classmethod - def builder(cls, callback_id: Union[str, Pattern]) -> AsyncWorkflowStepBuilder: - return AsyncWorkflowStepBuilder(callback_id) + def builder( + cls, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> AsyncWorkflowStepBuilder: + return AsyncWorkflowStepBuilder(callback_id, base_logger=base_logger) @classmethod def build_listener( @@ -339,6 +369,7 @@ def build_listener( name: str, matchers: Optional[List[AsyncListenerMatcher]] = None, middleware: Optional[List[AsyncMiddleware]] = None, + base_logger: Optional[Logger] = None, ): if listener_or_functions is None: raise BoltError(f"{name} listener is required (callback_id: {callback_id})") @@ -350,9 +381,13 @@ def build_listener( return listener_or_functions elif isinstance(listener_or_functions, list): matchers = matchers if matchers else [] - matchers.insert(0, cls._build_primary_matcher(name, callback_id)) + matchers.insert( + 0, cls._build_primary_matcher(name, callback_id, base_logger) + ) middleware = middleware if middleware else [] - middleware.insert(0, cls._build_single_middleware(name, callback_id)) + middleware.insert( + 0, cls._build_single_middleware(name, callback_id, base_logger) + ) functions = listener_or_functions ack_function = functions.pop(0) return AsyncCustomListener( @@ -362,6 +397,7 @@ def build_listener( ack_function=ack_function, lazy_functions=functions, auto_acknowledgement=name == "execute", + base_logger=base_logger, ) else: raise BoltError( @@ -370,25 +406,39 @@ def build_listener( @classmethod def _build_primary_matcher( - cls, name: str, callback_id: str + cls, + name: str, + callback_id: str, + base_logger: Optional[Logger] = None, ) -> AsyncListenerMatcher: if name == "edit": - return workflow_step_edit(callback_id, asyncio=True) + return workflow_step_edit( + callback_id, asyncio=True, base_logger=base_logger + ) elif name == "save": - return workflow_step_save(callback_id, asyncio=True) + return workflow_step_save( + callback_id, asyncio=True, base_logger=base_logger + ) elif name == "execute": - return workflow_step_execute(callback_id, asyncio=True) + return workflow_step_execute( + callback_id, asyncio=True, base_logger=base_logger + ) else: raise ValueError(f"Invalid name {name}") @classmethod - def _build_single_middleware(cls, name: str, callback_id: str) -> AsyncMiddleware: + def _build_single_middleware( + cls, + name: str, + callback_id: str, + base_logger: Optional[Logger] = None, + ) -> AsyncMiddleware: if name == "edit": - return _build_edit_listener_middleware(callback_id) + return _build_edit_listener_middleware(callback_id, base_logger) elif name == "save": - return _build_save_listener_middleware() + return _build_save_listener_middleware(base_logger) elif name == "execute": - return _build_execute_listener_middleware() + return _build_execute_listener_middleware(base_logger) else: raise ValueError(f"Invalid name {name}") @@ -398,7 +448,10 @@ def _build_single_middleware(cls, name: str, callback_id: str) -> AsyncMiddlewar ####################### -def _build_edit_listener_middleware(callback_id: str) -> AsyncMiddleware: +def _build_edit_listener_middleware( + callback_id: str, + base_logger: Optional[Logger] = None, +) -> AsyncMiddleware: async def edit_listener_middleware( context: AsyncBoltContext, client: AsyncWebClient, @@ -412,7 +465,11 @@ async def edit_listener_middleware( ) return await next() - return AsyncCustomMiddleware(app_name=__name__, func=edit_listener_middleware) + return AsyncCustomMiddleware( + app_name=__name__, + func=edit_listener_middleware, + base_logger=base_logger, + ) ####################### @@ -420,7 +477,9 @@ async def edit_listener_middleware( ####################### -def _build_save_listener_middleware() -> AsyncMiddleware: +def _build_save_listener_middleware( + base_logger: Optional[Logger] = None, +) -> AsyncMiddleware: async def save_listener_middleware( context: AsyncBoltContext, client: AsyncWebClient, @@ -433,7 +492,11 @@ async def save_listener_middleware( ) return await next() - return AsyncCustomMiddleware(app_name=__name__, func=save_listener_middleware) + return AsyncCustomMiddleware( + app_name=__name__, + func=save_listener_middleware, + base_logger=base_logger, + ) ####################### @@ -441,7 +504,9 @@ async def save_listener_middleware( ####################### -def _build_execute_listener_middleware() -> AsyncMiddleware: +def _build_execute_listener_middleware( + base_logger: Optional[Logger] = None, +) -> AsyncMiddleware: async def execute_listener_middleware( context: AsyncBoltContext, client: AsyncWebClient, @@ -458,4 +523,8 @@ async def execute_listener_middleware( ) return await next() - return AsyncCustomMiddleware(app_name=__name__, func=execute_listener_middleware) + return AsyncCustomMiddleware( + app_name=__name__, + func=execute_listener_middleware, + base_logger=base_logger, + ) diff --git a/slack_bolt/workflows/step/step.py b/slack_bolt/workflows/step/step.py index 692ee5d1e..dd17289fe 100644 --- a/slack_bolt/workflows/step/step.py +++ b/slack_bolt/workflows/step/step.py @@ -1,4 +1,5 @@ from functools import wraps +from logging import Logger from typing import Callable, Union, Optional, Sequence, Pattern, List from slack_bolt.context.context import BoltContext @@ -26,6 +27,7 @@ class WorkflowStepBuilder: """ callback_id: Union[str, Pattern] + _base_logger: Optional[Logger] _edit: Optional[Listener] _save: Optional[Listener] _execute: Optional[Listener] @@ -34,6 +36,7 @@ def __init__( self, callback_id: Union[str, Pattern], app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, ): """This builder is supposed to be used as decorator. @@ -56,9 +59,11 @@ def execute_my_step(step, complete, fail): Args: callback_id: The callback_id for the workflow app_name: The application name mainly for logging + base_logger: The base logger """ self.callback_id = callback_id self.app_name = app_name or __name__ + self._base_logger = base_logger self._edit = None self._save = None self._execute = None @@ -207,7 +212,7 @@ def _wrapper(*args, **kwargs): return _inner - def build(self) -> "WorkflowStep": + def build(self, base_logger: Optional[Logger] = None) -> "WorkflowStep": """Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -227,6 +232,7 @@ def build(self) -> "WorkflowStep": save=self._save, execute=self._execute, app_name=self.app_name, + base_logger=base_logger, ) # --------------------------------------- @@ -243,14 +249,20 @@ def _to_listener( app_name=self.app_name, listener_or_functions=listener_or_functions, name=name, - matchers=self.to_listener_matchers(self.app_name, matchers), - middleware=self.to_listener_middleware(self.app_name, middleware), + matchers=self.to_listener_matchers( + self.app_name, matchers, self._base_logger + ), + middleware=self.to_listener_middleware( + self.app_name, middleware, self._base_logger + ), + base_logger=self._base_logger, ) @staticmethod def to_listener_matchers( app_name: str, matchers: Optional[List[Union[Callable[..., bool], ListenerMatcher]]], + base_logger: Optional[Logger] = None, ) -> List[ListenerMatcher]: _matchers = [] if matchers is not None: @@ -258,14 +270,22 @@ def to_listener_matchers( if isinstance(m, ListenerMatcher): _matchers.append(m) elif isinstance(m, Callable): - _matchers.append(CustomListenerMatcher(app_name=app_name, func=m)) + _matchers.append( + CustomListenerMatcher( + app_name=app_name, + func=m, + base_logger=base_logger, + ) + ) else: raise ValueError(f"Invalid matcher: {type(m)}") return _matchers # type: ignore @staticmethod def to_listener_middleware( - app_name: str, middleware: Optional[List[Union[Callable, Middleware]]] + app_name: str, + middleware: Optional[List[Union[Callable, Middleware]]], + base_logger: Optional[Logger] = None, ) -> List[Middleware]: _middleware = [] if middleware is not None: @@ -273,7 +293,13 @@ def to_listener_middleware( if isinstance(m, Middleware): _middleware.append(m) elif isinstance(m, Callable): - _middleware.append(CustomMiddleware(app_name=app_name, func=m)) + _middleware.append( + CustomMiddleware( + app_name=app_name, + func=m, + base_logger=base_logger, + ) + ) else: raise ValueError(f"Invalid middleware: {type(m)}") return _middleware # type: ignore @@ -303,16 +329,40 @@ def __init__( Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable] ], app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, ): self.callback_id = callback_id app_name = app_name or __name__ - self.edit = self.build_listener(callback_id, app_name, edit, "edit") - self.save = self.build_listener(callback_id, app_name, save, "save") - self.execute = self.build_listener(callback_id, app_name, execute, "execute") + self.edit = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=edit, + name="edit", + base_logger=base_logger, + ) + self.save = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=save, + name="save", + base_logger=base_logger, + ) + self.execute = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=execute, + name="execute", + base_logger=base_logger, + ) @classmethod - def builder(cls, callback_id: Union[str, Pattern]) -> WorkflowStepBuilder: - return WorkflowStepBuilder(callback_id) + def builder( + cls, callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None + ) -> WorkflowStepBuilder: + return WorkflowStepBuilder( + callback_id, + base_logger=base_logger, + ) @classmethod def build_listener( @@ -323,6 +373,7 @@ def build_listener( name: str, matchers: Optional[List[ListenerMatcher]] = None, middleware: Optional[List[Middleware]] = None, + base_logger: Optional[Logger] = None, ) -> Listener: if listener_or_functions is None: raise BoltError(f"{name} listener is required (callback_id: {callback_id})") @@ -334,9 +385,23 @@ def build_listener( return listener_or_functions elif isinstance(listener_or_functions, list): matchers = matchers if matchers else [] - matchers.insert(0, cls._build_primary_matcher(name, callback_id)) + matchers.insert( + 0, + cls._build_primary_matcher( + name, + callback_id, + base_logger=base_logger, + ), + ) middleware = middleware if middleware else [] - middleware.insert(0, cls._build_single_middleware(name, callback_id)) + middleware.insert( + 0, + cls._build_single_middleware( + name, + callback_id, + base_logger=base_logger, + ), + ) functions = listener_or_functions ack_function = functions.pop(0) return CustomListener( @@ -346,6 +411,7 @@ def build_listener( ack_function=ack_function, lazy_functions=functions, auto_acknowledgement=name == "execute", + base_logger=base_logger, ) else: raise BoltError( @@ -353,24 +419,34 @@ def build_listener( ) @classmethod - def _build_primary_matcher(cls, name, callback_id) -> ListenerMatcher: + def _build_primary_matcher( + cls, + name: str, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> ListenerMatcher: if name == "edit": - return workflow_step_edit(callback_id) + return workflow_step_edit(callback_id, base_logger=base_logger) elif name == "save": - return workflow_step_save(callback_id) + return workflow_step_save(callback_id, base_logger=base_logger) elif name == "execute": - return workflow_step_execute(callback_id) + return workflow_step_execute(callback_id, base_logger=base_logger) else: raise ValueError(f"Invalid name {name}") @classmethod - def _build_single_middleware(cls, name, callback_id) -> Middleware: + def _build_single_middleware( + cls, + name: str, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> Middleware: if name == "edit": - return _build_edit_listener_middleware(callback_id) + return _build_edit_listener_middleware(callback_id, base_logger=base_logger) elif name == "save": - return _build_save_listener_middleware() + return _build_save_listener_middleware(base_logger=base_logger) elif name == "execute": - return _build_execute_listener_middleware() + return _build_execute_listener_middleware(base_logger=base_logger) else: raise ValueError(f"Invalid name {name}") @@ -380,7 +456,9 @@ def _build_single_middleware(cls, name, callback_id) -> Middleware: ####################### -def _build_edit_listener_middleware(callback_id: str) -> Middleware: +def _build_edit_listener_middleware( + callback_id: str, base_logger: Optional[Logger] = None +) -> Middleware: def edit_listener_middleware( context: BoltContext, client: WebClient, @@ -394,7 +472,11 @@ def edit_listener_middleware( ) return next() - return CustomMiddleware(app_name=__name__, func=edit_listener_middleware) + return CustomMiddleware( + app_name=__name__, + func=edit_listener_middleware, + base_logger=base_logger, + ) ####################### @@ -402,7 +484,7 @@ def edit_listener_middleware( ####################### -def _build_save_listener_middleware() -> Middleware: +def _build_save_listener_middleware(base_logger: Optional[Logger] = None) -> Middleware: def save_listener_middleware( context: BoltContext, client: WebClient, @@ -415,7 +497,11 @@ def save_listener_middleware( ) return next() - return CustomMiddleware(app_name=__name__, func=save_listener_middleware) + return CustomMiddleware( + app_name=__name__, + func=save_listener_middleware, + base_logger=base_logger, + ) ####################### @@ -423,7 +509,9 @@ def save_listener_middleware( ####################### -def _build_execute_listener_middleware() -> Middleware: +def _build_execute_listener_middleware( + base_logger: Optional[Logger] = None, +) -> Middleware: def execute_listener_middleware( context: BoltContext, client: WebClient, @@ -440,4 +528,8 @@ def execute_listener_middleware( ) return next() - return CustomMiddleware(app_name=__name__, func=execute_listener_middleware) + return CustomMiddleware( + app_name=__name__, + func=execute_listener_middleware, + base_logger=base_logger, + ) diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index 86e89c9e9..61d86659c 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -1,3 +1,5 @@ +import logging +import time from concurrent.futures import Executor from ssl import SSLContext @@ -259,26 +261,6 @@ def test_proxy_ssl_for_respond(self): ), ) - event_body = { - "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, - } - result = {"called": False} @app.event("app_mention") @@ -289,7 +271,84 @@ def handle(context: BoltContext, respond): assert respond.ssl == ssl result["called"] = True - req = BoltRequest(body=event_body, headers={}, mode="socket_mode") + req = BoltRequest(body=app_mention_event_body, headers={}, mode="socket_mode") response = app.dispatch(req) assert response.status == 200 assert result["called"] is True + + def test_argument_logger_propagation(self): + custom_logger = logging.getLogger(f"{__name__}-{time.time()}-logger-test") + custom_logger.setLevel(logging.INFO) + added_handler = logging.NullHandler() + custom_logger.addHandler(added_handler) + added_filter = logging.Filter() + custom_logger.addFilter(added_filter) + + app = App( + signing_secret="valid", + client=WebClient( + token=self.valid_token, + base_url=self.mock_api_server_base_url, + ), + authorize=lambda: AuthorizeResult( + enterprise_id="E111", + team_id="T111", + ), + logger=custom_logger, + ) + result = {"called": False} + + def _verify_logger(logger: logging.Logger): + assert logger.level == custom_logger.level + assert len(logger.handlers) == len(custom_logger.handlers) + assert logger.handlers[-1] == custom_logger.handlers[-1] + assert len(logger.filters) == len(custom_logger.filters) + assert logger.filters[-1] == custom_logger.filters[-1] + + @app.use + def global_middleware(logger, next): + _verify_logger(logger) + next() + + def listener_middleware(logger, next): + _verify_logger(logger) + next() + + def listener_matcher(logger): + _verify_logger(logger) + return True + + @app.event( + "app_mention", + middleware=[listener_middleware], + matchers=[listener_matcher], + ) + def handle(logger: logging.Logger): + _verify_logger(logger) + result["called"] = True + + req = BoltRequest(body=app_mention_event_body, headers={}, mode="socket_mode") + response = app.dispatch(req) + assert response.status == 200 + assert result["called"] is True + + +app_mention_event_body = { + "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, +} diff --git a/tests/scenario_tests/test_workflow_steps.py b/tests/scenario_tests/test_workflow_steps.py index 38cc803c3..95fc0379f 100644 --- a/tests/scenario_tests/test_workflow_steps.py +++ b/tests/scenario_tests/test_workflow_steps.py @@ -1,4 +1,5 @@ import json +import logging import time as time_module from time import time from urllib.parse import quote @@ -168,6 +169,65 @@ def test_execute_process_before_response(self): response = app.dispatch(request) assert response.status == 404 + def test_custom_logger_propagation(self): + custom_logger = logging.getLogger(f"{__name__}-{time()}-logger-test") + custom_logger.setLevel(logging.INFO) + added_handler = logging.NullHandler() + custom_logger.addHandler(added_handler) + added_filter = logging.Filter() + custom_logger.addFilter(added_filter) + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + logger=custom_logger, + ) + + def verify_logger_is_properly_passed(ack: Ack, logger: logging.Logger): + assert logger.level == custom_logger.level + assert len(logger.handlers) == len(custom_logger.handlers) + assert logger.handlers[-1] == custom_logger.handlers[-1] + assert len(logger.filters) == len(custom_logger.filters) + assert logger.filters[-1] == custom_logger.filters[-1] + ack() + + app.step( + callback_id="copy_review", + edit=verify_logger_is_properly_passed, + save=verify_logger_is_properly_passed, + execute=verify_logger_is_properly_passed, + ) + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + edit_payload = { "type": "workflow_step_edit", diff --git a/tests/scenario_tests/test_workflow_steps_decorator_with_args.py b/tests/scenario_tests/test_workflow_steps_decorator_with_args.py index 32f334c14..2d4ea5907 100644 --- a/tests/scenario_tests/test_workflow_steps_decorator_with_args.py +++ b/tests/scenario_tests/test_workflow_steps_decorator_with_args.py @@ -1,4 +1,5 @@ import json +import logging import time as time_module from time import time from urllib.parse import quote @@ -97,6 +98,44 @@ def test_execute(self): response = self.app.dispatch(request) assert response.status == 404 + def test_logger_propagation(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + logger=custom_logger, + ) + app.step(logger_test_step) + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + edit_payload = { "type": "workflow_step_edit", @@ -273,6 +312,10 @@ def test_execute(self): } +# +# The normal pattern tests +# + # https://api.slack.com/tutorials/workflow-builder-steps @@ -427,3 +470,64 @@ def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): ) except Exception as err: fail(error={"message": f"Something wrong! {err}"}) + + +# +# Logger propagation tests +# + +custom_logger = logging.getLogger(f"{__name__}-{time()}-logger-test") +custom_logger.setLevel(logging.INFO) +added_handler = logging.NullHandler() +custom_logger.addHandler(added_handler) +added_filter = logging.Filter() +custom_logger.addFilter(added_filter) + +logger_test_step = WorkflowStep.builder( + "copy_review", + base_logger=custom_logger, # to pass this logger to middleware / middleware matchers +) + + +def _verify_logger(logger: logging.Logger): + assert logger.level == custom_logger.level + assert len(logger.handlers) == len(custom_logger.handlers) + assert logger.handlers[-1] == custom_logger.handlers[-1] + assert len(logger.filters) == len(custom_logger.filters) + assert logger.filters[-1] == custom_logger.filters[-1] + + +def logger_middleware(next, logger): + _verify_logger(logger) + next() + + +def logger_matcher(logger): + _verify_logger(logger) + return True + + +@logger_test_step.edit( + middleware=[logger_middleware], + matchers=[logger_matcher], +) +def edit_for_logger_test(ack: Ack, logger: logging.Logger): + _verify_logger(logger) + ack() + + +@logger_test_step.save( + middleware=[logger_middleware], + matchers=[logger_matcher], +) +def save_for_logger_test(ack: Ack, logger: logging.Logger): + _verify_logger(logger) + ack() + + +@logger_test_step.execute( + middleware=[logger_middleware], + matchers=[logger_matcher], +) +def execute_for_logger_test(logger: logging.Logger): + _verify_logger(logger) diff --git a/tests/scenario_tests_async/test_app.py b/tests/scenario_tests_async/test_app.py index 7486027fe..3a756d533 100644 --- a/tests/scenario_tests_async/test_app.py +++ b/tests/scenario_tests_async/test_app.py @@ -1,4 +1,5 @@ import asyncio +import logging from ssl import SSLContext import pytest @@ -193,45 +194,17 @@ def test_installation_store_conflicts(self): @pytest.mark.asyncio async def test_proxy_ssl_for_respond(self): ssl = SSLContext() - web_client = AsyncWebClient( - token=self.valid_token, - base_url=self.mock_api_server_base_url, - proxy="http://proxy-host:9000/", - ssl=ssl, - ) - - async def my_authorize(): - return AuthorizeResult( - enterprise_id="E111", - team_id="T111", - ) - app = AsyncApp( signing_secret="valid", - client=web_client, + client=AsyncWebClient( + token=self.valid_token, + base_url=self.mock_api_server_base_url, + proxy="http://proxy-host:9000/", + ssl=ssl, + ), authorize=my_authorize, ) - event_body = { - "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, - } - result = {"called": False} @app.event("app_mention") @@ -242,8 +215,102 @@ async def handle(context: AsyncBoltContext, respond): assert respond.ssl == ssl result["called"] = True - req = AsyncBoltRequest(body=event_body, headers={}, mode="socket_mode") + req = AsyncBoltRequest( + body=app_mention_event_body, headers={}, mode="socket_mode" + ) response = await app.async_dispatch(req) assert response.status == 200 await asyncio.sleep(0.5) # wait a bit after auto ack() assert result["called"] is True + + @pytest.mark.asyncio + async def test_argument_logger_propagation(self): + import time + + custom_logger = logging.getLogger(f"{__name__}-{time.time()}-async-logger-test") + custom_logger.setLevel(logging.INFO) + added_handler = logging.NullHandler() + custom_logger.addHandler(added_handler) + added_filter = logging.Filter() + custom_logger.addFilter(added_filter) + + app = AsyncApp( + signing_secret="valid", + client=AsyncWebClient( + token=self.valid_token, + base_url=self.mock_api_server_base_url, + ), + authorize=my_authorize, + logger=custom_logger, + ) + + result = {"called": False} + + def _verify_logger(logger: logging.Logger): + assert logger.level == custom_logger.level + assert len(logger.handlers) == len(custom_logger.handlers) + # TODO: this assertion fails only with codecov + # assert logger.handlers[-1] == custom_logger.handlers[-1] + assert logger.handlers[-1].name == custom_logger.handlers[-1].name + assert len(logger.filters) == len(custom_logger.filters) + # TODO: this assertion fails only with codecov + # assert logger.filters[-1] == custom_logger.filters[-1] + assert logger.filters[-1].name == custom_logger.filters[-1].name + + @app.use + async def global_middleware(logger, next): + _verify_logger(logger) + await next() + + async def listener_middleware(logger, next): + _verify_logger(logger) + await next() + + async def listener_matcher(logger): + _verify_logger(logger) + return True + + @app.event( + "app_mention", + middleware=[listener_middleware], + matchers=[listener_matcher], + ) + async def handle(logger: logging.Logger): + _verify_logger(logger) + result["called"] = True + + req = AsyncBoltRequest( + body=app_mention_event_body, headers={}, mode="socket_mode" + ) + response = await app.async_dispatch(req) + assert response.status == 200 + await asyncio.sleep(0.5) # wait a bit after auto ack() + assert result["called"] is True + + +async def my_authorize(): + return AuthorizeResult( + enterprise_id="E111", + team_id="T111", + ) + + +app_mention_event_body = { + "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, +} diff --git a/tests/scenario_tests_async/test_workflow_steps.py b/tests/scenario_tests_async/test_workflow_steps.py index cf88d46b4..ca3390ac3 100644 --- a/tests/scenario_tests_async/test_workflow_steps.py +++ b/tests/scenario_tests_async/test_workflow_steps.py @@ -1,5 +1,6 @@ import asyncio import json +import logging from time import time from urllib.parse import quote @@ -188,6 +189,68 @@ async def test_execute_process_before_response(self): response = await app.async_dispatch(request) assert response.status == 404 + @pytest.mark.asyncio + async def test_custom_logger_propagation(self): + custom_logger = logging.getLogger(f"{__name__}-{time()}-async-logger-test") + custom_logger.setLevel(logging.INFO) + added_handler = logging.NullHandler() + custom_logger.addHandler(added_handler) + added_filter = logging.Filter() + custom_logger.addFilter(added_filter) + + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + logger=custom_logger, + ) + + async def verify_logger_is_properly_passed( + ack: AsyncAck, logger: logging.Logger + ): + assert logger.level == custom_logger.level + assert len(logger.handlers) == len(custom_logger.handlers) + assert logger.handlers[-1] == custom_logger.handlers[-1] + assert len(logger.filters) == len(custom_logger.filters) + assert logger.filters[-1] == custom_logger.filters[-1] + await ack() + + app.step( + callback_id="copy_review", + edit=verify_logger_is_properly_passed, + save=verify_logger_is_properly_passed, + execute=verify_logger_is_properly_passed, + ) + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + edit_payload = { "type": "workflow_step_edit", diff --git a/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py b/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py index 1db6c8b2d..759b8de8c 100644 --- a/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py +++ b/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py @@ -1,5 +1,6 @@ import asyncio import json +import logging from time import time from urllib.parse import quote @@ -118,6 +119,46 @@ async def test_execute(self): response = await self.app.async_dispatch(request) assert response.status == 404 + @pytest.mark.asyncio + async def test_logger_propagation(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + logger=custom_logger, + ) + app.step(logger_test_step) + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + await asyncio.sleep(0.5) # wait for the completion + edit_payload = { "type": "workflow_step_edit", @@ -293,6 +334,9 @@ async def test_execute(self): "event_time": 1601541373, } +# +# The normal pattern tests +# # https://api.slack.com/tutorials/workflow-builder-steps @@ -449,3 +493,64 @@ async def execute( ) except Exception as err: await fail(error={"message": f"Something wrong! {err}"}) + + +# +# Logger propagation tests +# + +custom_logger = logging.getLogger(f"{__name__}-{time()}-async-logger-test") +custom_logger.setLevel(logging.INFO) +added_handler = logging.NullHandler() +custom_logger.addHandler(added_handler) +added_filter = logging.Filter() +custom_logger.addFilter(added_filter) + +logger_test_step = AsyncWorkflowStep.builder( + "copy_review", + base_logger=custom_logger, # to pass this logger to middleware / middleware matchers +) + + +def _verify_logger(logger: logging.Logger): + assert logger.level == custom_logger.level + assert len(logger.handlers) == len(custom_logger.handlers) + assert logger.handlers[-1] == custom_logger.handlers[-1] + assert len(logger.filters) == len(custom_logger.filters) + assert logger.filters[-1] == custom_logger.filters[-1] + + +async def logger_middleware(next, logger): + _verify_logger(logger) + await next() + + +async def logger_matcher(logger): + _verify_logger(logger) + return True + + +@logger_test_step.edit( + middleware=[logger_middleware], + matchers=[logger_matcher], +) +async def edit_for_logger_test(ack: AsyncAck, logger: logging.Logger): + _verify_logger(logger) + await ack() + + +@logger_test_step.save( + middleware=[logger_middleware], + matchers=[logger_matcher], +) +async def save_for_logger_test(ack: AsyncAck, logger: logging.Logger): + _verify_logger(logger) + await ack() + + +@logger_test_step.execute( + middleware=[logger_middleware], + matchers=[logger_matcher], +) +async def execute_for_logger_test(logger: logging.Logger): + _verify_logger(logger) From 89883bdf2c9fc98a141f111232c81bfda8f08f45 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 18 Mar 2022 21:40:02 +0900 Subject: [PATCH 462/865] version 1.13.0 --- .../adapter/aws_lambda/chalice_handler.html | 8 +- .../adapter/aws_lambda/handler.html | 4 +- docs/api-docs/slack_bolt/app/app.html | 337 +++++++++++++----- docs/api-docs/slack_bolt/app/async_app.html | 336 ++++++++++++----- .../slack_bolt/listener/async_listener.html | 13 +- .../slack_bolt/listener/custom_listener.html | 8 +- .../listener_matcher/async_builtins.html | 2 +- .../async_listener_matcher.html | 36 +- .../slack_bolt/listener_matcher/builtins.html | 213 +++++++---- .../custom_listener_matcher.html | 24 +- docs/api-docs/slack_bolt/logger/index.html | 84 +++-- .../middleware/async_custom_middleware.html | 24 +- .../async_multi_teams_authorization.html | 21 +- .../async_single_team_authorization.html | 16 +- .../multi_teams_authorization.html | 15 +- .../single_team_authorization.html | 27 +- .../middleware/custom_middleware.html | 16 +- .../async_ignoring_self_events.html | 1 + .../ignoring_self_events.html | 11 +- .../async_request_verification.html | 4 +- .../request_verification.html | 17 +- .../middleware/ssl_check/async_ssl_check.html | 4 +- .../middleware/ssl_check/ssl_check.html | 22 +- .../async_url_verification.html | 19 +- .../url_verification/url_verification.html | 25 +- docs/api-docs/slack_bolt/version.html | 2 +- .../slack_bolt/workflows/step/async_step.html | 227 +++++++++--- .../slack_bolt/workflows/step/step.html | 332 +++++++++++++---- slack_bolt/version.py | 2 +- 29 files changed, 1379 insertions(+), 471 deletions(-) diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html index 53132d9a8..74c7de367 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html @@ -49,7 +49,9 @@

    Module slack_bolt.adapter.aws_lambda.chalice_handlerClasses

    def __init__(self, app: App, chalice: Chalice, lambda_client: Optional[BaseClient] = None): # type: ignore self.app = app self.chalice = chalice - self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler) + self.logger = get_bolt_app_logger( + app.name, ChaliceSlackRequestHandler, app.logger + ) if getenv("AWS_CHALICE_CLI_MODE") == "true" and lambda_client is None: try: diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html index ec31815d9..144301b9f 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/handler.html @@ -42,7 +42,7 @@

    Module slack_bolt.adapter.aws_lambda.handler

    class SlackRequestHandler: def __init__(self, app: App): # type: ignore self.app = app - self.logger = get_bolt_app_logger(app.name, SlackRequestHandler) + self.logger = get_bolt_app_logger(app.name, SlackRequestHandler, app.logger) self.app.listener_runner.lazy_listener_runner = LambdaLazyListenerRunner( self.logger ) @@ -228,7 +228,7 @@

    Classes

    class SlackRequestHandler:
         def __init__(self, app: App):  # type: ignore
             self.app = app
    -        self.logger = get_bolt_app_logger(app.name, SlackRequestHandler)
    +        self.logger = get_bolt_app_logger(app.name, SlackRequestHandler, app.logger)
             self.app.listener_runner.lazy_listener_runner = LambdaLazyListenerRunner(
                 self.logger
             )
    diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html
    index 1d80827e2..e49fe3d59 100644
    --- a/docs/api-docs/slack_bolt/app/app.html
    +++ b/docs/api-docs/slack_bolt/app/app.html
    @@ -217,6 +217,11 @@ 

    Module slack_bolt.app.app

    self._verification_token: Optional[str] = verification_token or os.environ.get( "SLACK_VERIFICATION_TOKEN", None ) + # If a logger is explicitly passed when initializing, the logger works as the base logger. + # The base logger's logging settings will be propagated to all the loggers created by bolt-python. + self._base_logger = logger + # The framework logger is supposed to be used for the internal logging. + # Also, it's accessible via `app.logger` as the app's singleton logger. self._framework_logger = logger or get_bolt_logger(App) self._raise_error_for_unhandled_request = raise_error_for_unhandled_request @@ -384,10 +389,15 @@

    Module slack_bolt.app.app

    return if ssl_check_enabled is True: self._middleware_list.append( - SslCheck(verification_token=self._verification_token) + SslCheck( + verification_token=self._verification_token, + base_logger=self._base_logger, + ) ) if request_verification_enabled is True: - self._middleware_list.append(RequestVerification(self._signing_secret)) + self._middleware_list.append( + RequestVerification(self._signing_secret, base_logger=self._base_logger) + ) # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._oauth_flow is None: @@ -398,24 +408,33 @@

    Module slack_bolt.app.app

    # This API call is for eagerly validating the token auth_test_result = self._client.auth_test(token=self._token) self._middleware_list.append( - SingleTeamAuthorization(auth_test_result=auth_test_result) + SingleTeamAuthorization( + auth_test_result=auth_test_result, + base_logger=self._base_logger, + ) ) except SlackApiError as err: raise BoltError(error_auth_test_failure(err.response)) elif self._authorize is not None: self._middleware_list.append( - MultiTeamsAuthorization(authorize=self._authorize) + MultiTeamsAuthorization( + authorize=self._authorize, base_logger=self._base_logger + ) ) else: raise BoltError(error_token_required()) else: self._middleware_list.append( - MultiTeamsAuthorization(authorize=self._authorize) + MultiTeamsAuthorization( + authorize=self._authorize, base_logger=self._base_logger + ) ) if ignoring_self_events_enabled is True: - self._middleware_list.append(IgnoringSelfEvents()) + self._middleware_list.append( + IgnoringSelfEvents(base_logger=self._base_logger) + ) if url_verification_enabled is True: - self._middleware_list.append(UrlVerification()) + self._middleware_list.append(UrlVerification(base_logger=self._base_logger)) self._init_middleware_list_done = True # ------------------------- @@ -644,7 +663,11 @@

    Module slack_bolt.app.app

    self._middleware_list.append(middleware) elif isinstance(middleware_or_callable, Callable): self._middleware_list.append( - CustomMiddleware(app_name=self.name, func=middleware_or_callable) + CustomMiddleware( + app_name=self.name, + func=middleware_or_callable, + base_logger=self._base_logger, + ) ) return middleware_or_callable else: @@ -705,9 +728,10 @@

    Module slack_bolt.app.app

    edit=edit, save=save, execute=execute, + base_logger=self._base_logger, ) elif isinstance(step, WorkflowStepBuilder): - step = step.build() + step = step.build(base_logger=self._base_logger) elif not isinstance(step, WorkflowStep): raise BoltError(f"Invalid step object ({type(step)})") @@ -752,7 +776,9 @@

    Module slack_bolt.app.app

    def event( self, event: Union[ - str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]] + str, + Pattern, + Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]], ], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, @@ -785,7 +811,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.event(event) + primary_matcher = builtin_matchers.event( + event, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True ) @@ -840,7 +868,7 @@

    Module slack_bolt.app.app

    ), } primary_matcher = builtin_matchers.message_event( - keyword=keyword, constraints=constraints + keyword=keyword, constraints=constraints, base_logger=self._base_logger ) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener( @@ -885,7 +913,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.command(command) + primary_matcher = builtin_matchers.command( + command, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -934,7 +964,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.shortcut(constraints) + primary_matcher = builtin_matchers.shortcut( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -951,7 +983,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.global_shortcut(callback_id) + primary_matcher = builtin_matchers.global_shortcut( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -968,7 +1002,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.message_shortcut(callback_id) + primary_matcher = builtin_matchers.message_shortcut( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1010,7 +1046,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.action(constraints) + primary_matcher = builtin_matchers.action( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1029,7 +1067,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_action(constraints) + primary_matcher = builtin_matchers.block_action( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1047,7 +1087,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.attachment_action(callback_id) + primary_matcher = builtin_matchers.attachment_action( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1065,7 +1107,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_submission(callback_id) + primary_matcher = builtin_matchers.dialog_submission( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1083,7 +1127,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_cancellation(callback_id) + primary_matcher = builtin_matchers.dialog_cancellation( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1136,7 +1182,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view(constraints) + primary_matcher = builtin_matchers.view( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1154,7 +1202,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_submission(constraints) + primary_matcher = builtin_matchers.view_submission( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1172,7 +1222,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_closed(constraints) + primary_matcher = builtin_matchers.view_closed( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1225,7 +1277,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.options(constraints) + primary_matcher = builtin_matchers.options( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1242,7 +1296,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_suggestion(action_id) + primary_matcher = builtin_matchers.block_suggestion( + action_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1260,7 +1316,9 @@

    Module slack_bolt.app.app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_suggestion(callback_id) + primary_matcher = builtin_matchers.dialog_suggestion( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1291,7 +1349,9 @@

    Module slack_bolt.app.app

    # ------------------------- def _init_context(self, req: BoltRequest): - req.context["logger"] = get_bolt_app_logger(self.name) + req.context["logger"] = get_bolt_app_logger( + app_name=self.name, base_logger=self._base_logger + ) req.context["token"] = self._token if self._token is not None: # This WebClient instance can be safely singleton @@ -1337,7 +1397,10 @@

    Module slack_bolt.app.app

    value_to_return = functions[0] listener_matchers = [ - CustomListenerMatcher(app_name=self.name, func=f) for f in (matchers or []) + CustomListenerMatcher( + app_name=self.name, func=f, base_logger=self._base_logger + ) + for f in (matchers or []) ] listener_matchers.insert(0, primary_matcher) listener_middleware = [] @@ -1345,7 +1408,11 @@

    Module slack_bolt.app.app

    if isinstance(m, Middleware): listener_middleware.append(m) elif isinstance(m, Callable): - listener_middleware.append(CustomMiddleware(app_name=self.name, func=m)) + listener_middleware.append( + CustomMiddleware( + app_name=self.name, func=m, base_logger=self._base_logger + ) + ) else: raise ValueError(error_unexpected_listener_middleware(type(m))) @@ -1357,6 +1424,7 @@

    Module slack_bolt.app.app

    matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + base_logger=self._base_logger, ) ) return value_to_return @@ -1685,6 +1753,11 @@

    Args

    self._verification_token: Optional[str] = verification_token or os.environ.get( "SLACK_VERIFICATION_TOKEN", None ) + # If a logger is explicitly passed when initializing, the logger works as the base logger. + # The base logger's logging settings will be propagated to all the loggers created by bolt-python. + self._base_logger = logger + # The framework logger is supposed to be used for the internal logging. + # Also, it's accessible via `app.logger` as the app's singleton logger. self._framework_logger = logger or get_bolt_logger(App) self._raise_error_for_unhandled_request = raise_error_for_unhandled_request @@ -1852,10 +1925,15 @@

    Args

    return if ssl_check_enabled is True: self._middleware_list.append( - SslCheck(verification_token=self._verification_token) + SslCheck( + verification_token=self._verification_token, + base_logger=self._base_logger, + ) ) if request_verification_enabled is True: - self._middleware_list.append(RequestVerification(self._signing_secret)) + self._middleware_list.append( + RequestVerification(self._signing_secret, base_logger=self._base_logger) + ) # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._oauth_flow is None: @@ -1866,24 +1944,33 @@

    Args

    # This API call is for eagerly validating the token auth_test_result = self._client.auth_test(token=self._token) self._middleware_list.append( - SingleTeamAuthorization(auth_test_result=auth_test_result) + SingleTeamAuthorization( + auth_test_result=auth_test_result, + base_logger=self._base_logger, + ) ) except SlackApiError as err: raise BoltError(error_auth_test_failure(err.response)) elif self._authorize is not None: self._middleware_list.append( - MultiTeamsAuthorization(authorize=self._authorize) + MultiTeamsAuthorization( + authorize=self._authorize, base_logger=self._base_logger + ) ) else: raise BoltError(error_token_required()) else: self._middleware_list.append( - MultiTeamsAuthorization(authorize=self._authorize) + MultiTeamsAuthorization( + authorize=self._authorize, base_logger=self._base_logger + ) ) if ignoring_self_events_enabled is True: - self._middleware_list.append(IgnoringSelfEvents()) + self._middleware_list.append( + IgnoringSelfEvents(base_logger=self._base_logger) + ) if url_verification_enabled is True: - self._middleware_list.append(UrlVerification()) + self._middleware_list.append(UrlVerification(base_logger=self._base_logger)) self._init_middleware_list_done = True # ------------------------- @@ -2112,7 +2199,11 @@

    Args

    self._middleware_list.append(middleware) elif isinstance(middleware_or_callable, Callable): self._middleware_list.append( - CustomMiddleware(app_name=self.name, func=middleware_or_callable) + CustomMiddleware( + app_name=self.name, + func=middleware_or_callable, + base_logger=self._base_logger, + ) ) return middleware_or_callable else: @@ -2173,9 +2264,10 @@

    Args

    edit=edit, save=save, execute=execute, + base_logger=self._base_logger, ) elif isinstance(step, WorkflowStepBuilder): - step = step.build() + step = step.build(base_logger=self._base_logger) elif not isinstance(step, WorkflowStep): raise BoltError(f"Invalid step object ({type(step)})") @@ -2220,7 +2312,9 @@

    Args

    def event( self, event: Union[ - str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]] + str, + Pattern, + Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]], ], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, @@ -2253,7 +2347,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.event(event) + primary_matcher = builtin_matchers.event( + event, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True ) @@ -2308,7 +2404,7 @@

    Args

    ), } primary_matcher = builtin_matchers.message_event( - keyword=keyword, constraints=constraints + keyword=keyword, constraints=constraints, base_logger=self._base_logger ) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener( @@ -2353,7 +2449,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.command(command) + primary_matcher = builtin_matchers.command( + command, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2402,7 +2500,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.shortcut(constraints) + primary_matcher = builtin_matchers.shortcut( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2419,7 +2519,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.global_shortcut(callback_id) + primary_matcher = builtin_matchers.global_shortcut( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2436,7 +2538,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.message_shortcut(callback_id) + primary_matcher = builtin_matchers.message_shortcut( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2478,7 +2582,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.action(constraints) + primary_matcher = builtin_matchers.action( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2497,7 +2603,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_action(constraints) + primary_matcher = builtin_matchers.block_action( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2515,7 +2623,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.attachment_action(callback_id) + primary_matcher = builtin_matchers.attachment_action( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2533,7 +2643,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_submission(callback_id) + primary_matcher = builtin_matchers.dialog_submission( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2551,7 +2663,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_cancellation(callback_id) + primary_matcher = builtin_matchers.dialog_cancellation( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2604,7 +2718,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view(constraints) + primary_matcher = builtin_matchers.view( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2622,7 +2738,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_submission(constraints) + primary_matcher = builtin_matchers.view_submission( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2640,7 +2758,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_closed(constraints) + primary_matcher = builtin_matchers.view_closed( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2693,7 +2813,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.options(constraints) + primary_matcher = builtin_matchers.options( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2710,7 +2832,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_suggestion(action_id) + primary_matcher = builtin_matchers.block_suggestion( + action_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2728,7 +2852,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_suggestion(callback_id) + primary_matcher = builtin_matchers.dialog_suggestion( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2759,7 +2885,9 @@

    Args

    # ------------------------- def _init_context(self, req: BoltRequest): - req.context["logger"] = get_bolt_app_logger(self.name) + req.context["logger"] = get_bolt_app_logger( + app_name=self.name, base_logger=self._base_logger + ) req.context["token"] = self._token if self._token is not None: # This WebClient instance can be safely singleton @@ -2805,7 +2933,10 @@

    Args

    value_to_return = functions[0] listener_matchers = [ - CustomListenerMatcher(app_name=self.name, func=f) for f in (matchers or []) + CustomListenerMatcher( + app_name=self.name, func=f, base_logger=self._base_logger + ) + for f in (matchers or []) ] listener_matchers.insert(0, primary_matcher) listener_middleware = [] @@ -2813,7 +2944,11 @@

    Args

    if isinstance(m, Middleware): listener_middleware.append(m) elif isinstance(m, Callable): - listener_middleware.append(CustomMiddleware(app_name=self.name, func=m)) + listener_middleware.append( + CustomMiddleware( + app_name=self.name, func=m, base_logger=self._base_logger + ) + ) else: raise ValueError(error_unexpected_listener_middleware(type(m))) @@ -2825,6 +2960,7 @@

    Args

    matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + base_logger=self._base_logger, ) ) return value_to_return
    @@ -2990,7 +3126,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.action(constraints) + primary_matcher = builtin_matchers.action( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3019,7 +3157,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.attachment_action(callback_id) + primary_matcher = builtin_matchers.attachment_action( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3049,7 +3189,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_action(constraints) + primary_matcher = builtin_matchers.block_action( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3076,7 +3218,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_suggestion(action_id) + primary_matcher = builtin_matchers.block_suggestion( + action_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3150,7 +3294,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.command(command) + primary_matcher = builtin_matchers.command( + command, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3213,7 +3359,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_cancellation(callback_id) + primary_matcher = builtin_matchers.dialog_cancellation( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3242,7 +3390,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_submission(callback_id) + primary_matcher = builtin_matchers.dialog_submission( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3271,7 +3421,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_suggestion(callback_id) + primary_matcher = builtin_matchers.dialog_suggestion( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3479,7 +3631,7 @@

    Args

    -def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, ForwardRef(None)]]]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, ForwardRef(None)]], ForwardRef(None)]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new event listener. This method can be used as either a decorator or a method.

    @@ -3515,7 +3667,9 @@

    Args

    def event(
         self,
         event: Union[
    -        str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]]
    +        str,
    +        Pattern,
    +        Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
         ],
         matchers: Optional[Sequence[Callable[..., bool]]] = None,
         middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    @@ -3548,7 +3702,9 @@ 

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.event(event) + primary_matcher = builtin_matchers.event( + event, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True ) @@ -3575,7 +3731,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.global_shortcut(callback_id) + primary_matcher = builtin_matchers.global_shortcut( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3663,7 +3821,7 @@

    Args

    ), } primary_matcher = builtin_matchers.message_event( - keyword=keyword, constraints=constraints + keyword=keyword, constraints=constraints, base_logger=self._base_logger ) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener( @@ -3692,7 +3850,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.message_shortcut(callback_id) + primary_matcher = builtin_matchers.message_shortcut( + callback_id, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3753,7 +3913,11 @@

    Args

    self._middleware_list.append(middleware) elif isinstance(middleware_or_callable, Callable): self._middleware_list.append( - CustomMiddleware(app_name=self.name, func=middleware_or_callable) + CustomMiddleware( + app_name=self.name, + func=middleware_or_callable, + base_logger=self._base_logger, + ) ) return middleware_or_callable else: @@ -3849,7 +4013,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.options(constraints) + primary_matcher = builtin_matchers.options( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3935,7 +4101,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.shortcut(constraints) + primary_matcher = builtin_matchers.shortcut( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -4084,9 +4252,10 @@

    Args

    edit=edit, save=save, execute=execute, + base_logger=self._base_logger, ) elif isinstance(step, WorkflowStepBuilder): - step = step.build() + step = step.build(base_logger=self._base_logger) elif not isinstance(step, WorkflowStep): raise BoltError(f"Invalid step object ({type(step)})") @@ -4196,7 +4365,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view(constraints) + primary_matcher = builtin_matchers.view( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -4225,7 +4396,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_closed(constraints) + primary_matcher = builtin_matchers.view_closed( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -4254,7 +4427,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_submission(constraints) + primary_matcher = builtin_matchers.view_submission( + constraints, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index 52fe24ac6..d22e917ec 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -222,6 +222,11 @@

    Module slack_bolt.app.async_app

    self._verification_token: Optional[str] = verification_token or os.environ.get( "SLACK_VERIFICATION_TOKEN", None ) + # If a logger is explicitly passed when initializing, the logger works as the base logger. + # The base logger's logging settings will be propagated to all the loggers created by bolt-python. + self._base_logger = logger + # The framework logger is supposed to be used for the internal logging. + # Also, it's accessible via `app.logger` as the app's singleton logger. self._framework_logger = logger or get_bolt_logger(AsyncApp) self._raise_error_for_unhandled_request = raise_error_for_unhandled_request @@ -404,31 +409,46 @@

    Module slack_bolt.app.async_app

    return if ssl_check_enabled is True: self._async_middleware_list.append( - AsyncSslCheck(verification_token=self._verification_token) + AsyncSslCheck( + verification_token=self._verification_token, + base_logger=self._base_logger, + ) ) if request_verification_enabled is True: self._async_middleware_list.append( - AsyncRequestVerification(self._signing_secret) + AsyncRequestVerification( + self._signing_secret, base_logger=self._base_logger + ) ) # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: - self._async_middleware_list.append(AsyncSingleTeamAuthorization()) + self._async_middleware_list.append( + AsyncSingleTeamAuthorization(base_logger=self._base_logger) + ) elif self._async_authorize is not None: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, base_logger=self._base_logger + ) ) else: raise BoltError(error_token_required()) else: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, base_logger=self._base_logger + ) ) if ignoring_self_events_enabled is True: - self._async_middleware_list.append(AsyncIgnoringSelfEvents()) + self._async_middleware_list.append( + AsyncIgnoringSelfEvents(base_logger=self._base_logger) + ) if url_verification_enabled is True: - self._async_middleware_list.append(AsyncUrlVerification()) + self._async_middleware_list.append( + AsyncUrlVerification(base_logger=self._base_logger) + ) self._init_middleware_list_done = True # ------------------------- @@ -690,7 +710,9 @@

    Module slack_bolt.app.async_app

    elif isinstance(middleware_or_callable, Callable): self._async_middleware_list.append( AsyncCustomMiddleware( - app_name=self.name, func=middleware_or_callable + app_name=self.name, + func=middleware_or_callable, + base_logger=self._base_logger, ) ) return middleware_or_callable @@ -758,9 +780,10 @@

    Module slack_bolt.app.async_app

    edit=edit, save=save, execute=execute, + base_logger=self._base_logger, ) elif isinstance(step, AsyncWorkflowStepBuilder): - step = step.build() + step = step.build(base_logger=self._base_logger) elif not isinstance(step, AsyncWorkflowStep): raise BoltError(f"Invalid step object ({type(step)})") @@ -810,7 +833,9 @@

    Module slack_bolt.app.async_app

    def event( self, event: Union[ - str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]] + str, + Pattern, + Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]], ], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, @@ -843,7 +868,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.event(event, True) + primary_matcher = builtin_matchers.event( + event, True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True ) @@ -898,7 +925,10 @@

    Module slack_bolt.app.async_app

    ), } primary_matcher = builtin_matchers.message_event( - constraints=constraints, keyword=keyword, asyncio=True + constraints=constraints, + keyword=keyword, + asyncio=True, + base_logger=self._base_logger, ) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener( @@ -943,7 +973,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.command(command, True) + primary_matcher = builtin_matchers.command( + command, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -992,7 +1024,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.shortcut(constraints, True) + primary_matcher = builtin_matchers.shortcut( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1009,7 +1043,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.global_shortcut(callback_id, True) + primary_matcher = builtin_matchers.global_shortcut( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1026,7 +1062,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.message_shortcut(callback_id, True) + primary_matcher = builtin_matchers.message_shortcut( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1068,7 +1106,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.action(constraints, True) + primary_matcher = builtin_matchers.action( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1087,7 +1127,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_action(constraints, True) + primary_matcher = builtin_matchers.block_action( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1105,7 +1147,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.attachment_action(callback_id, True) + primary_matcher = builtin_matchers.attachment_action( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1123,7 +1167,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_submission(callback_id, True) + primary_matcher = builtin_matchers.dialog_submission( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1141,7 +1187,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_cancellation(callback_id, True) + primary_matcher = builtin_matchers.dialog_cancellation( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1194,7 +1242,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view(constraints, True) + primary_matcher = builtin_matchers.view( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1212,7 +1262,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_submission(constraints, True) + primary_matcher = builtin_matchers.view_submission( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1230,7 +1282,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_closed(constraints, True) + primary_matcher = builtin_matchers.view_closed( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1283,7 +1337,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.options(constraints, True) + primary_matcher = builtin_matchers.options( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1300,7 +1356,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_suggestion(action_id, True) + primary_matcher = builtin_matchers.block_suggestion( + action_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1318,7 +1376,9 @@

    Module slack_bolt.app.async_app

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_suggestion(callback_id, True) + primary_matcher = builtin_matchers.dialog_suggestion( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -1349,7 +1409,9 @@

    Module slack_bolt.app.async_app

    # ------------------------- def _init_context(self, req: AsyncBoltRequest): - req.context["logger"] = get_bolt_app_logger(self.name) + req.context["logger"] = get_bolt_app_logger( + app_name=self.name, base_logger=self._base_logger + ) req.context["token"] = self._token if self._token is not None: # This AsyncWebClient instance can be safely singleton @@ -1402,7 +1464,9 @@

    Module slack_bolt.app.async_app

    raise BoltError(error_listener_function_must_be_coro_func(name)) listener_matchers = [ - AsyncCustomListenerMatcher(app_name=self.name, func=f) + AsyncCustomListenerMatcher( + app_name=self.name, func=f, base_logger=self._base_logger + ) for f in (matchers or []) ] listener_matchers.insert(0, primary_matcher) @@ -1412,7 +1476,9 @@

    Module slack_bolt.app.async_app

    listener_middleware.append(m) elif isinstance(m, Callable) and inspect.iscoroutinefunction(m): listener_middleware.append( - AsyncCustomMiddleware(app_name=self.name, func=m) + AsyncCustomMiddleware( + app_name=self.name, func=m, base_logger=self._base_logger + ) ) else: raise ValueError(error_unexpected_listener_middleware(type(m))) @@ -1425,6 +1491,7 @@

    Module slack_bolt.app.async_app

    matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + base_logger=self._base_logger, ) ) @@ -1622,6 +1689,11 @@

    Args

    self._verification_token: Optional[str] = verification_token or os.environ.get( "SLACK_VERIFICATION_TOKEN", None ) + # If a logger is explicitly passed when initializing, the logger works as the base logger. + # The base logger's logging settings will be propagated to all the loggers created by bolt-python. + self._base_logger = logger + # The framework logger is supposed to be used for the internal logging. + # Also, it's accessible via `app.logger` as the app's singleton logger. self._framework_logger = logger or get_bolt_logger(AsyncApp) self._raise_error_for_unhandled_request = raise_error_for_unhandled_request @@ -1804,31 +1876,46 @@

    Args

    return if ssl_check_enabled is True: self._async_middleware_list.append( - AsyncSslCheck(verification_token=self._verification_token) + AsyncSslCheck( + verification_token=self._verification_token, + base_logger=self._base_logger, + ) ) if request_verification_enabled is True: self._async_middleware_list.append( - AsyncRequestVerification(self._signing_secret) + AsyncRequestVerification( + self._signing_secret, base_logger=self._base_logger + ) ) # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: - self._async_middleware_list.append(AsyncSingleTeamAuthorization()) + self._async_middleware_list.append( + AsyncSingleTeamAuthorization(base_logger=self._base_logger) + ) elif self._async_authorize is not None: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, base_logger=self._base_logger + ) ) else: raise BoltError(error_token_required()) else: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, base_logger=self._base_logger + ) ) if ignoring_self_events_enabled is True: - self._async_middleware_list.append(AsyncIgnoringSelfEvents()) + self._async_middleware_list.append( + AsyncIgnoringSelfEvents(base_logger=self._base_logger) + ) if url_verification_enabled is True: - self._async_middleware_list.append(AsyncUrlVerification()) + self._async_middleware_list.append( + AsyncUrlVerification(base_logger=self._base_logger) + ) self._init_middleware_list_done = True # ------------------------- @@ -2090,7 +2177,9 @@

    Args

    elif isinstance(middleware_or_callable, Callable): self._async_middleware_list.append( AsyncCustomMiddleware( - app_name=self.name, func=middleware_or_callable + app_name=self.name, + func=middleware_or_callable, + base_logger=self._base_logger, ) ) return middleware_or_callable @@ -2158,9 +2247,10 @@

    Args

    edit=edit, save=save, execute=execute, + base_logger=self._base_logger, ) elif isinstance(step, AsyncWorkflowStepBuilder): - step = step.build() + step = step.build(base_logger=self._base_logger) elif not isinstance(step, AsyncWorkflowStep): raise BoltError(f"Invalid step object ({type(step)})") @@ -2210,7 +2300,9 @@

    Args

    def event( self, event: Union[ - str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]] + str, + Pattern, + Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]], ], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, @@ -2243,7 +2335,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.event(event, True) + primary_matcher = builtin_matchers.event( + event, True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True ) @@ -2298,7 +2392,10 @@

    Args

    ), } primary_matcher = builtin_matchers.message_event( - constraints=constraints, keyword=keyword, asyncio=True + constraints=constraints, + keyword=keyword, + asyncio=True, + base_logger=self._base_logger, ) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener( @@ -2343,7 +2440,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.command(command, True) + primary_matcher = builtin_matchers.command( + command, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2392,7 +2491,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.shortcut(constraints, True) + primary_matcher = builtin_matchers.shortcut( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2409,7 +2510,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.global_shortcut(callback_id, True) + primary_matcher = builtin_matchers.global_shortcut( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2426,7 +2529,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.message_shortcut(callback_id, True) + primary_matcher = builtin_matchers.message_shortcut( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2468,7 +2573,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.action(constraints, True) + primary_matcher = builtin_matchers.action( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2487,7 +2594,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_action(constraints, True) + primary_matcher = builtin_matchers.block_action( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2505,7 +2614,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.attachment_action(callback_id, True) + primary_matcher = builtin_matchers.attachment_action( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2523,7 +2634,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_submission(callback_id, True) + primary_matcher = builtin_matchers.dialog_submission( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2541,7 +2654,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_cancellation(callback_id, True) + primary_matcher = builtin_matchers.dialog_cancellation( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2594,7 +2709,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view(constraints, True) + primary_matcher = builtin_matchers.view( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2612,7 +2729,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_submission(constraints, True) + primary_matcher = builtin_matchers.view_submission( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2630,7 +2749,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_closed(constraints, True) + primary_matcher = builtin_matchers.view_closed( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2683,7 +2804,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.options(constraints, True) + primary_matcher = builtin_matchers.options( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2700,7 +2823,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_suggestion(action_id, True) + primary_matcher = builtin_matchers.block_suggestion( + action_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2718,7 +2843,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_suggestion(callback_id, True) + primary_matcher = builtin_matchers.dialog_suggestion( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -2749,7 +2876,9 @@

    Args

    # ------------------------- def _init_context(self, req: AsyncBoltRequest): - req.context["logger"] = get_bolt_app_logger(self.name) + req.context["logger"] = get_bolt_app_logger( + app_name=self.name, base_logger=self._base_logger + ) req.context["token"] = self._token if self._token is not None: # This AsyncWebClient instance can be safely singleton @@ -2802,7 +2931,9 @@

    Args

    raise BoltError(error_listener_function_must_be_coro_func(name)) listener_matchers = [ - AsyncCustomListenerMatcher(app_name=self.name, func=f) + AsyncCustomListenerMatcher( + app_name=self.name, func=f, base_logger=self._base_logger + ) for f in (matchers or []) ] listener_matchers.insert(0, primary_matcher) @@ -2812,7 +2943,9 @@

    Args

    listener_middleware.append(m) elif isinstance(m, Callable) and inspect.iscoroutinefunction(m): listener_middleware.append( - AsyncCustomMiddleware(app_name=self.name, func=m) + AsyncCustomMiddleware( + app_name=self.name, func=m, base_logger=self._base_logger + ) ) else: raise ValueError(error_unexpected_listener_middleware(type(m))) @@ -2825,6 +2958,7 @@

    Args

    matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + base_logger=self._base_logger, ) ) @@ -2998,7 +3132,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.action(constraints, True) + primary_matcher = builtin_matchers.action( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3158,7 +3294,9 @@

    Returns

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.attachment_action(callback_id, True) + primary_matcher = builtin_matchers.attachment_action( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3188,7 +3326,9 @@

    Returns

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_action(constraints, True) + primary_matcher = builtin_matchers.block_action( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3215,7 +3355,9 @@

    Returns

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.block_suggestion(action_id, True) + primary_matcher = builtin_matchers.block_suggestion( + action_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3289,7 +3431,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.command(command, True) + primary_matcher = builtin_matchers.command( + command, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3352,7 +3496,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_cancellation(callback_id, True) + primary_matcher = builtin_matchers.dialog_cancellation( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3381,7 +3527,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_submission(callback_id, True) + primary_matcher = builtin_matchers.dialog_submission( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3410,7 +3558,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.dialog_suggestion(callback_id, True) + primary_matcher = builtin_matchers.dialog_suggestion( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3494,7 +3644,7 @@

    Args

    -def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, ForwardRef(None)]]]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, ForwardRef(None)]], ForwardRef(None)]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new event listener. This method can be used as either a decorator or a method.

    @@ -3530,7 +3680,9 @@

    Args

    def event(
         self,
         event: Union[
    -        str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]]
    +        str,
    +        Pattern,
    +        Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
         ],
         matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
         middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    @@ -3563,7 +3715,9 @@ 

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.event(event, True) + primary_matcher = builtin_matchers.event( + event, True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware, True ) @@ -3590,7 +3744,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.global_shortcut(callback_id, True) + primary_matcher = builtin_matchers.global_shortcut( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3678,7 +3834,10 @@

    Args

    ), } primary_matcher = builtin_matchers.message_event( - constraints=constraints, keyword=keyword, asyncio=True + constraints=constraints, + keyword=keyword, + asyncio=True, + base_logger=self._base_logger, ) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener( @@ -3707,7 +3866,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.message_shortcut(callback_id, True) + primary_matcher = builtin_matchers.message_shortcut( + callback_id, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3766,7 +3927,9 @@

    Args

    elif isinstance(middleware_or_callable, Callable): self._async_middleware_list.append( AsyncCustomMiddleware( - app_name=self.name, func=middleware_or_callable + app_name=self.name, + func=middleware_or_callable, + base_logger=self._base_logger, ) ) return middleware_or_callable @@ -3863,7 +4026,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.options(constraints, True) + primary_matcher = builtin_matchers.options( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -3996,7 +4161,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.shortcut(constraints, True) + primary_matcher = builtin_matchers.shortcut( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -4130,9 +4297,10 @@

    Args

    edit=edit, save=save, execute=execute, + base_logger=self._base_logger, ) elif isinstance(step, AsyncWorkflowStepBuilder): - step = step.build() + step = step.build(base_logger=self._base_logger) elif not isinstance(step, AsyncWorkflowStep): raise BoltError(f"Invalid step object ({type(step)})") @@ -4239,7 +4407,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view(constraints, True) + primary_matcher = builtin_matchers.view( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -4268,7 +4438,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_closed(constraints, True) + primary_matcher = builtin_matchers.view_closed( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) @@ -4297,7 +4469,9 @@

    Args

    def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.view_submission(constraints, True) + primary_matcher = builtin_matchers.view_submission( + constraints, asyncio=True, base_logger=self._base_logger + ) return self._register_listener( list(functions), primary_matcher, matchers, middleware ) diff --git a/docs/api-docs/slack_bolt/listener/async_listener.html b/docs/api-docs/slack_bolt/listener/async_listener.html index b36ef4c5e..12af0c553 100644 --- a/docs/api-docs/slack_bolt/listener/async_listener.html +++ b/docs/api-docs/slack_bolt/listener/async_listener.html @@ -129,6 +129,7 @@

    Module slack_bolt.listener.async_listener

    matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False, + base_logger: Optional[Logger] = None, ): self.app_name = app_name self.ack_function = ack_function @@ -137,7 +138,7 @@

    Module slack_bolt.listener.async_listener

    self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement self.arg_names = inspect.getfullargspec(ack_function).args - self.logger = get_bolt_app_logger(app_name, self.ack_function) + self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) async def run_ack_function( self, @@ -174,7 +175,7 @@

    Classes

    class AsyncCustomListener -(*, app_name: str, ack_function: Callable[..., Awaitable[Optional[BoltResponse]]], lazy_functions: Sequence[Callable[..., Awaitable[None]]], matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False) +(*, app_name: str, ack_function: Callable[..., Awaitable[Optional[BoltResponse]]], lazy_functions: Sequence[Callable[..., Awaitable[None]]], matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False, base_logger: Optional[logging.Logger] = None)
    @@ -201,6 +202,7 @@

    Classes

    matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False, + base_logger: Optional[Logger] = None, ): self.app_name = app_name self.ack_function = ack_function @@ -209,7 +211,7 @@

    Classes

    self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement self.arg_names = inspect.getfullargspec(ack_function).args - self.logger = get_bolt_app_logger(app_name, self.ack_function) + self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) async def run_ack_function( self, @@ -307,7 +309,7 @@

    Returns

    class cls -(*, app_name: str, ack_function: Callable[..., Awaitable[Optional[BoltResponse]]], lazy_functions: Sequence[Callable[..., Awaitable[None]]], matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False) +(*, app_name: str, ack_function: Callable[..., Awaitable[Optional[BoltResponse]]], lazy_functions: Sequence[Callable[..., Awaitable[None]]], matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False, base_logger: Optional[logging.Logger] = None)
    @@ -334,6 +336,7 @@

    Returns

    matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False, + base_logger: Optional[Logger] = None, ): self.app_name = app_name self.ack_function = ack_function @@ -342,7 +345,7 @@

    Returns

    self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement self.arg_names = inspect.getfullargspec(ack_function).args - self.logger = get_bolt_app_logger(app_name, self.ack_function) + self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) async def run_ack_function( self, diff --git a/docs/api-docs/slack_bolt/listener/custom_listener.html b/docs/api-docs/slack_bolt/listener/custom_listener.html index a0fc17138..b5189b432 100644 --- a/docs/api-docs/slack_bolt/listener/custom_listener.html +++ b/docs/api-docs/slack_bolt/listener/custom_listener.html @@ -58,6 +58,7 @@

    Module slack_bolt.listener.custom_listener

    matchers: Sequence[ListenerMatcher], middleware: Sequence[Middleware], # type: ignore auto_acknowledgement: bool = False, + base_logger: Optional[Logger] = None, ): self.app_name = app_name self.ack_function = ack_function @@ -66,7 +67,7 @@

    Module slack_bolt.listener.custom_listener

    self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement self.arg_names = inspect.getfullargspec(ack_function).args - self.logger = get_bolt_app_logger(app_name, self.ack_function) + self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) def run_ack_function( self, @@ -96,7 +97,7 @@

    Classes

    class CustomListener -(*, app_name: str, ack_function: Callable[..., Optional[BoltResponse]], lazy_functions: Sequence[Callable[..., None]], matchers: Sequence[ListenerMatcher], middleware: Sequence[Middleware], auto_acknowledgement: bool = False) +(*, app_name: str, ack_function: Callable[..., Optional[BoltResponse]], lazy_functions: Sequence[Callable[..., None]], matchers: Sequence[ListenerMatcher], middleware: Sequence[Middleware], auto_acknowledgement: bool = False, base_logger: Optional[logging.Logger] = None)
    @@ -123,6 +124,7 @@

    Classes

    matchers: Sequence[ListenerMatcher], middleware: Sequence[Middleware], # type: ignore auto_acknowledgement: bool = False, + base_logger: Optional[Logger] = None, ): self.app_name = app_name self.ack_function = ack_function @@ -131,7 +133,7 @@

    Classes

    self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement self.arg_names = inspect.getfullargspec(ack_function).args - self.logger = get_bolt_app_logger(app_name, self.ack_function) + self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) def run_ack_function( self, diff --git a/docs/api-docs/slack_bolt/listener_matcher/async_builtins.html b/docs/api-docs/slack_bolt/listener_matcher/async_builtins.html index 2e6d19576..2bf7a8a39 100644 --- a/docs/api-docs/slack_bolt/listener_matcher/async_builtins.html +++ b/docs/api-docs/slack_bolt/listener_matcher/async_builtins.html @@ -58,7 +58,7 @@

    Classes

    class AsyncBuiltinListenerMatcher -(*, func: Callable[..., Union[bool, Awaitable[bool]]]) +(*, func: Callable[..., Union[bool, Awaitable[bool]]], base_logger: Optional[logging.Logger] = None)
    diff --git a/docs/api-docs/slack_bolt/listener_matcher/async_listener_matcher.html b/docs/api-docs/slack_bolt/listener_matcher/async_listener_matcher.html index 3287a9b40..95a06c74d 100644 --- a/docs/api-docs/slack_bolt/listener_matcher/async_listener_matcher.html +++ b/docs/api-docs/slack_bolt/listener_matcher/async_listener_matcher.html @@ -49,7 +49,7 @@

    Module slack_bolt.listener_matcher.async_listener_matche import inspect from logging import Logger -from typing import Callable, Awaitable, Sequence +from typing import Callable, Awaitable, Sequence, Optional from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs from slack_bolt.logger import get_bolt_app_logger @@ -63,11 +63,17 @@

    Module slack_bolt.listener_matcher.async_listener_matche arg_names: Sequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable[..., Awaitable[bool]]): + def __init__( + self, + *, + app_name: str, + func: Callable[..., Awaitable[bool]], + base_logger: Optional[Logger] = None + ): self.app_name = app_name self.func = func self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool: return await self.func( @@ -99,7 +105,7 @@

    Classes

    class AsyncCustomListenerMatcher -(*, app_name: str, func: Callable[..., Awaitable[bool]]) +(*, app_name: str, func: Callable[..., Awaitable[bool]], base_logger: Optional[logging.Logger] = None)
    @@ -113,11 +119,17 @@

    Classes

    arg_names: Sequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable[..., Awaitable[bool]]): + def __init__( + self, + *, + app_name: str, + func: Callable[..., Awaitable[bool]], + base_logger: Optional[Logger] = None + ): self.app_name = app_name self.func = func self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool: return await self.func( @@ -189,7 +201,7 @@

    Returns

    class cls -(*, app_name: str, func: Callable[..., Awaitable[bool]]) +(*, app_name: str, func: Callable[..., Awaitable[bool]], base_logger: Optional[logging.Logger] = None)
    @@ -203,11 +215,17 @@

    Returns

    arg_names: Sequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable[..., Awaitable[bool]]): + def __init__( + self, + *, + app_name: str, + func: Callable[..., Awaitable[bool]], + base_logger: Optional[Logger] = None + ): self.app_name = app_name self.func = func self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool: return await self.func( diff --git a/docs/api-docs/slack_bolt/listener_matcher/builtins.html b/docs/api-docs/slack_bolt/listener_matcher/builtins.html index f03e45470..e55a7e489 100644 --- a/docs/api-docs/slack_bolt/listener_matcher/builtins.html +++ b/docs/api-docs/slack_bolt/listener_matcher/builtins.html @@ -30,6 +30,7 @@

    Module slack_bolt.listener_matcher.builtins

    import inspect import re import sys +from logging import Logger from slack_bolt.error import BoltError from slack_bolt.request.payload_utils import ( @@ -68,10 +69,15 @@

    Module slack_bolt.listener_matcher.builtins

    # a.k.a Union[ListenerMatcher, "AsyncListenerMatcher"] class BuiltinListenerMatcher(ListenerMatcher): - def __init__(self, *, func: Callable[..., Union[bool, Awaitable[bool]]]): + def __init__( + self, + *, + func: Callable[..., Union[bool, Awaitable[bool]]], + base_logger: Optional[Logger] = None, + ): self.func = func self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_logger(self.func) + self.logger = get_bolt_logger(self.func, base_logger) def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: return self.func( @@ -88,6 +94,7 @@

    Module slack_bolt.listener_matcher.builtins

    def build_listener_matcher( func: Callable[..., bool], asyncio: bool, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if asyncio: from .async_builtins import AsyncBuiltinListenerMatcher @@ -95,9 +102,9 @@

    Module slack_bolt.listener_matcher.builtins

    async def async_fun(body: Dict[str, Any]) -> bool: return func(body) - return AsyncBuiltinListenerMatcher(func=async_fun) + return AsyncBuiltinListenerMatcher(func=async_fun, base_logger=base_logger) else: - return BuiltinListenerMatcher(func=func) + return BuiltinListenerMatcher(func=func, base_logger=base_logger) # ------------- @@ -106,9 +113,12 @@

    Module slack_bolt.listener_matcher.builtins

    def event( constraints: Union[ - str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]] + str, + Pattern, + Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]], ], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): event_type: Union[str, Pattern] = constraints @@ -117,7 +127,7 @@

    Module slack_bolt.listener_matcher.builtins

    def func(body: Dict[str, Any]) -> bool: return is_event(body) and _matches(event_type, body["event"]["type"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) elif "type" in constraints: _verify_message_event_type(constraints["type"]) @@ -130,7 +140,7 @@

    Module slack_bolt.listener_matcher.builtins

    ) return False - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) raise BoltError( f"event ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict" @@ -138,9 +148,12 @@

    Module slack_bolt.listener_matcher.builtins

    def message_event( - constraints: Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]], + constraints: Dict[ + str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]] + ], keyword: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if "type" in constraints and keyword is not None: _verify_message_event_type(constraints["type"]) @@ -159,7 +172,7 @@

    Module slack_bolt.listener_matcher.builtins

    return True return False - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) raise BoltError(f"event ({constraints}: {type(constraints)}) must be dict") @@ -168,8 +181,8 @@

    Module slack_bolt.listener_matcher.builtins

    if not _matches(constraints["type"], event_payload["type"]): return False if "subtype" in constraints: - expected_subtype: Union[ - str, Sequence[Optional[Union[str, Pattern]]] + expected_subtype: Optional[ + Union[str, Sequence[Optional[Union[str, Pattern]]]] ] = constraints["subtype"] if expected_subtype is None: # "subtype" in constraints is intentionally None for this pattern @@ -205,6 +218,7 @@

    Module slack_bolt.listener_matcher.builtins

    def workflow_step_execute( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return ( @@ -214,7 +228,7 @@

    Module slack_bolt.listener_matcher.builtins

    and _matches(callback_id, body["event"]["callback_id"]) ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -224,11 +238,12 @@

    Module slack_bolt.listener_matcher.builtins

    def command( command: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_slash_command(body) and _matches(command, body["command"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -238,6 +253,7 @@

    Module slack_bolt.listener_matcher.builtins

    def shortcut( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): callback_id: Union[str, Pattern] = constraints @@ -245,7 +261,7 @@

    Module slack_bolt.listener_matcher.builtins

    def func(body: Dict[str, Any]) -> bool: return is_shortcut(body) and _matches(callback_id, body["callback_id"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) elif "type" in constraints and "callback_id" in constraints: if constraints["type"] == "shortcut": @@ -261,21 +277,23 @@

    Module slack_bolt.listener_matcher.builtins

    def global_shortcut( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_global_shortcut(body) and _matches(callback_id, body["callback_id"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def message_shortcut( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_message_shortcut(body) and _matches(callback_id, body["callback_id"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -285,6 +303,7 @@

    Module slack_bolt.listener_matcher.builtins

    def action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): @@ -297,7 +316,7 @@

    Module slack_bolt.listener_matcher.builtins

    or _workflow_step_edit(constraints, body) ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) elif "type" in constraints: action_type = constraints["type"] @@ -347,11 +366,12 @@

    Module slack_bolt.listener_matcher.builtins

    def block_action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _block_action(constraints, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def _attachment_action( @@ -364,11 +384,12 @@

    Module slack_bolt.listener_matcher.builtins

    def attachment_action( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _attachment_action(callback_id, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def _dialog_submission( @@ -381,11 +402,12 @@

    Module slack_bolt.listener_matcher.builtins

    def dialog_submission( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _dialog_submission(callback_id, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def _dialog_cancellation( @@ -398,11 +420,12 @@

    Module slack_bolt.listener_matcher.builtins

    def dialog_cancellation( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _dialog_cancellation(callback_id, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def _workflow_step_edit( @@ -415,11 +438,12 @@

    Module slack_bolt.listener_matcher.builtins

    def workflow_step_edit( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _workflow_step_edit(callback_id, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------------------- @@ -429,6 +453,7 @@

    Module slack_bolt.listener_matcher.builtins

    def view( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): return view_submission(constraints, asyncio) @@ -446,37 +471,40 @@

    Module slack_bolt.listener_matcher.builtins

    def view_submission( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_view_submission(body) and _matches( callback_id, body["view"]["callback_id"] ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def view_closed( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_view_closed(body) and _matches( callback_id, body["view"]["callback_id"] ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def workflow_step_save( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return is_workflow_step_save(body) and _matches( callback_id, body["view"]["callback_id"] ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -486,6 +514,7 @@

    Module slack_bolt.listener_matcher.builtins

    def options( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: if isinstance(constraints, (str, Pattern)): @@ -494,7 +523,7 @@

    Module slack_bolt.listener_matcher.builtins

    constraints, body ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) if "action_id" in constraints: return block_suggestion(constraints["action_id"], asyncio) @@ -516,11 +545,12 @@

    Module slack_bolt.listener_matcher.builtins

    def block_suggestion( action_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _block_suggestion(action_id, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def _dialog_suggestion( @@ -533,11 +563,12 @@

    Module slack_bolt.listener_matcher.builtins

    def dialog_suggestion( callback_id: Union[str, Pattern], asyncio: bool = False, + base_logger: Optional[Logger] = None, ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: def func(body: Dict[str, Any]) -> bool: return _dialog_suggestion(callback_id, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------------------- @@ -567,7 +598,7 @@

    Module slack_bolt.listener_matcher.builtins

    Functions

    -def action(constraints: Union[str, re.Pattern, Dict[str, Union[str, re.Pattern]]], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def action(constraints: Union[str, re.Pattern, Dict[str, Union[str, re.Pattern]]], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -578,6 +609,7 @@

    Functions

    def action(
         constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         if isinstance(constraints, (str, Pattern)):
     
    @@ -590,7 +622,7 @@ 

    Functions

    or _workflow_step_edit(constraints, body) ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) elif "type" in constraints: action_type = constraints["type"] @@ -617,7 +649,7 @@

    Functions

    -def attachment_action(callback_id: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def attachment_action(callback_id: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -628,15 +660,16 @@

    Functions

    def attachment_action(
         callback_id: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return _attachment_action(callback_id, body)
     
    -    return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)

    -def block_action(constraints: Union[str, re.Pattern, Dict[str, Union[str, re.Pattern]]], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def block_action(constraints: Union[str, re.Pattern, Dict[str, Union[str, re.Pattern]]], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -647,15 +680,16 @@

    Functions

    def block_action(
         constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return _block_action(constraints, body)
     
    -    return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)
    -def block_suggestion(action_id: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def block_suggestion(action_id: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -666,15 +700,16 @@

    Functions

    def block_suggestion(
         action_id: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return _block_suggestion(action_id, body)
     
    -    return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)
    -def build_listener_matcher(func: Callable[..., bool], asyncio: bool) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def build_listener_matcher(func: Callable[..., bool], asyncio: bool, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -685,6 +720,7 @@

    Functions

    def build_listener_matcher(
         func: Callable[..., bool],
         asyncio: bool,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         if asyncio:
             from .async_builtins import AsyncBuiltinListenerMatcher
    @@ -692,13 +728,13 @@ 

    Functions

    async def async_fun(body: Dict[str, Any]) -> bool: return func(body) - return AsyncBuiltinListenerMatcher(func=async_fun) + return AsyncBuiltinListenerMatcher(func=async_fun, base_logger=base_logger) else: - return BuiltinListenerMatcher(func=func)
    + return BuiltinListenerMatcher(func=func, base_logger=base_logger)
    -def command(command: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def command(command: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -709,15 +745,16 @@

    Functions

    def command(
         command: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return is_slash_command(body) and _matches(command, body["command"])
     
    -    return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)
    -def dialog_cancellation(callback_id: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def dialog_cancellation(callback_id: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -728,15 +765,16 @@

    Functions

    def dialog_cancellation(
         callback_id: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return _dialog_cancellation(callback_id, body)
     
    -    return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)
    -def dialog_submission(callback_id: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def dialog_submission(callback_id: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -747,15 +785,16 @@

    Functions

    def dialog_submission(
         callback_id: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return _dialog_submission(callback_id, body)
     
    -    return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)
    -def dialog_suggestion(callback_id: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def dialog_suggestion(callback_id: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -766,15 +805,16 @@

    Functions

    def dialog_suggestion(
         callback_id: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return _dialog_suggestion(callback_id, body)
     
    -    return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)
    -def event(constraints: Union[str, re.Pattern, Dict[str, Union[str, Sequence[Union[str, re.Pattern, ForwardRef(None)]]]]], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def event(constraints: Union[str, re.Pattern, Dict[str, Union[str, Sequence[Union[str, re.Pattern, ForwardRef(None)]], ForwardRef(None)]]], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -784,9 +824,12 @@

    Functions

    def event(
         constraints: Union[
    -        str, Pattern, Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]]
    +        str,
    +        Pattern,
    +        Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
         ],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         if isinstance(constraints, (str, Pattern)):
             event_type: Union[str, Pattern] = constraints
    @@ -795,7 +838,7 @@ 

    Functions

    def func(body: Dict[str, Any]) -> bool: return is_event(body) and _matches(event_type, body["event"]["type"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) elif "type" in constraints: _verify_message_event_type(constraints["type"]) @@ -808,7 +851,7 @@

    Functions

    ) return False - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) raise BoltError( f"event ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict" @@ -816,7 +859,7 @@

    Functions

    -def global_shortcut(callback_id: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def global_shortcut(callback_id: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -827,15 +870,16 @@

    Functions

    def global_shortcut(
         callback_id: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return is_global_shortcut(body) and _matches(callback_id, body["callback_id"])
     
    -    return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)
    -def message_event(constraints: Dict[str, Union[str, Sequence[Union[str, re.Pattern, ForwardRef(None)]]]], keyword: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def message_event(constraints: Dict[str, Union[str, Sequence[Union[str, re.Pattern, ForwardRef(None)]], ForwardRef(None)]], keyword: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -844,9 +888,12 @@

    Functions

    Expand source code
    def message_event(
    -    constraints: Dict[str, Union[str, Sequence[Optional[Union[str, Pattern]]]]],
    +    constraints: Dict[
    +        str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]
    +    ],
         keyword: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         if "type" in constraints and keyword is not None:
             _verify_message_event_type(constraints["type"])
    @@ -865,13 +912,13 @@ 

    Functions

    return True return False - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) raise BoltError(f"event ({constraints}: {type(constraints)}) must be dict")
    -def message_shortcut(callback_id: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def message_shortcut(callback_id: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -882,15 +929,16 @@

    Functions

    def message_shortcut(
         callback_id: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return is_message_shortcut(body) and _matches(callback_id, body["callback_id"])
     
    -    return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)
    -def options(constraints: Union[str, re.Pattern, Dict[str, Union[str, re.Pattern]]], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def options(constraints: Union[str, re.Pattern, Dict[str, Union[str, re.Pattern]]], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -901,6 +949,7 @@

    Functions

    def options(
         constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         if isinstance(constraints, (str, Pattern)):
     
    @@ -909,7 +958,7 @@ 

    Functions

    constraints, body ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) if "action_id" in constraints: return block_suggestion(constraints["action_id"], asyncio) @@ -922,7 +971,7 @@

    Functions

    -def shortcut(constraints: Union[str, re.Pattern, Dict[str, Union[str, re.Pattern]]], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def shortcut(constraints: Union[str, re.Pattern, Dict[str, Union[str, re.Pattern]]], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -933,6 +982,7 @@

    Functions

    def shortcut(
         constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         if isinstance(constraints, (str, Pattern)):
             callback_id: Union[str, Pattern] = constraints
    @@ -940,7 +990,7 @@ 

    Functions

    def func(body: Dict[str, Any]) -> bool: return is_shortcut(body) and _matches(callback_id, body["callback_id"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) elif "type" in constraints and "callback_id" in constraints: if constraints["type"] == "shortcut": @@ -954,7 +1004,7 @@

    Functions

    -def view(constraints: Union[str, re.Pattern, Dict[str, Union[str, re.Pattern]]], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def view(constraints: Union[str, re.Pattern, Dict[str, Union[str, re.Pattern]]], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -965,6 +1015,7 @@

    Functions

    def view(
         constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         if isinstance(constraints, (str, Pattern)):
             return view_submission(constraints, asyncio)
    @@ -980,7 +1031,7 @@ 

    Functions

    -def view_closed(callback_id: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def view_closed(callback_id: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -991,17 +1042,18 @@

    Functions

    def view_closed(
         callback_id: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return is_view_closed(body) and _matches(
                 callback_id, body["view"]["callback_id"]
             )
     
    -    return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)
    -def view_submission(callback_id: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def view_submission(callback_id: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -1012,17 +1064,18 @@

    Functions

    def view_submission(
         callback_id: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return is_view_submission(body) and _matches(
                 callback_id, body["view"]["callback_id"]
             )
     
    -    return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)
    -def workflow_step_edit(callback_id: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def workflow_step_edit(callback_id: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -1033,15 +1086,16 @@

    Functions

    def workflow_step_edit(
         callback_id: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return _workflow_step_edit(callback_id, body)
     
    -    return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)
    -def workflow_step_execute(callback_id: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def workflow_step_execute(callback_id: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -1052,6 +1106,7 @@

    Functions

    def workflow_step_execute(
         callback_id: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return (
    @@ -1061,11 +1116,11 @@ 

    Functions

    and _matches(callback_id, body["event"]["callback_id"]) ) - return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)
    -def workflow_step_save(callback_id: Union[str, re.Pattern], asyncio: bool = False) ‑> Union[ListenerMatcher, AsyncListenerMatcher] +def workflow_step_save(callback_id: Union[str, re.Pattern], asyncio: bool = False, base_logger: Optional[logging.Logger] = None) ‑> Union[ListenerMatcher, AsyncListenerMatcher]
    @@ -1076,13 +1131,14 @@

    Functions

    def workflow_step_save(
         callback_id: Union[str, Pattern],
         asyncio: bool = False,
    +    base_logger: Optional[Logger] = None,
     ) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:
         def func(body: Dict[str, Any]) -> bool:
             return is_workflow_step_save(body) and _matches(
                 callback_id, body["view"]["callback_id"]
             )
     
    -    return build_listener_matcher(func, asyncio)
    + return build_listener_matcher(func, asyncio, base_logger)
    @@ -1092,7 +1148,7 @@

    Classes

    class BuiltinListenerMatcher -(*, func: Callable[..., Union[bool, Awaitable[bool]]]) +(*, func: Callable[..., Union[bool, Awaitable[bool]]], base_logger: Optional[logging.Logger] = None)
    @@ -1101,10 +1157,15 @@

    Classes

    Expand source code
    class BuiltinListenerMatcher(ListenerMatcher):
    -    def __init__(self, *, func: Callable[..., Union[bool, Awaitable[bool]]]):
    +    def __init__(
    +        self,
    +        *,
    +        func: Callable[..., Union[bool, Awaitable[bool]]],
    +        base_logger: Optional[Logger] = None,
    +    ):
             self.func = func
             self.arg_names = inspect.getfullargspec(func).args
    -        self.logger = get_bolt_logger(self.func)
    +        self.logger = get_bolt_logger(self.func, base_logger)
     
         def matches(self, req: BoltRequest, resp: BoltResponse) -> bool:
             return self.func(
    diff --git a/docs/api-docs/slack_bolt/listener_matcher/custom_listener_matcher.html b/docs/api-docs/slack_bolt/listener_matcher/custom_listener_matcher.html
    index e9c8b0d5f..817c4ec64 100644
    --- a/docs/api-docs/slack_bolt/listener_matcher/custom_listener_matcher.html
    +++ b/docs/api-docs/slack_bolt/listener_matcher/custom_listener_matcher.html
    @@ -28,7 +28,7 @@ 

    Module slack_bolt.listener_matcher.custom_listener_match
    import inspect
     from logging import Logger
    -from typing import Callable, Sequence
    +from typing import Callable, Sequence, Optional
     
     from slack_bolt.kwargs_injection import build_required_kwargs
     from slack_bolt.logger import get_bolt_app_logger
    @@ -43,11 +43,17 @@ 

    Module slack_bolt.listener_matcher.custom_listener_match arg_names: Sequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable[..., bool]): + def __init__( + self, + *, + app_name: str, + func: Callable[..., bool], + base_logger: Optional[Logger] = None + ): self.app_name = app_name self.func = func self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: return self.func( @@ -72,7 +78,7 @@

    Classes

    class CustomListenerMatcher -(*, app_name: str, func: Callable[..., bool]) +(*, app_name: str, func: Callable[..., bool], base_logger: Optional[logging.Logger] = None)
    @@ -86,11 +92,17 @@

    Classes

    arg_names: Sequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable[..., bool]): + def __init__( + self, + *, + app_name: str, + func: Callable[..., bool], + base_logger: Optional[Logger] = None + ): self.app_name = app_name self.func = func self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: return self.func( diff --git a/docs/api-docs/slack_bolt/logger/index.html b/docs/api-docs/slack_bolt/logger/index.html index 42ad13c85..911a0a32a 100644 --- a/docs/api-docs/slack_bolt/logger/index.html +++ b/docs/api-docs/slack_bolt/logger/index.html @@ -31,27 +31,48 @@

    Module slack_bolt.logger

    import logging from logging import Logger -from typing import Any +from typing import Any, Optional -def get_bolt_logger(cls: Any) -> Logger: +def get_bolt_logger(cls: Any, base_logger: Optional[Logger] = None) -> Logger: logger = logging.getLogger(f"slack_bolt.{cls.__name__}") - logger.disabled = logging.root.disabled - logger.level = logging.root.level + if base_logger is not None: + _configure_from_base_logger(logger, base_logger) + else: + _configure_from_root(logger) return logger -def get_bolt_app_logger(app_name: str, cls: object = None) -> Logger: - if cls and hasattr(cls, "__name__"): - logger = logging.getLogger(f"{app_name}:{cls.__name__}") - logger.disabled = logging.root.disabled - logger.level = logging.root.level - return logger +def get_bolt_app_logger( + app_name: str, cls: object = None, base_logger: Optional[Logger] = None +) -> Logger: + logger: Logger = ( + logging.getLogger(f"{app_name}:{cls.__name__}") + if cls and hasattr(cls, "__name__") + else logging.getLogger(app_name) + ) + + if base_logger is not None: + _configure_from_base_logger(logger, base_logger) else: - logger = logging.getLogger(app_name) - logger.disabled = logging.root.disabled - logger.level = logging.root.level - return logger

    + _configure_from_root(logger) + return logger + + +def _configure_from_base_logger(new_logger: Logger, base_logger: Logger): + new_logger.disabled = base_logger.disabled + new_logger.level = base_logger.level + if len(new_logger.handlers) == 0: + for h in base_logger.handlers: + new_logger.addHandler(h) + if len(new_logger.filters) == 0: + for f in base_logger.filters: + new_logger.addFilter(f) + + +def _configure_from_root(new_logger: Logger): + new_logger.disabled = logging.root.disabled + new_logger.level = logging.root.level

    @@ -69,7 +90,7 @@

    Sub-modules

    Functions

    -def get_bolt_app_logger(app_name: str, cls: object = None) ‑> logging.Logger +def get_bolt_app_logger(app_name: str, cls: object = None, base_logger: Optional[logging.Logger] = None) ‑> logging.Logger
    @@ -77,21 +98,24 @@

    Functions

    Expand source code -
    def get_bolt_app_logger(app_name: str, cls: object = None) -> Logger:
    -    if cls and hasattr(cls, "__name__"):
    -        logger = logging.getLogger(f"{app_name}:{cls.__name__}")
    -        logger.disabled = logging.root.disabled
    -        logger.level = logging.root.level
    -        return logger
    +
    def get_bolt_app_logger(
    +    app_name: str, cls: object = None, base_logger: Optional[Logger] = None
    +) -> Logger:
    +    logger: Logger = (
    +        logging.getLogger(f"{app_name}:{cls.__name__}")
    +        if cls and hasattr(cls, "__name__")
    +        else logging.getLogger(app_name)
    +    )
    +
    +    if base_logger is not None:
    +        _configure_from_base_logger(logger, base_logger)
         else:
    -        logger = logging.getLogger(app_name)
    -        logger.disabled = logging.root.disabled
    -        logger.level = logging.root.level
    -        return logger
    + _configure_from_root(logger) + return logger
    -def get_bolt_logger(cls: Any) ‑> logging.Logger +def get_bolt_logger(cls: Any, base_logger: Optional[logging.Logger] = None) ‑> logging.Logger
    @@ -99,10 +123,12 @@

    Functions

    Expand source code -
    def get_bolt_logger(cls: Any) -> Logger:
    +
    def get_bolt_logger(cls: Any, base_logger: Optional[Logger] = None) -> Logger:
         logger = logging.getLogger(f"slack_bolt.{cls.__name__}")
    -    logger.disabled = logging.root.disabled
    -    logger.level = logging.root.level
    +    if base_logger is not None:
    +        _configure_from_base_logger(logger, base_logger)
    +    else:
    +        _configure_from_root(logger)
         return logger
    diff --git a/docs/api-docs/slack_bolt/middleware/async_custom_middleware.html b/docs/api-docs/slack_bolt/middleware/async_custom_middleware.html index f4f454203..6f8247528 100644 --- a/docs/api-docs/slack_bolt/middleware/async_custom_middleware.html +++ b/docs/api-docs/slack_bolt/middleware/async_custom_middleware.html @@ -28,7 +28,7 @@

    Module slack_bolt.middleware.async_custom_middleware
    import inspect
     from logging import Logger
    -from typing import Callable, Awaitable, Any, Sequence
    +from typing import Callable, Awaitable, Any, Sequence, Optional
     
     from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs
     from slack_bolt.logger import get_bolt_app_logger
    @@ -44,7 +44,13 @@ 

    Module slack_bolt.middleware.async_custom_middlewareModule slack_bolt.middleware.async_custom_middlewareClasses

    class AsyncCustomMiddleware -(*, app_name: str, func: Callable[..., Awaitable[Any]]) +(*, app_name: str, func: Callable[..., Awaitable[Any]], base_logger: Optional[logging.Logger] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -105,7 +111,13 @@

    Classes

    arg_names: Sequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable[..., Awaitable[Any]]): + def __init__( + self, + *, + app_name: str, + func: Callable[..., Awaitable[Any]], + base_logger: Optional[Logger] = None, + ): self.app_name = app_name if inspect.iscoroutinefunction(func): self.func = func @@ -113,7 +125,7 @@

    Classes

    raise ValueError("Async middleware function must be an async function") self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) async def async_process( self, diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html index 5c6528140..f388ec84a 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html @@ -26,7 +26,8 @@

    Module slack_bolt.middleware.authorization.async_multi_t Expand source code -
    from typing import Callable, Optional, Awaitable
    +
    from logging import Logger
    +from typing import Callable, Optional, Awaitable
     
     from slack_sdk.errors import SlackApiError
     from slack_bolt.logger import get_bolt_logger
    @@ -42,14 +43,17 @@ 

    Module slack_bolt.middleware.authorization.async_multi_t class AsyncMultiTeamsAuthorization(AsyncAuthorization): authorize: AsyncAuthorize - def __init__(self, authorize: AsyncAuthorize): + def __init__(self, authorize: AsyncAuthorize, base_logger: Optional[Logger] = None): """Multi-workspace authorization. Args: authorize: The function to authorize incoming requests from Slack. + base_logger: The base logger """ self.authorize = authorize - self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization) + self.logger = get_bolt_logger( + AsyncMultiTeamsAuthorization, base_logger=base_logger + ) async def async_process( self, @@ -115,7 +119,7 @@

    Classes

    class AsyncMultiTeamsAuthorization -(authorize: AsyncAuthorize) +(authorize: AsyncAuthorize, base_logger: Optional[logging.Logger] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -124,6 +128,8 @@

    Args

    authorize
    The function to authorize incoming requests from Slack.
    +
    base_logger
    +
    The base logger
    @@ -132,14 +138,17 @@

    Args

    class AsyncMultiTeamsAuthorization(AsyncAuthorization):
         authorize: AsyncAuthorize
     
    -    def __init__(self, authorize: AsyncAuthorize):
    +    def __init__(self, authorize: AsyncAuthorize, base_logger: Optional[Logger] = None):
             """Multi-workspace authorization.
     
             Args:
                 authorize: The function to authorize incoming requests from Slack.
    +            base_logger: The base logger
             """
             self.authorize = authorize
    -        self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization)
    +        self.logger = get_bolt_logger(
    +            AsyncMultiTeamsAuthorization, base_logger=base_logger
    +        )
     
         async def async_process(
             self,
    diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html
    index 65fac44b0..89702d936 100644
    --- a/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html
    +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html
    @@ -26,7 +26,8 @@ 

    Module slack_bolt.middleware.authorization.async_single_ Expand source code -
    from typing import Callable, Awaitable, Optional
    +
    from logging import Logger
    +from typing import Callable, Awaitable, Optional
     
     from slack_bolt.logger import get_bolt_logger
     from slack_bolt.middleware.authorization.async_authorization import AsyncAuthorization
    @@ -40,10 +41,12 @@ 

    Module slack_bolt.middleware.authorization.async_single_ class AsyncSingleTeamAuthorization(AsyncAuthorization): - def __init__(self): + def __init__(self, base_logger: Optional[Logger] = None): """Single-workspace authorization.""" self.auth_test_result: Optional[AsyncSlackResponse] = None - self.logger = get_bolt_logger(AsyncSingleTeamAuthorization) + self.logger = get_bolt_logger( + AsyncSingleTeamAuthorization, base_logger=base_logger + ) async def async_process( self, @@ -101,6 +104,7 @@

    Classes

    class AsyncSingleTeamAuthorization +(base_logger: Optional[logging.Logger] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -110,10 +114,12 @@

    Classes

    Expand source code

    class AsyncSingleTeamAuthorization(AsyncAuthorization):
    -    def __init__(self):
    +    def __init__(self, base_logger: Optional[Logger] = None):
             """Single-workspace authorization."""
             self.auth_test_result: Optional[AsyncSlackResponse] = None
    -        self.logger = get_bolt_logger(AsyncSingleTeamAuthorization)
    +        self.logger = get_bolt_logger(
    +            AsyncSingleTeamAuthorization, base_logger=base_logger
    +        )
     
         async def async_process(
             self,
    diff --git a/docs/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html
    index 31d6c1621..9e390013e 100644
    --- a/docs/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html
    +++ b/docs/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html
    @@ -26,7 +26,8 @@ 

    Module slack_bolt.middleware.authorization.multi_teams_a Expand source code -
    from typing import Callable, Optional
    +
    from logging import Logger
    +from typing import Callable, Optional
     
     from slack_sdk.errors import SlackApiError
     
    @@ -50,14 +51,16 @@ 

    Module slack_bolt.middleware.authorization.multi_teams_a self, *, authorize: Authorize, + base_logger: Optional[Logger] = None, ): """Multi-workspace authorization. Args: authorize: The function to authorize incoming requests from Slack. + base_logger: The base logger """ self.authorize = authorize - self.logger = get_bolt_logger(MultiTeamsAuthorization) + self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger) def process( self, @@ -120,7 +123,7 @@

    Classes

    class MultiTeamsAuthorization -(*, authorize: Authorize) +(*, authorize: Authorize, base_logger: Optional[logging.Logger] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -129,6 +132,8 @@

    Args

    authorize
    The function to authorize incoming requests from Slack.
    +
    base_logger
    +
    The base logger
    @@ -141,14 +146,16 @@

    Args

    self, *, authorize: Authorize, + base_logger: Optional[Logger] = None, ): """Multi-workspace authorization. Args: authorize: The function to authorize incoming requests from Slack. + base_logger: The base logger """ self.authorize = authorize - self.logger = get_bolt_logger(MultiTeamsAuthorization) + self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger) def process( self, diff --git a/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html index c59b62d7d..3d48d602e 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html @@ -26,7 +26,8 @@

    Module slack_bolt.middleware.authorization.single_team_a Expand source code -
    from typing import Callable, Optional
    +
    from logging import Logger
    +from typing import Callable, Optional
     
     from slack_bolt.logger import get_bolt_logger
     from .authorization import Authorization
    @@ -44,14 +45,20 @@ 

    Module slack_bolt.middleware.authorization.single_team_a class SingleTeamAuthorization(Authorization): - def __init__(self, *, auth_test_result: Optional[SlackResponse] = None): + def __init__( + self, + *, + auth_test_result: Optional[SlackResponse] = None, + base_logger: Optional[Logger] = None, + ): """Single-workspace authorization. Args: auth_test_result: The initial `auth.test` API call result. + base_logger: The base logger """ self.auth_test_result = auth_test_result - self.logger = get_bolt_logger(SingleTeamAuthorization) + self.logger = get_bolt_logger(SingleTeamAuthorization, base_logger=base_logger) def process( self, @@ -109,7 +116,7 @@

    Classes

    class SingleTeamAuthorization -(*, auth_test_result: Optional[slack_sdk.web.slack_response.SlackResponse] = None) +(*, auth_test_result: Optional[slack_sdk.web.slack_response.SlackResponse] = None, base_logger: Optional[logging.Logger] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -118,20 +125,28 @@

    Args

    auth_test_result
    The initial auth.test API call result.
    +
    base_logger
    +
    The base logger
    Expand source code
    class SingleTeamAuthorization(Authorization):
    -    def __init__(self, *, auth_test_result: Optional[SlackResponse] = None):
    +    def __init__(
    +        self,
    +        *,
    +        auth_test_result: Optional[SlackResponse] = None,
    +        base_logger: Optional[Logger] = None,
    +    ):
             """Single-workspace authorization.
     
             Args:
                 auth_test_result: The initial `auth.test` API call result.
    +            base_logger: The base logger
             """
             self.auth_test_result = auth_test_result
    -        self.logger = get_bolt_logger(SingleTeamAuthorization)
    +        self.logger = get_bolt_logger(SingleTeamAuthorization, base_logger=base_logger)
     
         def process(
             self,
    diff --git a/docs/api-docs/slack_bolt/middleware/custom_middleware.html b/docs/api-docs/slack_bolt/middleware/custom_middleware.html
    index 901f1b928..750e704c2 100644
    --- a/docs/api-docs/slack_bolt/middleware/custom_middleware.html
    +++ b/docs/api-docs/slack_bolt/middleware/custom_middleware.html
    @@ -28,7 +28,7 @@ 

    Module slack_bolt.middleware.custom_middleware
    import inspect
     from logging import Logger
    -from typing import Callable, Any, Sequence
    +from typing import Callable, Any, Sequence, Optional
     
     from slack_bolt.kwargs_injection import build_required_kwargs
     from slack_bolt.logger import get_bolt_app_logger
    @@ -44,11 +44,13 @@ 

    Module slack_bolt.middleware.custom_middlewareClasses

    class CustomMiddleware -(*, app_name: str, func: Callable) +(*, app_name: str, func: Callable, base_logger: Optional[logging.Logger] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -101,11 +103,13 @@

    Classes

    arg_names: Sequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable): + def __init__( + self, *, app_name: str, func: Callable, base_logger: Optional[Logger] = None + ): self.app_name = app_name self.func = func self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) def process( self, diff --git a/docs/api-docs/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.html b/docs/api-docs/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.html index b04e95361..f6e03efc5 100644 --- a/docs/api-docs/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.html +++ b/docs/api-docs/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.html @@ -61,6 +61,7 @@

    Classes

    class AsyncIgnoringSelfEvents +(base_logger: Optional[logging.Logger] = None)

    A middleware can process request data before other middleware and listener functions.

    diff --git a/docs/api-docs/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.html b/docs/api-docs/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.html index e7abb14fc..da31d3542 100644 --- a/docs/api-docs/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.html +++ b/docs/api-docs/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.html @@ -27,7 +27,7 @@

    Module slack_bolt.middleware.ignoring_self_events.ignori Expand source code

    import logging
    -from typing import Callable, Dict, Any
    +from typing import Callable, Dict, Any, Optional
     
     from slack_bolt.authorization import AuthorizeResult
     from slack_bolt.logger import get_bolt_logger
    @@ -37,9 +37,9 @@ 

    Module slack_bolt.middleware.ignoring_self_events.ignori class IgnoringSelfEvents(Middleware): - def __init__(self): + def __init__(self, base_logger: Optional[logging.Logger] = None): """Ignores the events generated by this bot user itself.""" - self.logger = get_bolt_logger(IgnoringSelfEvents) + self.logger = get_bolt_logger(IgnoringSelfEvents, base_logger=base_logger) def process( self, @@ -91,6 +91,7 @@

    Classes

    class IgnoringSelfEvents +(base_logger: Optional[logging.Logger] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -100,9 +101,9 @@

    Classes

    Expand source code
    class IgnoringSelfEvents(Middleware):
    -    def __init__(self):
    +    def __init__(self, base_logger: Optional[logging.Logger] = None):
             """Ignores the events generated by this bot user itself."""
    -        self.logger = get_bolt_logger(IgnoringSelfEvents)
    +        self.logger = get_bolt_logger(IgnoringSelfEvents, base_logger=base_logger)
     
         def process(
             self,
    diff --git a/docs/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html b/docs/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html
    index 853eaee48..9d532ffff 100644
    --- a/docs/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html
    +++ b/docs/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html
    @@ -75,7 +75,7 @@ 

    Classes

    class AsyncRequestVerification -(signing_secret: str) +(signing_secret: str, base_logger: Optional[logging.Logger] = None)

    Verifies an incoming request by checking the validity of @@ -88,6 +88,8 @@

    Args

    signing_secret
    The signing secret
    +
    base_logger
    +
    The base logger
    diff --git a/docs/api-docs/slack_bolt/middleware/request_verification/request_verification.html b/docs/api-docs/slack_bolt/middleware/request_verification/request_verification.html index 81e8e9952..65e62356a 100644 --- a/docs/api-docs/slack_bolt/middleware/request_verification/request_verification.html +++ b/docs/api-docs/slack_bolt/middleware/request_verification/request_verification.html @@ -26,7 +26,8 @@

    Module slack_bolt.middleware.request_verification.reques Expand source code -
    from typing import Callable, Dict, Any
    +
    from logging import Logger
    +from typing import Callable, Dict, Any, Optional
     
     from slack_sdk.signature import SignatureVerifier
     
    @@ -37,7 +38,7 @@ 

    Module slack_bolt.middleware.request_verification.reques class RequestVerification(Middleware): # type: ignore - def __init__(self, signing_secret: str): + def __init__(self, signing_secret: str, base_logger: Optional[Logger] = None): """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. @@ -45,9 +46,10 @@

    Module slack_bolt.middleware.request_verification.reques Args: signing_secret: The signing secret + base_logger: The base logger """ self.verifier = SignatureVerifier(signing_secret=signing_secret) - self.logger = get_bolt_logger(RequestVerification) + self.logger = get_bolt_logger(RequestVerification, base_logger=base_logger) def process( self, @@ -101,7 +103,7 @@

    Classes

    class RequestVerification -(signing_secret: str) +(signing_secret: str, base_logger: Optional[logging.Logger] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -112,13 +114,15 @@

    Args

    signing_secret
    The signing secret
    +
    base_logger
    +
    The base logger
    Expand source code
    class RequestVerification(Middleware):  # type: ignore
    -    def __init__(self, signing_secret: str):
    +    def __init__(self, signing_secret: str, base_logger: Optional[Logger] = None):
             """Verifies an incoming request by checking the validity of
             `x-slack-signature`, `x-slack-request-timestamp`, and its body data.
     
    @@ -126,9 +130,10 @@ 

    Args

    Args: signing_secret: The signing secret + base_logger: The base logger """ self.verifier = SignatureVerifier(signing_secret=signing_secret) - self.logger = get_bolt_logger(RequestVerification) + self.logger = get_bolt_logger(RequestVerification, base_logger=base_logger) def process( self, diff --git a/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html b/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html index 76e2501a0..6624b8e10 100644 --- a/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html +++ b/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html @@ -64,7 +64,7 @@

    Classes

    class AsyncSslCheck -(verification_token: Optional[str] = None) +(verification_token: Optional[str] = None, base_logger: Optional[logging.Logger] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -75,6 +75,8 @@

    Args

    verification_token
    The verification token to check (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation)
    +
    base_logger
    +
    The base logger

    diff --git a/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html b/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html index 76dcd8647..20fd600ed 100644 --- a/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html +++ b/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html @@ -39,16 +39,21 @@

    Module slack_bolt.middleware.ssl_check.ssl_check< verification_token: Optional[str] logger: Logger - def __init__(self, verification_token: Optional[str] = None): + def __init__( + self, + verification_token: Optional[str] = None, + base_logger: Optional[Logger] = None, + ): """Handles `ssl_check` requests. Refer to https://api.slack.com/interactivity/slash-commands for details. Args: verification_token: The verification token to check (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) + base_logger: The base logger """ self.verification_token = verification_token - self.logger = get_bolt_logger(SslCheck) + self.logger = get_bolt_logger(SslCheck, base_logger=base_logger) def process( self, @@ -96,7 +101,7 @@

    Classes

    class SslCheck -(verification_token: Optional[str] = None) +(verification_token: Optional[str] = None, base_logger: Optional[logging.Logger] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -107,6 +112,8 @@

    Args

    verification_token
    The verification token to check (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation)
    +
    base_logger
    +
    The base logger
    @@ -116,16 +123,21 @@

    Args

    verification_token: Optional[str] logger: Logger - def __init__(self, verification_token: Optional[str] = None): + def __init__( + self, + verification_token: Optional[str] = None, + base_logger: Optional[Logger] = None, + ): """Handles `ssl_check` requests. Refer to https://api.slack.com/interactivity/slash-commands for details. Args: verification_token: The verification token to check (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) + base_logger: The base logger """ self.verification_token = verification_token - self.logger = get_bolt_logger(SslCheck) + self.logger = get_bolt_logger(SslCheck, base_logger=base_logger) def process( self, diff --git a/docs/api-docs/slack_bolt/middleware/url_verification/async_url_verification.html b/docs/api-docs/slack_bolt/middleware/url_verification/async_url_verification.html index 8a862b3c8..98c4cc403 100644 --- a/docs/api-docs/slack_bolt/middleware/url_verification/async_url_verification.html +++ b/docs/api-docs/slack_bolt/middleware/url_verification/async_url_verification.html @@ -26,7 +26,8 @@

    Module slack_bolt.middleware.url_verification.async_url_ Expand source code -
    from typing import Callable, Awaitable
    +
    from logging import Logger
    +from typing import Callable, Awaitable, Optional
     
     from slack_bolt.logger import get_bolt_logger
     from .url_verification import UrlVerification
    @@ -36,8 +37,8 @@ 

    Module slack_bolt.middleware.url_verification.async_url_ class AsyncUrlVerification(UrlVerification, AsyncMiddleware): - def __init__(self): - self.logger = get_bolt_logger(AsyncUrlVerification) + def __init__(self, base_logger: Optional[Logger] = None): + self.logger = get_bolt_logger(AsyncUrlVerification, base_logger=base_logger) async def async_process( self, @@ -63,18 +64,24 @@

    Classes

    class AsyncUrlVerification +(base_logger: Optional[logging.Logger] = None)

    A middleware can process request data before other middleware and listener functions.

    Handles url_verification requests.

    -

    Refer to https://api.slack.com/events/url_verification for details.

    +

    Refer to https://api.slack.com/events/url_verification for details.

    +

    Args

    +
    +
    base_logger
    +
    The base logger
    +
    Expand source code
    class AsyncUrlVerification(UrlVerification, AsyncMiddleware):
    -    def __init__(self):
    -        self.logger = get_bolt_logger(AsyncUrlVerification)
    +    def __init__(self, base_logger: Optional[Logger] = None):
    +        self.logger = get_bolt_logger(AsyncUrlVerification, base_logger=base_logger)
     
         async def async_process(
             self,
    diff --git a/docs/api-docs/slack_bolt/middleware/url_verification/url_verification.html b/docs/api-docs/slack_bolt/middleware/url_verification/url_verification.html
    index e1f45172d..be99bb396 100644
    --- a/docs/api-docs/slack_bolt/middleware/url_verification/url_verification.html
    +++ b/docs/api-docs/slack_bolt/middleware/url_verification/url_verification.html
    @@ -26,7 +26,8 @@ 

    Module slack_bolt.middleware.url_verification.url_verifi Expand source code -
    from typing import Callable
    +
    from logging import Logger
    +from typing import Callable, Optional
     
     from slack_bolt.logger import get_bolt_logger
     from slack_bolt.middleware.middleware import Middleware
    @@ -35,12 +36,15 @@ 

    Module slack_bolt.middleware.url_verification.url_verifi class UrlVerification(Middleware): # type: ignore - def __init__(self): + def __init__(self, base_logger: Optional[Logger] = None): """Handles url_verification requests. Refer to https://api.slack.com/events/url_verification for details. + + Args: + base_logger: The base logger """ - self.logger = get_bolt_logger(UrlVerification) + self.logger = get_bolt_logger(UrlVerification, base_logger=base_logger) def process( self, @@ -79,22 +83,31 @@

    Classes

    class UrlVerification +(base_logger: Optional[logging.Logger] = None)

    A middleware can process request data before other middleware and listener functions.

    Handles url_verification requests.

    -

    Refer to https://api.slack.com/events/url_verification for details.

    +

    Refer to https://api.slack.com/events/url_verification for details.

    +

    Args

    +
    +
    base_logger
    +
    The base logger
    +
    Expand source code
    class UrlVerification(Middleware):  # type: ignore
    -    def __init__(self):
    +    def __init__(self, base_logger: Optional[Logger] = None):
             """Handles url_verification requests.
     
             Refer to https://api.slack.com/events/url_verification for details.
    +
    +        Args:
    +            base_logger: The base logger
             """
    -        self.logger = get_bolt_logger(UrlVerification)
    +        self.logger = get_bolt_logger(UrlVerification, base_logger=base_logger)
     
         def process(
             self,
    diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html
    index aeabb6323..dc5919bc8 100644
    --- a/docs/api-docs/slack_bolt/version.html
    +++ b/docs/api-docs/slack_bolt/version.html
    @@ -28,7 +28,7 @@ 

    Module slack_bolt.version

    Expand source code

    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.12.0"
    +__version__ = "1.13.0"

    diff --git a/docs/api-docs/slack_bolt/workflows/step/async_step.html b/docs/api-docs/slack_bolt/workflows/step/async_step.html index c0a8c39d6..1a560fb77 100644 --- a/docs/api-docs/slack_bolt/workflows/step/async_step.html +++ b/docs/api-docs/slack_bolt/workflows/step/async_step.html @@ -27,6 +27,7 @@

    Module slack_bolt.workflows.step.async_step

    Expand source code
    from functools import wraps
    +from logging import Logger
     from typing import Callable, Union, Optional, Awaitable, Sequence, List, Pattern
     
     from slack_sdk.web.async_client import AsyncWebClient
    @@ -59,6 +60,7 @@ 

    Module slack_bolt.workflows.step.async_step

    """ callback_id: Union[str, Pattern] + _base_logger: Optional[Logger] _edit: Optional[AsyncListener] _save: Optional[AsyncListener] _execute: Optional[AsyncListener] @@ -67,6 +69,7 @@

    Module slack_bolt.workflows.step.async_step

    self, callback_id: Union[str, Pattern], app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, ): """This builder is supposed to be used as decorator. @@ -89,9 +92,11 @@

    Module slack_bolt.workflows.step.async_step

    Args: callback_id: The callback_id for the workflow app_name: The application name mainly for logging + base_logger: The base logger """ self.callback_id = callback_id self.app_name = app_name or __name__ + self._base_logger = base_logger self._edit = None self._save = None self._execute = None @@ -245,7 +250,7 @@

    Module slack_bolt.workflows.step.async_step

    return _inner - def build(self) -> "AsyncWorkflowStep": + def build(self, base_logger: Optional[Logger] = None) -> "AsyncWorkflowStep": """Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -265,6 +270,7 @@

    Module slack_bolt.workflows.step.async_step

    save=self._save, execute=self._execute, app_name=self.app_name, + base_logger=base_logger, ) # --------------------------------------- @@ -285,6 +291,7 @@

    Module slack_bolt.workflows.step.async_step

    name=name, matchers=self.to_listener_matchers(self.app_name, matchers), middleware=self.to_listener_middleware(self.app_name, middleware), + base_logger=self._base_logger, ) @staticmethod @@ -347,16 +354,39 @@

    Module slack_bolt.workflows.step.async_step

    Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable] ], app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, ): self.callback_id = callback_id app_name = app_name or __name__ - self.edit = self.build_listener(callback_id, app_name, edit, "edit") - self.save = self.build_listener(callback_id, app_name, save, "save") - self.execute = self.build_listener(callback_id, app_name, execute, "execute") + self.edit = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=edit, + name="edit", + base_logger=base_logger, + ) + self.save = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=save, + name="save", + base_logger=base_logger, + ) + self.execute = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=execute, + name="execute", + base_logger=base_logger, + ) @classmethod - def builder(cls, callback_id: Union[str, Pattern]) -> AsyncWorkflowStepBuilder: - return AsyncWorkflowStepBuilder(callback_id) + def builder( + cls, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> AsyncWorkflowStepBuilder: + return AsyncWorkflowStepBuilder(callback_id, base_logger=base_logger) @classmethod def build_listener( @@ -367,6 +397,7 @@

    Module slack_bolt.workflows.step.async_step

    name: str, matchers: Optional[List[AsyncListenerMatcher]] = None, middleware: Optional[List[AsyncMiddleware]] = None, + base_logger: Optional[Logger] = None, ): if listener_or_functions is None: raise BoltError(f"{name} listener is required (callback_id: {callback_id})") @@ -378,9 +409,13 @@

    Module slack_bolt.workflows.step.async_step

    return listener_or_functions elif isinstance(listener_or_functions, list): matchers = matchers if matchers else [] - matchers.insert(0, cls._build_primary_matcher(name, callback_id)) + matchers.insert( + 0, cls._build_primary_matcher(name, callback_id, base_logger) + ) middleware = middleware if middleware else [] - middleware.insert(0, cls._build_single_middleware(name, callback_id)) + middleware.insert( + 0, cls._build_single_middleware(name, callback_id, base_logger) + ) functions = listener_or_functions ack_function = functions.pop(0) return AsyncCustomListener( @@ -390,6 +425,7 @@

    Module slack_bolt.workflows.step.async_step

    ack_function=ack_function, lazy_functions=functions, auto_acknowledgement=name == "execute", + base_logger=base_logger, ) else: raise BoltError( @@ -398,25 +434,39 @@

    Module slack_bolt.workflows.step.async_step

    @classmethod def _build_primary_matcher( - cls, name: str, callback_id: str + cls, + name: str, + callback_id: str, + base_logger: Optional[Logger] = None, ) -> AsyncListenerMatcher: if name == "edit": - return workflow_step_edit(callback_id, asyncio=True) + return workflow_step_edit( + callback_id, asyncio=True, base_logger=base_logger + ) elif name == "save": - return workflow_step_save(callback_id, asyncio=True) + return workflow_step_save( + callback_id, asyncio=True, base_logger=base_logger + ) elif name == "execute": - return workflow_step_execute(callback_id, asyncio=True) + return workflow_step_execute( + callback_id, asyncio=True, base_logger=base_logger + ) else: raise ValueError(f"Invalid name {name}") @classmethod - def _build_single_middleware(cls, name: str, callback_id: str) -> AsyncMiddleware: + def _build_single_middleware( + cls, + name: str, + callback_id: str, + base_logger: Optional[Logger] = None, + ) -> AsyncMiddleware: if name == "edit": - return _build_edit_listener_middleware(callback_id) + return _build_edit_listener_middleware(callback_id, base_logger) elif name == "save": - return _build_save_listener_middleware() + return _build_save_listener_middleware(base_logger) elif name == "execute": - return _build_execute_listener_middleware() + return _build_execute_listener_middleware(base_logger) else: raise ValueError(f"Invalid name {name}") @@ -426,7 +476,10 @@

    Module slack_bolt.workflows.step.async_step

    ####################### -def _build_edit_listener_middleware(callback_id: str) -> AsyncMiddleware: +def _build_edit_listener_middleware( + callback_id: str, + base_logger: Optional[Logger] = None, +) -> AsyncMiddleware: async def edit_listener_middleware( context: AsyncBoltContext, client: AsyncWebClient, @@ -440,7 +493,11 @@

    Module slack_bolt.workflows.step.async_step

    ) return await next() - return AsyncCustomMiddleware(app_name=__name__, func=edit_listener_middleware) + return AsyncCustomMiddleware( + app_name=__name__, + func=edit_listener_middleware, + base_logger=base_logger, + ) ####################### @@ -448,7 +505,9 @@

    Module slack_bolt.workflows.step.async_step

    ####################### -def _build_save_listener_middleware() -> AsyncMiddleware: +def _build_save_listener_middleware( + base_logger: Optional[Logger] = None, +) -> AsyncMiddleware: async def save_listener_middleware( context: AsyncBoltContext, client: AsyncWebClient, @@ -461,7 +520,11 @@

    Module slack_bolt.workflows.step.async_step

    ) return await next() - return AsyncCustomMiddleware(app_name=__name__, func=save_listener_middleware) + return AsyncCustomMiddleware( + app_name=__name__, + func=save_listener_middleware, + base_logger=base_logger, + ) ####################### @@ -469,7 +532,9 @@

    Module slack_bolt.workflows.step.async_step

    ####################### -def _build_execute_listener_middleware() -> AsyncMiddleware: +def _build_execute_listener_middleware( + base_logger: Optional[Logger] = None, +) -> AsyncMiddleware: async def execute_listener_middleware( context: AsyncBoltContext, client: AsyncWebClient, @@ -486,7 +551,11 @@

    Module slack_bolt.workflows.step.async_step

    ) return await next() - return AsyncCustomMiddleware(app_name=__name__, func=execute_listener_middleware)
    + return AsyncCustomMiddleware( + app_name=__name__, + func=execute_listener_middleware, + base_logger=base_logger, + )
    @@ -500,7 +569,7 @@

    Classes

    class AsyncWorkflowStep -(*, callback_id: Union[str, Pattern], edit: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], save: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], execute: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], app_name: Optional[str] = None) +(*, callback_id: Union[str, Pattern], edit: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], save: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], execute: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], app_name: Optional[str] = None, base_logger: Optional[logging.Logger] = None)
    @@ -532,16 +601,39 @@

    Classes

    Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable] ], app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, ): self.callback_id = callback_id app_name = app_name or __name__ - self.edit = self.build_listener(callback_id, app_name, edit, "edit") - self.save = self.build_listener(callback_id, app_name, save, "save") - self.execute = self.build_listener(callback_id, app_name, execute, "execute") + self.edit = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=edit, + name="edit", + base_logger=base_logger, + ) + self.save = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=save, + name="save", + base_logger=base_logger, + ) + self.execute = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=execute, + name="execute", + base_logger=base_logger, + ) @classmethod - def builder(cls, callback_id: Union[str, Pattern]) -> AsyncWorkflowStepBuilder: - return AsyncWorkflowStepBuilder(callback_id) + def builder( + cls, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> AsyncWorkflowStepBuilder: + return AsyncWorkflowStepBuilder(callback_id, base_logger=base_logger) @classmethod def build_listener( @@ -552,6 +644,7 @@

    Classes

    name: str, matchers: Optional[List[AsyncListenerMatcher]] = None, middleware: Optional[List[AsyncMiddleware]] = None, + base_logger: Optional[Logger] = None, ): if listener_or_functions is None: raise BoltError(f"{name} listener is required (callback_id: {callback_id})") @@ -563,9 +656,13 @@

    Classes

    return listener_or_functions elif isinstance(listener_or_functions, list): matchers = matchers if matchers else [] - matchers.insert(0, cls._build_primary_matcher(name, callback_id)) + matchers.insert( + 0, cls._build_primary_matcher(name, callback_id, base_logger) + ) middleware = middleware if middleware else [] - middleware.insert(0, cls._build_single_middleware(name, callback_id)) + middleware.insert( + 0, cls._build_single_middleware(name, callback_id, base_logger) + ) functions = listener_or_functions ack_function = functions.pop(0) return AsyncCustomListener( @@ -575,6 +672,7 @@

    Classes

    ack_function=ack_function, lazy_functions=functions, auto_acknowledgement=name == "execute", + base_logger=base_logger, ) else: raise BoltError( @@ -583,25 +681,39 @@

    Classes

    @classmethod def _build_primary_matcher( - cls, name: str, callback_id: str + cls, + name: str, + callback_id: str, + base_logger: Optional[Logger] = None, ) -> AsyncListenerMatcher: if name == "edit": - return workflow_step_edit(callback_id, asyncio=True) + return workflow_step_edit( + callback_id, asyncio=True, base_logger=base_logger + ) elif name == "save": - return workflow_step_save(callback_id, asyncio=True) + return workflow_step_save( + callback_id, asyncio=True, base_logger=base_logger + ) elif name == "execute": - return workflow_step_execute(callback_id, asyncio=True) + return workflow_step_execute( + callback_id, asyncio=True, base_logger=base_logger + ) else: raise ValueError(f"Invalid name {name}") @classmethod - def _build_single_middleware(cls, name: str, callback_id: str) -> AsyncMiddleware: + def _build_single_middleware( + cls, + name: str, + callback_id: str, + base_logger: Optional[Logger] = None, + ) -> AsyncMiddleware: if name == "edit": - return _build_edit_listener_middleware(callback_id) + return _build_edit_listener_middleware(callback_id, base_logger) elif name == "save": - return _build_save_listener_middleware() + return _build_save_listener_middleware(base_logger) elif name == "execute": - return _build_execute_listener_middleware() + return _build_execute_listener_middleware(base_logger) else: raise ValueError(f"Invalid name {name}")
    @@ -627,7 +739,7 @@

    Class variables

    Static methods

    -def build_listener(callback_id: Union[str, Pattern], app_name: str, listener_or_functions: Union[AsyncListener, Callable, List[Callable]], name: str, matchers: Optional[List[AsyncListenerMatcher]] = None, middleware: Optional[List[AsyncMiddleware]] = None) +def build_listener(callback_id: Union[str, Pattern], app_name: str, listener_or_functions: Union[AsyncListener, Callable, List[Callable]], name: str, matchers: Optional[List[AsyncListenerMatcher]] = None, middleware: Optional[List[AsyncMiddleware]] = None, base_logger: Optional[logging.Logger] = None)
    @@ -644,6 +756,7 @@

    Static methods

    name: str, matchers: Optional[List[AsyncListenerMatcher]] = None, middleware: Optional[List[AsyncMiddleware]] = None, + base_logger: Optional[Logger] = None, ): if listener_or_functions is None: raise BoltError(f"{name} listener is required (callback_id: {callback_id})") @@ -655,9 +768,13 @@

    Static methods

    return listener_or_functions elif isinstance(listener_or_functions, list): matchers = matchers if matchers else [] - matchers.insert(0, cls._build_primary_matcher(name, callback_id)) + matchers.insert( + 0, cls._build_primary_matcher(name, callback_id, base_logger) + ) middleware = middleware if middleware else [] - middleware.insert(0, cls._build_single_middleware(name, callback_id)) + middleware.insert( + 0, cls._build_single_middleware(name, callback_id, base_logger) + ) functions = listener_or_functions ack_function = functions.pop(0) return AsyncCustomListener( @@ -667,6 +784,7 @@

    Static methods

    ack_function=ack_function, lazy_functions=functions, auto_acknowledgement=name == "execute", + base_logger=base_logger, ) else: raise BoltError( @@ -675,7 +793,7 @@

    Static methods

    -def builder(callback_id: Union[str, Pattern]) ‑> AsyncWorkflowStepBuilder +def builder(callback_id: Union[str, Pattern], base_logger: Optional[logging.Logger] = None) ‑> AsyncWorkflowStepBuilder
    @@ -684,15 +802,19 @@

    Static methods

    Expand source code
    @classmethod
    -def builder(cls, callback_id: Union[str, Pattern]) -> AsyncWorkflowStepBuilder:
    -    return AsyncWorkflowStepBuilder(callback_id)
    +def builder( + cls, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, +) -> AsyncWorkflowStepBuilder: + return AsyncWorkflowStepBuilder(callback_id, base_logger=base_logger)
    class AsyncWorkflowStepBuilder -(callback_id: Union[str, Pattern], app_name: Optional[str] = None) +(callback_id: Union[str, Pattern], app_name: Optional[str] = None, base_logger: Optional[logging.Logger] = None)

    Steps from Apps @@ -719,6 +841,8 @@

    Args

    The callback_id for the workflow
    app_name
    The application name mainly for logging
    +
    base_logger
    +
    The base logger
    @@ -730,6 +854,7 @@

    Args

    """ callback_id: Union[str, Pattern] + _base_logger: Optional[Logger] _edit: Optional[AsyncListener] _save: Optional[AsyncListener] _execute: Optional[AsyncListener] @@ -738,6 +863,7 @@

    Args

    self, callback_id: Union[str, Pattern], app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, ): """This builder is supposed to be used as decorator. @@ -760,9 +886,11 @@

    Args

    Args: callback_id: The callback_id for the workflow app_name: The application name mainly for logging + base_logger: The base logger """ self.callback_id = callback_id self.app_name = app_name or __name__ + self._base_logger = base_logger self._edit = None self._save = None self._execute = None @@ -916,7 +1044,7 @@

    Args

    return _inner - def build(self) -> "AsyncWorkflowStep": + def build(self, base_logger: Optional[Logger] = None) -> "AsyncWorkflowStep": """Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -936,6 +1064,7 @@

    Args

    save=self._save, execute=self._execute, app_name=self.app_name, + base_logger=base_logger, ) # --------------------------------------- @@ -956,6 +1085,7 @@

    Args

    name=name, matchers=self.to_listener_matchers(self.app_name, matchers), middleware=self.to_listener_middleware(self.app_name, middleware), + base_logger=self._base_logger, ) @staticmethod @@ -1061,7 +1191,7 @@

    Static methods

    Methods

    -def build(self) ‑> AsyncWorkflowStep +def build(self, base_logger: Optional[logging.Logger] = None) ‑> AsyncWorkflowStep

    Constructs a WorkflowStep object. This method may raise an exception @@ -1072,7 +1202,7 @@

    Returns

    Expand source code -
    def build(self) -> "AsyncWorkflowStep":
    +
    def build(self, base_logger: Optional[Logger] = None) -> "AsyncWorkflowStep":
         """Constructs a WorkflowStep object. This method may raise an exception
         if the builder doesn't have enough configurations to build the object.
     
    @@ -1092,6 +1222,7 @@ 

    Returns

    save=self._save, execute=self._execute, app_name=self.app_name, + base_logger=base_logger, )
    diff --git a/docs/api-docs/slack_bolt/workflows/step/step.html b/docs/api-docs/slack_bolt/workflows/step/step.html index d65f4f9d1..f64c784d3 100644 --- a/docs/api-docs/slack_bolt/workflows/step/step.html +++ b/docs/api-docs/slack_bolt/workflows/step/step.html @@ -27,6 +27,7 @@

    Module slack_bolt.workflows.step.step

    Expand source code
    from functools import wraps
    +from logging import Logger
     from typing import Callable, Union, Optional, Sequence, Pattern, List
     
     from slack_bolt.context.context import BoltContext
    @@ -54,6 +55,7 @@ 

    Module slack_bolt.workflows.step.step

    """ callback_id: Union[str, Pattern] + _base_logger: Optional[Logger] _edit: Optional[Listener] _save: Optional[Listener] _execute: Optional[Listener] @@ -62,6 +64,7 @@

    Module slack_bolt.workflows.step.step

    self, callback_id: Union[str, Pattern], app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, ): """This builder is supposed to be used as decorator. @@ -84,9 +87,11 @@

    Module slack_bolt.workflows.step.step

    Args: callback_id: The callback_id for the workflow app_name: The application name mainly for logging + base_logger: The base logger """ self.callback_id = callback_id self.app_name = app_name or __name__ + self._base_logger = base_logger self._edit = None self._save = None self._execute = None @@ -235,7 +240,7 @@

    Module slack_bolt.workflows.step.step

    return _inner - def build(self) -> "WorkflowStep": + def build(self, base_logger: Optional[Logger] = None) -> "WorkflowStep": """Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -255,6 +260,7 @@

    Module slack_bolt.workflows.step.step

    save=self._save, execute=self._execute, app_name=self.app_name, + base_logger=base_logger, ) # --------------------------------------- @@ -271,14 +277,20 @@

    Module slack_bolt.workflows.step.step

    app_name=self.app_name, listener_or_functions=listener_or_functions, name=name, - matchers=self.to_listener_matchers(self.app_name, matchers), - middleware=self.to_listener_middleware(self.app_name, middleware), + matchers=self.to_listener_matchers( + self.app_name, matchers, self._base_logger + ), + middleware=self.to_listener_middleware( + self.app_name, middleware, self._base_logger + ), + base_logger=self._base_logger, ) @staticmethod def to_listener_matchers( app_name: str, matchers: Optional[List[Union[Callable[..., bool], ListenerMatcher]]], + base_logger: Optional[Logger] = None, ) -> List[ListenerMatcher]: _matchers = [] if matchers is not None: @@ -286,14 +298,22 @@

    Module slack_bolt.workflows.step.step

    if isinstance(m, ListenerMatcher): _matchers.append(m) elif isinstance(m, Callable): - _matchers.append(CustomListenerMatcher(app_name=app_name, func=m)) + _matchers.append( + CustomListenerMatcher( + app_name=app_name, + func=m, + base_logger=base_logger, + ) + ) else: raise ValueError(f"Invalid matcher: {type(m)}") return _matchers # type: ignore @staticmethod def to_listener_middleware( - app_name: str, middleware: Optional[List[Union[Callable, Middleware]]] + app_name: str, + middleware: Optional[List[Union[Callable, Middleware]]], + base_logger: Optional[Logger] = None, ) -> List[Middleware]: _middleware = [] if middleware is not None: @@ -301,7 +321,13 @@

    Module slack_bolt.workflows.step.step

    if isinstance(m, Middleware): _middleware.append(m) elif isinstance(m, Callable): - _middleware.append(CustomMiddleware(app_name=app_name, func=m)) + _middleware.append( + CustomMiddleware( + app_name=app_name, + func=m, + base_logger=base_logger, + ) + ) else: raise ValueError(f"Invalid middleware: {type(m)}") return _middleware # type: ignore @@ -331,16 +357,40 @@

    Module slack_bolt.workflows.step.step

    Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable] ], app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, ): self.callback_id = callback_id app_name = app_name or __name__ - self.edit = self.build_listener(callback_id, app_name, edit, "edit") - self.save = self.build_listener(callback_id, app_name, save, "save") - self.execute = self.build_listener(callback_id, app_name, execute, "execute") + self.edit = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=edit, + name="edit", + base_logger=base_logger, + ) + self.save = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=save, + name="save", + base_logger=base_logger, + ) + self.execute = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=execute, + name="execute", + base_logger=base_logger, + ) @classmethod - def builder(cls, callback_id: Union[str, Pattern]) -> WorkflowStepBuilder: - return WorkflowStepBuilder(callback_id) + def builder( + cls, callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None + ) -> WorkflowStepBuilder: + return WorkflowStepBuilder( + callback_id, + base_logger=base_logger, + ) @classmethod def build_listener( @@ -351,6 +401,7 @@

    Module slack_bolt.workflows.step.step

    name: str, matchers: Optional[List[ListenerMatcher]] = None, middleware: Optional[List[Middleware]] = None, + base_logger: Optional[Logger] = None, ) -> Listener: if listener_or_functions is None: raise BoltError(f"{name} listener is required (callback_id: {callback_id})") @@ -362,9 +413,23 @@

    Module slack_bolt.workflows.step.step

    return listener_or_functions elif isinstance(listener_or_functions, list): matchers = matchers if matchers else [] - matchers.insert(0, cls._build_primary_matcher(name, callback_id)) + matchers.insert( + 0, + cls._build_primary_matcher( + name, + callback_id, + base_logger=base_logger, + ), + ) middleware = middleware if middleware else [] - middleware.insert(0, cls._build_single_middleware(name, callback_id)) + middleware.insert( + 0, + cls._build_single_middleware( + name, + callback_id, + base_logger=base_logger, + ), + ) functions = listener_or_functions ack_function = functions.pop(0) return CustomListener( @@ -374,6 +439,7 @@

    Module slack_bolt.workflows.step.step

    ack_function=ack_function, lazy_functions=functions, auto_acknowledgement=name == "execute", + base_logger=base_logger, ) else: raise BoltError( @@ -381,24 +447,34 @@

    Module slack_bolt.workflows.step.step

    ) @classmethod - def _build_primary_matcher(cls, name, callback_id) -> ListenerMatcher: + def _build_primary_matcher( + cls, + name: str, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> ListenerMatcher: if name == "edit": - return workflow_step_edit(callback_id) + return workflow_step_edit(callback_id, base_logger=base_logger) elif name == "save": - return workflow_step_save(callback_id) + return workflow_step_save(callback_id, base_logger=base_logger) elif name == "execute": - return workflow_step_execute(callback_id) + return workflow_step_execute(callback_id, base_logger=base_logger) else: raise ValueError(f"Invalid name {name}") @classmethod - def _build_single_middleware(cls, name, callback_id) -> Middleware: + def _build_single_middleware( + cls, + name: str, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> Middleware: if name == "edit": - return _build_edit_listener_middleware(callback_id) + return _build_edit_listener_middleware(callback_id, base_logger=base_logger) elif name == "save": - return _build_save_listener_middleware() + return _build_save_listener_middleware(base_logger=base_logger) elif name == "execute": - return _build_execute_listener_middleware() + return _build_execute_listener_middleware(base_logger=base_logger) else: raise ValueError(f"Invalid name {name}") @@ -408,7 +484,9 @@

    Module slack_bolt.workflows.step.step

    ####################### -def _build_edit_listener_middleware(callback_id: str) -> Middleware: +def _build_edit_listener_middleware( + callback_id: str, base_logger: Optional[Logger] = None +) -> Middleware: def edit_listener_middleware( context: BoltContext, client: WebClient, @@ -422,7 +500,11 @@

    Module slack_bolt.workflows.step.step

    ) return next() - return CustomMiddleware(app_name=__name__, func=edit_listener_middleware) + return CustomMiddleware( + app_name=__name__, + func=edit_listener_middleware, + base_logger=base_logger, + ) ####################### @@ -430,7 +512,7 @@

    Module slack_bolt.workflows.step.step

    ####################### -def _build_save_listener_middleware() -> Middleware: +def _build_save_listener_middleware(base_logger: Optional[Logger] = None) -> Middleware: def save_listener_middleware( context: BoltContext, client: WebClient, @@ -443,7 +525,11 @@

    Module slack_bolt.workflows.step.step

    ) return next() - return CustomMiddleware(app_name=__name__, func=save_listener_middleware) + return CustomMiddleware( + app_name=__name__, + func=save_listener_middleware, + base_logger=base_logger, + ) ####################### @@ -451,7 +537,9 @@

    Module slack_bolt.workflows.step.step

    ####################### -def _build_execute_listener_middleware() -> Middleware: +def _build_execute_listener_middleware( + base_logger: Optional[Logger] = None, +) -> Middleware: def execute_listener_middleware( context: BoltContext, client: WebClient, @@ -468,7 +556,11 @@

    Module slack_bolt.workflows.step.step

    ) return next() - return CustomMiddleware(app_name=__name__, func=execute_listener_middleware)
    + return CustomMiddleware( + app_name=__name__, + func=execute_listener_middleware, + base_logger=base_logger, + )
    @@ -482,7 +574,7 @@

    Classes

    class WorkflowStep -(*, callback_id: Union[str, Pattern], edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], app_name: Optional[str] = None) +(*, callback_id: Union[str, Pattern], edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], app_name: Optional[str] = None, base_logger: Optional[logging.Logger] = None)
    @@ -514,16 +606,40 @@

    Classes

    Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable] ], app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, ): self.callback_id = callback_id app_name = app_name or __name__ - self.edit = self.build_listener(callback_id, app_name, edit, "edit") - self.save = self.build_listener(callback_id, app_name, save, "save") - self.execute = self.build_listener(callback_id, app_name, execute, "execute") + self.edit = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=edit, + name="edit", + base_logger=base_logger, + ) + self.save = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=save, + name="save", + base_logger=base_logger, + ) + self.execute = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=execute, + name="execute", + base_logger=base_logger, + ) @classmethod - def builder(cls, callback_id: Union[str, Pattern]) -> WorkflowStepBuilder: - return WorkflowStepBuilder(callback_id) + def builder( + cls, callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None + ) -> WorkflowStepBuilder: + return WorkflowStepBuilder( + callback_id, + base_logger=base_logger, + ) @classmethod def build_listener( @@ -534,6 +650,7 @@

    Classes

    name: str, matchers: Optional[List[ListenerMatcher]] = None, middleware: Optional[List[Middleware]] = None, + base_logger: Optional[Logger] = None, ) -> Listener: if listener_or_functions is None: raise BoltError(f"{name} listener is required (callback_id: {callback_id})") @@ -545,9 +662,23 @@

    Classes

    return listener_or_functions elif isinstance(listener_or_functions, list): matchers = matchers if matchers else [] - matchers.insert(0, cls._build_primary_matcher(name, callback_id)) + matchers.insert( + 0, + cls._build_primary_matcher( + name, + callback_id, + base_logger=base_logger, + ), + ) middleware = middleware if middleware else [] - middleware.insert(0, cls._build_single_middleware(name, callback_id)) + middleware.insert( + 0, + cls._build_single_middleware( + name, + callback_id, + base_logger=base_logger, + ), + ) functions = listener_or_functions ack_function = functions.pop(0) return CustomListener( @@ -557,6 +688,7 @@

    Classes

    ack_function=ack_function, lazy_functions=functions, auto_acknowledgement=name == "execute", + base_logger=base_logger, ) else: raise BoltError( @@ -564,24 +696,34 @@

    Classes

    ) @classmethod - def _build_primary_matcher(cls, name, callback_id) -> ListenerMatcher: + def _build_primary_matcher( + cls, + name: str, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> ListenerMatcher: if name == "edit": - return workflow_step_edit(callback_id) + return workflow_step_edit(callback_id, base_logger=base_logger) elif name == "save": - return workflow_step_save(callback_id) + return workflow_step_save(callback_id, base_logger=base_logger) elif name == "execute": - return workflow_step_execute(callback_id) + return workflow_step_execute(callback_id, base_logger=base_logger) else: raise ValueError(f"Invalid name {name}") @classmethod - def _build_single_middleware(cls, name, callback_id) -> Middleware: + def _build_single_middleware( + cls, + name: str, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> Middleware: if name == "edit": - return _build_edit_listener_middleware(callback_id) + return _build_edit_listener_middleware(callback_id, base_logger=base_logger) elif name == "save": - return _build_save_listener_middleware() + return _build_save_listener_middleware(base_logger=base_logger) elif name == "execute": - return _build_execute_listener_middleware() + return _build_execute_listener_middleware(base_logger=base_logger) else: raise ValueError(f"Invalid name {name}") @@ -607,7 +749,7 @@

    Class variables

    Static methods

    -def build_listener(callback_id: Union[str, Pattern], app_name: str, listener_or_functions: Union[Listener, Callable, List[Callable]], name: str, matchers: Optional[List[ListenerMatcher]] = None, middleware: Optional[List[Middleware]] = None) ‑> Listener +def build_listener(callback_id: Union[str, Pattern], app_name: str, listener_or_functions: Union[Listener, Callable, List[Callable]], name: str, matchers: Optional[List[ListenerMatcher]] = None, middleware: Optional[List[Middleware]] = None, base_logger: Optional[logging.Logger] = None) ‑> Listener
    @@ -624,6 +766,7 @@

    Static methods

    name: str, matchers: Optional[List[ListenerMatcher]] = None, middleware: Optional[List[Middleware]] = None, + base_logger: Optional[Logger] = None, ) -> Listener: if listener_or_functions is None: raise BoltError(f"{name} listener is required (callback_id: {callback_id})") @@ -635,9 +778,23 @@

    Static methods

    return listener_or_functions elif isinstance(listener_or_functions, list): matchers = matchers if matchers else [] - matchers.insert(0, cls._build_primary_matcher(name, callback_id)) + matchers.insert( + 0, + cls._build_primary_matcher( + name, + callback_id, + base_logger=base_logger, + ), + ) middleware = middleware if middleware else [] - middleware.insert(0, cls._build_single_middleware(name, callback_id)) + middleware.insert( + 0, + cls._build_single_middleware( + name, + callback_id, + base_logger=base_logger, + ), + ) functions = listener_or_functions ack_function = functions.pop(0) return CustomListener( @@ -647,6 +804,7 @@

    Static methods

    ack_function=ack_function, lazy_functions=functions, auto_acknowledgement=name == "execute", + base_logger=base_logger, ) else: raise BoltError( @@ -655,7 +813,7 @@

    Static methods

    -def builder(callback_id: Union[str, Pattern]) ‑> WorkflowStepBuilder +def builder(callback_id: Union[str, Pattern], base_logger: Optional[logging.Logger] = None) ‑> WorkflowStepBuilder
    @@ -664,15 +822,20 @@

    Static methods

    Expand source code
    @classmethod
    -def builder(cls, callback_id: Union[str, Pattern]) -> WorkflowStepBuilder:
    -    return WorkflowStepBuilder(callback_id)
    +def builder( + cls, callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None +) -> WorkflowStepBuilder: + return WorkflowStepBuilder( + callback_id, + base_logger=base_logger, + )
    class WorkflowStepBuilder -(callback_id: Union[str, Pattern], app_name: Optional[str] = None) +(callback_id: Union[str, Pattern], app_name: Optional[str] = None, base_logger: Optional[logging.Logger] = None)

    Steps from Apps @@ -699,6 +862,8 @@

    Args

    The callback_id for the workflow
    app_name
    The application name mainly for logging
    +
    base_logger
    +
    The base logger
    @@ -710,6 +875,7 @@

    Args

    """ callback_id: Union[str, Pattern] + _base_logger: Optional[Logger] _edit: Optional[Listener] _save: Optional[Listener] _execute: Optional[Listener] @@ -718,6 +884,7 @@

    Args

    self, callback_id: Union[str, Pattern], app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, ): """This builder is supposed to be used as decorator. @@ -740,9 +907,11 @@

    Args

    Args: callback_id: The callback_id for the workflow app_name: The application name mainly for logging + base_logger: The base logger """ self.callback_id = callback_id self.app_name = app_name or __name__ + self._base_logger = base_logger self._edit = None self._save = None self._execute = None @@ -891,7 +1060,7 @@

    Args

    return _inner - def build(self) -> "WorkflowStep": + def build(self, base_logger: Optional[Logger] = None) -> "WorkflowStep": """Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -911,6 +1080,7 @@

    Args

    save=self._save, execute=self._execute, app_name=self.app_name, + base_logger=base_logger, ) # --------------------------------------- @@ -927,14 +1097,20 @@

    Args

    app_name=self.app_name, listener_or_functions=listener_or_functions, name=name, - matchers=self.to_listener_matchers(self.app_name, matchers), - middleware=self.to_listener_middleware(self.app_name, middleware), + matchers=self.to_listener_matchers( + self.app_name, matchers, self._base_logger + ), + middleware=self.to_listener_middleware( + self.app_name, middleware, self._base_logger + ), + base_logger=self._base_logger, ) @staticmethod def to_listener_matchers( app_name: str, matchers: Optional[List[Union[Callable[..., bool], ListenerMatcher]]], + base_logger: Optional[Logger] = None, ) -> List[ListenerMatcher]: _matchers = [] if matchers is not None: @@ -942,14 +1118,22 @@

    Args

    if isinstance(m, ListenerMatcher): _matchers.append(m) elif isinstance(m, Callable): - _matchers.append(CustomListenerMatcher(app_name=app_name, func=m)) + _matchers.append( + CustomListenerMatcher( + app_name=app_name, + func=m, + base_logger=base_logger, + ) + ) else: raise ValueError(f"Invalid matcher: {type(m)}") return _matchers # type: ignore @staticmethod def to_listener_middleware( - app_name: str, middleware: Optional[List[Union[Callable, Middleware]]] + app_name: str, + middleware: Optional[List[Union[Callable, Middleware]]], + base_logger: Optional[Logger] = None, ) -> List[Middleware]: _middleware = [] if middleware is not None: @@ -957,7 +1141,13 @@

    Args

    if isinstance(m, Middleware): _middleware.append(m) elif isinstance(m, Callable): - _middleware.append(CustomMiddleware(app_name=app_name, func=m)) + _middleware.append( + CustomMiddleware( + app_name=app_name, + func=m, + base_logger=base_logger, + ) + ) else: raise ValueError(f"Invalid middleware: {type(m)}") return _middleware # type: ignore @@ -972,7 +1162,7 @@

    Class variables

    Static methods

    -def to_listener_matchers(app_name: str, matchers: Optional[List[Union[Callable[..., bool], ListenerMatcher]]]) ‑> List[ListenerMatcher] +def to_listener_matchers(app_name: str, matchers: Optional[List[Union[Callable[..., bool], ListenerMatcher]]], base_logger: Optional[logging.Logger] = None) ‑> List[ListenerMatcher]
    @@ -984,6 +1174,7 @@

    Static methods

    def to_listener_matchers( app_name: str, matchers: Optional[List[Union[Callable[..., bool], ListenerMatcher]]], + base_logger: Optional[Logger] = None, ) -> List[ListenerMatcher]: _matchers = [] if matchers is not None: @@ -991,14 +1182,20 @@

    Static methods

    if isinstance(m, ListenerMatcher): _matchers.append(m) elif isinstance(m, Callable): - _matchers.append(CustomListenerMatcher(app_name=app_name, func=m)) + _matchers.append( + CustomListenerMatcher( + app_name=app_name, + func=m, + base_logger=base_logger, + ) + ) else: raise ValueError(f"Invalid matcher: {type(m)}") return _matchers # type: ignore
    -def to_listener_middleware(app_name: str, middleware: Optional[List[Union[Callable, Middleware]]]) ‑> List[Middleware] +def to_listener_middleware(app_name: str, middleware: Optional[List[Union[Callable, Middleware]]], base_logger: Optional[logging.Logger] = None) ‑> List[Middleware]
    @@ -1008,7 +1205,9 @@

    Static methods

    @staticmethod
     def to_listener_middleware(
    -    app_name: str, middleware: Optional[List[Union[Callable, Middleware]]]
    +    app_name: str,
    +    middleware: Optional[List[Union[Callable, Middleware]]],
    +    base_logger: Optional[Logger] = None,
     ) -> List[Middleware]:
         _middleware = []
         if middleware is not None:
    @@ -1016,7 +1215,13 @@ 

    Static methods

    if isinstance(m, Middleware): _middleware.append(m) elif isinstance(m, Callable): - _middleware.append(CustomMiddleware(app_name=app_name, func=m)) + _middleware.append( + CustomMiddleware( + app_name=app_name, + func=m, + base_logger=base_logger, + ) + ) else: raise ValueError(f"Invalid middleware: {type(m)}") return _middleware # type: ignore
    @@ -1026,7 +1231,7 @@

    Static methods

    Methods

    -def build(self) ‑> WorkflowStep +def build(self, base_logger: Optional[logging.Logger] = None) ‑> WorkflowStep

    Constructs a WorkflowStep object. This method may raise an exception @@ -1037,7 +1242,7 @@

    Returns

    Expand source code -
    def build(self) -> "WorkflowStep":
    +
    def build(self, base_logger: Optional[Logger] = None) -> "WorkflowStep":
         """Constructs a WorkflowStep object. This method may raise an exception
         if the builder doesn't have enough configurations to build the object.
     
    @@ -1057,6 +1262,7 @@ 

    Returns

    save=self._save, execute=self._execute, app_name=self.app_name, + base_logger=base_logger, )
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index aada8e7e5..d48ad3d3a 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.12.0" +__version__ = "1.13.0" From e47cba85b85365490497d581b73f4e815b7b70f4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 19 Mar 2022 19:09:16 +0900 Subject: [PATCH 463/865] Add more unit tests --- tests/slack_bolt/listener/__init__.py | 0 .../test_listener_completion_handler.py | 27 ++++++++++++ .../listener/test_listener_start_handler.py | 27 ++++++++++++ tests/slack_bolt/workflows/__init__.py | 0 tests/slack_bolt/workflows/step/__init__.py | 0 tests/slack_bolt/workflows/step/test_step.py | 42 +++++++++++++++++++ tests/slack_bolt_async/listener/__init__.py | 0 .../test_async_listener_completion_handler.py | 29 +++++++++++++ .../test_async_listener_start_handler.py | 29 +++++++++++++ tests/slack_bolt_async/workflows/__init__.py | 0 .../workflows/step/__init__.py | 0 .../workflows/step/test_async_step.py | 42 +++++++++++++++++++ 12 files changed, 196 insertions(+) create mode 100644 tests/slack_bolt/listener/__init__.py create mode 100644 tests/slack_bolt/listener/test_listener_completion_handler.py create mode 100644 tests/slack_bolt/listener/test_listener_start_handler.py create mode 100644 tests/slack_bolt/workflows/__init__.py create mode 100644 tests/slack_bolt/workflows/step/__init__.py create mode 100644 tests/slack_bolt/workflows/step/test_step.py create mode 100644 tests/slack_bolt_async/listener/__init__.py create mode 100644 tests/slack_bolt_async/listener/test_async_listener_completion_handler.py create mode 100644 tests/slack_bolt_async/listener/test_async_listener_start_handler.py create mode 100644 tests/slack_bolt_async/workflows/__init__.py create mode 100644 tests/slack_bolt_async/workflows/step/__init__.py create mode 100644 tests/slack_bolt_async/workflows/step/test_async_step.py diff --git a/tests/slack_bolt/listener/__init__.py b/tests/slack_bolt/listener/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/listener/test_listener_completion_handler.py b/tests/slack_bolt/listener/test_listener_completion_handler.py new file mode 100644 index 000000000..b55a6eb27 --- /dev/null +++ b/tests/slack_bolt/listener/test_listener_completion_handler.py @@ -0,0 +1,27 @@ +import logging + +from slack_bolt import BoltRequest +from slack_bolt.listener.listener_completion_handler import ( + CustomListenerCompletionHandler, +) + + +class TestListenerCompletionHandler: + def test_handler(self): + result = {"called": False} + + def f(): + result["called"] = True + + handler = CustomListenerCompletionHandler( + logger=logging.getLogger(__name__), + func=f, + ) + request = BoltRequest( + body="{}", + query={}, + headers={"content-type": ["application/json"]}, + context={}, + ) + handler.handle(request, None) + assert result["called"] is True diff --git a/tests/slack_bolt/listener/test_listener_start_handler.py b/tests/slack_bolt/listener/test_listener_start_handler.py new file mode 100644 index 000000000..73a4d9c10 --- /dev/null +++ b/tests/slack_bolt/listener/test_listener_start_handler.py @@ -0,0 +1,27 @@ +import logging + +from slack_bolt import BoltRequest +from slack_bolt.listener.listener_start_handler import ( + CustomListenerStartHandler, +) + + +class TestListenerStartHandler: + def test_handler(self): + result = {"called": False} + + def f(): + result["called"] = True + + handler = CustomListenerStartHandler( + logger=logging.getLogger(__name__), + func=f, + ) + request = BoltRequest( + body="{}", + query={}, + headers={"content-type": ["application/json"]}, + context={}, + ) + handler.handle(request, None) + assert result["called"] is True diff --git a/tests/slack_bolt/workflows/__init__.py b/tests/slack_bolt/workflows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/workflows/step/__init__.py b/tests/slack_bolt/workflows/step/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/workflows/step/test_step.py b/tests/slack_bolt/workflows/step/test_step.py new file mode 100644 index 000000000..ebde40ee5 --- /dev/null +++ b/tests/slack_bolt/workflows/step/test_step.py @@ -0,0 +1,42 @@ +import pytest + +from slack_bolt import Ack +from slack_bolt.error import BoltError +from slack_bolt.workflows.step import WorkflowStep + + +class TestStep: + def test_build(self): + step = WorkflowStep.builder("foo") + step.edit(just_ack) + step.save(just_ack) + step.execute(just_ack) + assert step.build() is not None + + def test_build_errors(self): + with pytest.raises(BoltError): + step = WorkflowStep.builder("foo") + step.edit(None) + step.save(just_ack) + step.execute(just_ack) + step.build() + with pytest.raises(BoltError): + step = WorkflowStep.builder("foo") + step.edit(just_ack) + step.save(None) + step.execute(just_ack) + step.build() + with pytest.raises(BoltError): + step = WorkflowStep.builder("foo") + step.edit(just_ack) + step.save(just_ack) + step.execute(None) + step.build() + + +def just_ack(ack: Ack): + ack() + + +def execute(): + pass diff --git a/tests/slack_bolt_async/listener/__init__.py b/tests/slack_bolt_async/listener/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/listener/test_async_listener_completion_handler.py b/tests/slack_bolt_async/listener/test_async_listener_completion_handler.py new file mode 100644 index 000000000..c03789329 --- /dev/null +++ b/tests/slack_bolt_async/listener/test_async_listener_completion_handler.py @@ -0,0 +1,29 @@ +import logging +import pytest + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.listener.async_listener_completion_handler import ( + AsyncCustomListenerCompletionHandler, +) + + +class TestAsyncListenerCompletionHandler: + @pytest.mark.asyncio + async def test_handler(self): + result = {"called": False} + + async def f(): + result["called"] = True + + handler = AsyncCustomListenerCompletionHandler( + logger=logging.getLogger(__name__), + func=f, + ) + request = AsyncBoltRequest( + body="{}", + query={}, + headers={"content-type": ["application/json"]}, + context={}, + ) + await handler.handle(request, None) + assert result["called"] is True diff --git a/tests/slack_bolt_async/listener/test_async_listener_start_handler.py b/tests/slack_bolt_async/listener/test_async_listener_start_handler.py new file mode 100644 index 000000000..0a69a671d --- /dev/null +++ b/tests/slack_bolt_async/listener/test_async_listener_start_handler.py @@ -0,0 +1,29 @@ +import logging +import pytest + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.listener.async_listener_start_handler import ( + AsyncCustomListenerStartHandler, +) + + +class TestAsyncListenerStartHandler: + @pytest.mark.asyncio + async def test_handler(self): + result = {"called": False} + + async def f(): + result["called"] = True + + handler = AsyncCustomListenerStartHandler( + logger=logging.getLogger(__name__), + func=f, + ) + request = AsyncBoltRequest( + body="{}", + query={}, + headers={"content-type": ["application/json"]}, + context={}, + ) + await handler.handle(request, None) + assert result["called"] is True diff --git a/tests/slack_bolt_async/workflows/__init__.py b/tests/slack_bolt_async/workflows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/workflows/step/__init__.py b/tests/slack_bolt_async/workflows/step/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/workflows/step/test_async_step.py b/tests/slack_bolt_async/workflows/step/test_async_step.py new file mode 100644 index 000000000..550dd3c46 --- /dev/null +++ b/tests/slack_bolt_async/workflows/step/test_async_step.py @@ -0,0 +1,42 @@ +import pytest + +from slack_bolt import Ack +from slack_bolt.error import BoltError +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep + + +class TestStep: + def test_build(self): + step = AsyncWorkflowStep.builder("foo") + step.edit(just_ack) + step.save(just_ack) + step.execute(just_ack) + assert step.build() is not None + + def test_build_errors(self): + with pytest.raises(BoltError): + step = AsyncWorkflowStep.builder("foo") + step.edit(None) + step.save(just_ack) + step.execute(just_ack) + step.build() + with pytest.raises(BoltError): + step = AsyncWorkflowStep.builder("foo") + step.edit(just_ack) + step.save(None) + step.execute(just_ack) + step.build() + with pytest.raises(BoltError): + step = AsyncWorkflowStep.builder("foo") + step.edit(just_ack) + step.save(just_ack) + step.execute(None) + step.build() + + +def just_ack(ack: Ack): + ack() + + +def execute(): + pass From 8bdc43aafe9d498a25c57aafd933e18d1e024588 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 20 Mar 2022 08:46:50 +0900 Subject: [PATCH 464/865] Update tests and test settings --- setup.py | 2 +- tests/slack_bolt/workflows/step/test_step.py | 3 --- tests/slack_bolt_async/workflows/step/test_async_step.py | 3 --- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/setup.py b/setup.py index 758953f86..811f907cf 100755 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ ] async_test_dependencies = test_dependencies + [ - "pytest-asyncio<1", # for async + "pytest-asyncio>=0.18.2,<1", # for async "aiohttp>=3,<4", # for async ] diff --git a/tests/slack_bolt/workflows/step/test_step.py b/tests/slack_bolt/workflows/step/test_step.py index ebde40ee5..107154439 100644 --- a/tests/slack_bolt/workflows/step/test_step.py +++ b/tests/slack_bolt/workflows/step/test_step.py @@ -16,21 +16,18 @@ def test_build(self): def test_build_errors(self): with pytest.raises(BoltError): step = WorkflowStep.builder("foo") - step.edit(None) step.save(just_ack) step.execute(just_ack) step.build() with pytest.raises(BoltError): step = WorkflowStep.builder("foo") step.edit(just_ack) - step.save(None) step.execute(just_ack) step.build() with pytest.raises(BoltError): step = WorkflowStep.builder("foo") step.edit(just_ack) step.save(just_ack) - step.execute(None) step.build() diff --git a/tests/slack_bolt_async/workflows/step/test_async_step.py b/tests/slack_bolt_async/workflows/step/test_async_step.py index 550dd3c46..f377d8906 100644 --- a/tests/slack_bolt_async/workflows/step/test_async_step.py +++ b/tests/slack_bolt_async/workflows/step/test_async_step.py @@ -16,21 +16,18 @@ def test_build(self): def test_build_errors(self): with pytest.raises(BoltError): step = AsyncWorkflowStep.builder("foo") - step.edit(None) step.save(just_ack) step.execute(just_ack) step.build() with pytest.raises(BoltError): step = AsyncWorkflowStep.builder("foo") step.edit(just_ack) - step.save(None) step.execute(just_ack) step.build() with pytest.raises(BoltError): step = AsyncWorkflowStep.builder("foo") step.edit(just_ack) step.save(just_ack) - step.execute(None) step.build() From 6c868f3b2c87e4768e4ce7a888096a39c408ac48 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Mon, 21 Mar 2022 17:21:33 -0700 Subject: [PATCH 465/865] Improve oauth lambda deploy instructs (#621) Co-authored-by: Kazuhiro Sera --- examples/aws_lambda/README.md | 202 ++++++++++++++++++++-------------- 1 file changed, 118 insertions(+), 84 deletions(-) diff --git a/examples/aws_lambda/README.md b/examples/aws_lambda/README.md index 15b3f3bc3..6b486d028 100644 --- a/examples/aws_lambda/README.md +++ b/examples/aws_lambda/README.md @@ -85,87 +85,121 @@ Instructions on how to set up and deploy each example are provided below. ## OAuth Lambda Listener Example Bolt App -1. You need an AWS account and your AWS credentials set up on your machine. -2. Make sure you have an AWS IAM Role defined with the needed permissions for - your Lambda function powering your Slack app: - - Head to the AWS IAM section of AWS Console - - Click Roles from the menu - - Click the Create Role button - - Under "Select type of trusted entity", choose "AWS service" - - Under "Choose a use case", select "Common use cases: Lambda" - - Click "Next: Permissions" - - Under "Attach permission policies", enter "lambda" in the Filter input - - Check the "AWSLambdaBasicExecutionRole" and "AWSLambdaExecute" policies - - Under "Attach permission policies", enter "s3" in the Filter input - - Check the "AWSS3FullAccess" policy - - Click "Next: tags" - - Click "Next: review" - - Enter `bolt_python_s3_storage` as the Role name. You can change this - if you want, but then make sure to update the role name in - `aws_lambda_oauth_config.yaml` - - Optionally enter a description for the role, such as "Bolt Python with S3 - access role" -3. Ensure you have created an app on api.slack.com/apps as per the [Getting - Started Guide](https://slack.dev/bolt-python/tutorial/getting-started). - You do not need to ensure you have installed it to a workspace, as the OAuth - flow will provide your app the ability to be installed by anyone. -4. You will need to create two S3 buckets: one to store installation credentials - (when a new Slack workspace installs your app) and one to store state - variables during the OAuth flow. You will need the names of these buckets in - the next step. -5. You need many environment variables exported! Specifically the following from - api.slack.com/apps: - - `SLACK_SIGNING_SECRET`: Signing Secret from Basic Information page - - `SLACK_CLIENT_ID`: Client ID from Basic Information page - - `SLACK_CLIENT_SECRET`: Client Secret from Basic Information page - - `SLACK_SCOPES="app_mentions:read,chat:write"`: Which scopes this application - needs - - `SLACK_INSTALLATION_S3_BUCKET_NAME`: The name of one of the S3 buckets you - created - - `SLACK_STATE_S3_BUCKET_NAME`: The name of the other S3 bucket you created -6. Let's deploy the Lambda! Run `./deploy_oauth.sh`. By default it deploys to the - us-east-1 region in AWS - you can change this at the top of `aws_lambda_oauth_config.yaml` if you wish. -7. Load up AWS Lambda inside the AWS Console - make sure you are in the correct - region that you deployed your app to. You should see a `bolt_py_oauth_function` - Lambda there. -8. While your Lambda exists, it is not accessible to the internet, so Slack - cannot send events happening in your Slack workspace to your Lambda. Let's - fix that by adding an AWS API Gateway in front of your Lambda so that your - Lambda can accept HTTP requests: - - Click on your `bolt_py_oauth_function` Lambda - - In the Function Overview, on the left side, click "+ Add Trigger" - - Select API Gateway from the trigger list - - Make sure "Create an API" is selected in the dropdown, and choose "HTTP API" - as the API Type - - Under Security, select "Open" - - Click "Add" -9. Congrats! Your Slack app is now accessible to the public. On the left side of - your `bolt_py_oauth_function` Function Overview you should see a purple API Gateway - icon. Click it. -10. Click Details to expand the details section. -11. Copy the API Endpoint - this is the URL your Lambda function is accessible - at publicly. -12. We will now inform Slack that this example app can accept Slash Commands. - - Back on api.slack.com/apps, select your app and choose Slash Commands from the left menu. - - Click Create New Command - - By default, the `aws_lambda_oauth.py` function has logic for a - `/hello-bolt-python-lambda` command. Enter `/hello-bolt-python-lambda` as - the Command. - - Under Request URL, paste in the previously-copied API Endpoint from API - Gateway. - - Click Save -13. We also need to register the API Endpoint as the OAuth redirect URL: - - Load up the "OAuth & Permissions" page on api.slack.com/apps - - Scroll down to Redirect URLs - - Copy the API endpoint in - but remove the path portion. The Redirect URL - needs to only _partially_ match where we will send users. -14. You can now install the app to any workspace! -15. Test it out! Once installed to a Slack workspace, try typing - `/hello-bolt-python-lambda hello`. -16. If you have issues, here are some debugging options: - - Check the Monitor tab under your Lambda. Did the Lambda get invoked? Did it - respond with an error? Investigate the graphs to see how your Lambda is - behaving. - - From this same Monitor tab, you can also click "View Logs in CloudWatch" to - see the execution logs for your Lambda. This can be helpful to see what - errors are being raised. +### Setup your AWS Account + Credentials +You need an AWS account and your AWS credentials set up on your machine. + +Once you’ve done that you should have access to AWS Console, which is what we’ll use for the rest of this tutorial. + +### Create S3 Buckets to store Installations and State + +1. Start by creating two S3 buckets: + 1. One to store installation credentials for each Slack workspace that installs your app. + 2. One to store state variables during the OAuth flow. +2. Head over to **Amazon S3** in the AWS Console +3. Give your bucket a name, region, and set access controls. If you’re doing this for the first time, it’s easiest to keep the defaults and edit them later as necessary. We'll be using the names: + 1. slack-installations-s3 + 2. slack-state-store-s3 +4. After your buckets are created, in each bucket’s page head over to “Properties” and save the Amazon Resource Name (ARN). It should look something like `arn:aws:s3:::slack-installations-s3`. + +### Create a Policy to Enable Actions on S3 Buckets +Now let's create a policy that will allow the holder of the policy to take actions in your S3 buckets. + +1. Head over to Identity and Access Management (IAM) in the AWS Console via Search Bar +2. Head to **Access Management > Policies** and select “Create Policy” +3. Click the JSON tab and copy this in: +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:Get*", + "s3:Put*", + "s3:Delete*", + "s3-object-lambda:*" + ], + "Resource": [ + "/*", // don't forget the `/*` + "/*" + ] + } + ] +} +``` +4. Edit “Resource” to include the ARNs of the two buckets you created in the earlier step. These need to exactly match the ARNS you copied earlier and end with a `/*` +5. Hit "Next:Tags" and "Next:Review" +6. Review policy + 1. Name your policy something memorable enough that you won’t have forgotten it 5 minutes from now when we’ll need to look it up from a list. (e.g. AmazonS3-FullAccess-SlackBuckets) + 2. Review the summary, and hit "Create Policy". Once the policy is created you should be redirected to the Policies page and see your new policy show up as Customer managed policy. + +### Setup an AWS IAM Role with Policies for Executing Your Lambda +Let’s create a user role that will use the custom policy we created as well as other policies to let us execute our lambda, write output logs to CloudWatch. + +1. Head to the **Identity and Access Management (IAM)** section of AWS Console +2. Select **Access Management > Roles** from the menu +3. Click "Create Role" +4. Step 1 - Select trusted entity + 1. Under "Select type of trusted entity", choose "AWS service" + 2. Under "Choose a use case", select "Common use cases: Lambda" + 3. Click "Next: Permissions” +5. Step 2 - Add permissions + 1. Add the following policies to the role we’re creating that will allow the user with the role permission to execute Lambda, make changes to their S3 Buckets, log output to CloudWatch + 1. `AWSLambdaExecute` + 2. `AWSLambdaBasicExecutionRole` + 3. `` +6. Step 3 - Name, review, create + 1. Enter `bolt_python_s3_storage` as your role name. To use a different name, make sure to update the role name in `aws_lambda_oauth_config.yaml` + 2. Optionally enter a description for the role, such as "Bolt Python with S3 access role” + 3. "Create Role" + +### Create Slack App and Load your Lambda to AWS +Ensure you have created an app on [api.slack.com/apps](https://api.slack.com/apps) as per the [Getting Started Guide](https://slack.dev/bolt-python/tutorial/getting-started). You do not need to ensure you have installed it to a workspace, as the OAuth flow will provide your app the ability to be installed by anyone. + +1. Remember those S3 buckets we made? You will need the names of these buckets again in the next step. +2. You need many environment variables exported! Specifically the following from api.slack.com/apps + +```bash +SLACK_SIGNING_SECRET= # Signing Secret from Basic Information page +SLACK_CLIENT_ID= # Client ID from Basic Information page +SLACK_CLIENT_SECRET # Client Secret from Basic Information page +SLACK_SCOPES= "app_mentions:read,chat:write" +SLACK_INSTALLATION_S3_BUCKET_NAME: # The name of installations bucket +SLACK_STATE_S3_BUCKET_NAME: # The name of the state store bucket +export +``` +6. Let's deploy the Lambda! Run `./deploy_oauth.sh`. By default it deploys to the us-east-1 region in AWS - you can customize this in `aws_lambda_oauth_config.yaml`. +7. Load up AWS Lambda inside the AWS Console - make sure you are in the correct region that you deployed your app to. You should see a `bolt_py_oauth_function` Lambda there. + +### Set up AWS API Gateway +Your Lambda exists, but it is not accessible to the internet, so Slack cannot yet send events happening in your Slack workspace to your Lambda. Let's fix that by adding an AWS API Gateway in front of your Lambda so that your Lambda can accept HTTP requests + +1. Click on your `bolt_py_oauth_function` Lambda +2. In the **Function Overview**, on the left side, click "+ Add Trigger" +3. Select "API Gateway" from the trigger list +4. Make sure "Create an API" is selected in the dropdown, and choose "HTTP API" as the API Type +5. Under Security, select "Open" +6. Click "Add" + +Phew, congrats! Your Slack app is now accessible to the public. On the left side of your bolt_py_oauth_function Function Overview you should see a purple API Gateway icon. Click it. + +1. Click "Details" +2. Copy the API Endpoint - this is the URL your Lambda function is accessible at publicly. +3. We will now inform Slack that this example app can accept Slash Commands. +4. Back on [api.slack.com/apps](https://api.slack.com/apps), select your app and choose "Slash Commands" from the left menu. +5. Click "Create New Command" + 1. By default, the `aws_lambda_oauth.py` function has logic for a /hello-bolt-python-lambda command. Enter `/hello-bolt-python-lambda` as the Command. + * Under **Request URL**, paste in the previously-copied API Endpoint from API Gateway. + * Click "Save" +6. We also need to register the API Endpoint as the OAuth redirect URL: + 1. Load up the **OAuth & Permissions** page on[api.slack.com/apps](https://api.slack.com/apps) + 2. Scroll down to "Redirect URLs" + 3. Copy the API endpoint in - but remove the path portion. The Redirect URL needs to only partially match where we will send users. + +You can now install the app to any workspace! + +### Test it out! +1. Once installed to a Slack workspace, try typing `/hello-bolt-python-lambda` hello. +2. If you have issues, here are some debugging options: + 1. _View lambda activity_: Head to the Monitor tab under your Lambda. Did the Lambda get invoked? Did it respond with an error? Investigate the graphs to see how your Lambda is behaving. + 2. _Check out the logs_: From this same Monitor tab, you can also click "View Logs in CloudWatch" to see the execution logs for your Lambda. This can be helpful to see what errors are being raised. From cc194b7e782b5a69a76fd443affaa6be65f030f1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 23 Mar 2022 14:58:26 +0900 Subject: [PATCH 466/865] Fix #622 by adding Socket Mode healthcheck endpoint examples (#623) --- examples/socket_mode_async_healthcheck.py | 56 +++++++++++++++++++++++ examples/socket_mode_healthcheck.py | 50 ++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 examples/socket_mode_async_healthcheck.py create mode 100644 examples/socket_mode_healthcheck.py diff --git a/examples/socket_mode_async_healthcheck.py b/examples/socket_mode_async_healthcheck.py new file mode 100644 index 000000000..26de6b786 --- /dev/null +++ b/examples/socket_mode_async_healthcheck.py @@ -0,0 +1,56 @@ +import logging +import os +from typing import Optional + +from slack_sdk.socket_mode.aiohttp import SocketModeClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + +logging.basicConfig(level=logging.DEBUG) + +# +# Socket Mode Bolt app +# + +# Install the Slack app and get xoxb- token in advance +app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) +socket_mode_client: Optional[SocketModeClient] = None + + +@app.event("app_mention") +async def event_test(event, say): + await say(f"Hi there, <@{event['user']}>!") + + +# +# Web app for hosting the healthcheck endpoint for k8s etc. +# + +from aiohttp import web + + +async def healthcheck(_req: web.Request): + if socket_mode_client is not None and socket_mode_client.is_connected(): + return web.Response(status=200, text="OK") + return web.Response(status=503, text="The Socket Mode client is inactive") + + +web_app = app.web_app() +web_app.add_routes([web.get("/health", healthcheck)]) + + +# +# Start the app +# + +if __name__ == "__main__": + + async def start_socket_mode(_web_app: web.Application): + handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + await handler.connect_async() + global socket_mode_client + socket_mode_client = handler.client + + app.web_app().on_startup.append(start_socket_mode) + app.start(8080) diff --git a/examples/socket_mode_healthcheck.py b/examples/socket_mode_healthcheck.py new file mode 100644 index 000000000..b76e14b87 --- /dev/null +++ b/examples/socket_mode_healthcheck.py @@ -0,0 +1,50 @@ +import logging +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +logging.basicConfig(level=logging.DEBUG) + +# +# Socket Mode Bolt app +# + +# Install the Slack app and get xoxb- token in advance +app = App(token=os.environ["SLACK_BOT_TOKEN"]) +socket_mode_handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + + +@app.event("app_mention") +def event_test(event, say): + say(f"Hi there, <@{event['user']}>!") + + +# +# Web app for hosting the healthcheck endpoint for k8s etc. +# + +# pip install Flask +from flask import Flask, make_response + +flask_app = Flask(__name__) + + +@flask_app.route("/health", methods=["GET"]) +def slack_events(): + if ( + socket_mode_handler.client is not None + and socket_mode_handler.client.is_connected() + ): + return make_response("OK", 200) + return make_response("The Socket Mode client is inactive", 503) + + +# +# Start the app +# +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** + +if __name__ == "__main__": + socket_mode_handler.connect() # does not block the current thread + flask_app.run(port=8080) From 5a42d8c9c2b83793becb18cee1cbb59e7b9e1845 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 24 Mar 2022 11:41:55 +0900 Subject: [PATCH 467/865] Improve AIOHTTP example code --- examples/socket_mode_async_healthcheck.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/socket_mode_async_healthcheck.py b/examples/socket_mode_async_healthcheck.py index 26de6b786..2f183a462 100644 --- a/examples/socket_mode_async_healthcheck.py +++ b/examples/socket_mode_async_healthcheck.py @@ -52,5 +52,9 @@ async def start_socket_mode(_web_app: web.Application): global socket_mode_client socket_mode_client = handler.client - app.web_app().on_startup.append(start_socket_mode) - app.start(8080) + async def shutdown_socket_mode(_web_app: web.Application): + await socket_mode_client.close() + + web_app.on_startup.append(start_socket_mode) + web_app.on_shutdown.append(shutdown_socket_mode) + web.run_app(app=web_app, port=8080) From 1b10a83fddfb24584fd16b75f893d0292e0b4970 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 4 Apr 2022 07:10:10 +0900 Subject: [PATCH 468/865] Fix build errors due to click, jinja2 breaking changes --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 811f907cf..c8f95dec8 100755 --- a/setup.py +++ b/setup.py @@ -18,7 +18,9 @@ "Flask-Sockets>=0.2,<1", # TODO: This module is not yet Flask 2.x compatible "Werkzeug>=1,<2", # TODO: Flask-Sockets is not yet compatible with Flask 2.x "itsdangerous==2.0.1", # TODO: Flask-Sockets is not yet compatible with Flask 2.x - "black==22.1.0", + "Jinja2==3.0.3", # https://github.com/pallets/flask/issues/4494 + "black==22.3.0", + "click<=8.0.4", # black is affected by https://github.com/pallets/click/issues/2225 ] adapter_test_dependencies = [ From 02c35203d17f3c30a42bb4ed8a765cab3e1ab8ec Mon Sep 17 00:00:00 2001 From: Fil Maj Date: Wed, 13 Apr 2022 17:16:04 -0400 Subject: [PATCH 469/865] Update AWS example documentation with correct AWS Lambda roles required to run. Fixes #632. (#633) --- examples/aws_lambda/README.md | 25 +++++++++++++------------ examples/aws_lambda/aws_lambda_oauth.py | 2 ++ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/examples/aws_lambda/README.md b/examples/aws_lambda/README.md index 6b486d028..e78a25571 100644 --- a/examples/aws_lambda/README.md +++ b/examples/aws_lambda/README.md @@ -24,7 +24,7 @@ Instructions on how to set up and deploy each example are provided below. - Under "Choose a use case", select "Common use cases: Lambda" - Click "Next: Permissions" - Under "Attach permission policies", enter "lambda" in the Filter input - - Check the "AWSLambdaBasicExecutionRole" and "AWSLambdaExecute" policies + - Check the "AWSLambdaBasicExecutionRole", "AWSLambdaExecute" and "AWSLambdaRole" policies - Click "Next: tags" - Click "Next: review" - Enter `bolt_python_lambda_invocation` as the Role name. You can change this @@ -86,17 +86,17 @@ Instructions on how to set up and deploy each example are provided below. ## OAuth Lambda Listener Example Bolt App ### Setup your AWS Account + Credentials -You need an AWS account and your AWS credentials set up on your machine. +You need an AWS account and your AWS credentials set up on your machine. Once you’ve done that you should have access to AWS Console, which is what we’ll use for the rest of this tutorial. ### Create S3 Buckets to store Installations and State -1. Start by creating two S3 buckets: +1. Start by creating two S3 buckets: 1. One to store installation credentials for each Slack workspace that installs your app. 2. One to store state variables during the OAuth flow. 2. Head over to **Amazon S3** in the AWS Console -3. Give your bucket a name, region, and set access controls. If you’re doing this for the first time, it’s easiest to keep the defaults and edit them later as necessary. We'll be using the names: +3. Give your bucket a name, region, and set access controls. If you’re doing this for the first time, it’s easiest to keep the defaults and edit them later as necessary. We'll be using the names: 1. slack-installations-s3 2. slack-state-store-s3 4. After your buckets are created, in each bucket’s page head over to “Properties” and save the Amazon Resource Name (ARN). It should look something like `arn:aws:s3:::slack-installations-s3`. @@ -121,7 +121,7 @@ Now let's create a policy that will allow the holder of the policy to take actio ], "Resource": [ "/*", // don't forget the `/*` - "/*" + "/*" ] } ] @@ -130,8 +130,8 @@ Now let's create a policy that will allow the holder of the policy to take actio 4. Edit “Resource” to include the ARNs of the two buckets you created in the earlier step. These need to exactly match the ARNS you copied earlier and end with a `/*` 5. Hit "Next:Tags" and "Next:Review" 6. Review policy - 1. Name your policy something memorable enough that you won’t have forgotten it 5 minutes from now when we’ll need to look it up from a list. (e.g. AmazonS3-FullAccess-SlackBuckets) - 2. Review the summary, and hit "Create Policy". Once the policy is created you should be redirected to the Policies page and see your new policy show up as Customer managed policy. + 1. Name your policy something memorable enough that you won’t have forgotten it 5 minutes from now when we’ll need to look it up from a list. (e.g. AmazonS3-FullAccess-SlackBuckets) + 2. Review the summary, and hit "Create Policy". Once the policy is created you should be redirected to the Policies page and see your new policy show up as Customer managed policy. ### Setup an AWS IAM Role with Policies for Executing Your Lambda Let’s create a user role that will use the custom policy we created as well as other policies to let us execute our lambda, write output logs to CloudWatch. @@ -142,12 +142,13 @@ Let’s create a user role that will use the custom policy we created as well as 4. Step 1 - Select trusted entity 1. Under "Select type of trusted entity", choose "AWS service" 2. Under "Choose a use case", select "Common use cases: Lambda" - 3. Click "Next: Permissions” + 3. Click "Next: Permissions" 5. Step 2 - Add permissions 1. Add the following policies to the role we’re creating that will allow the user with the role permission to execute Lambda, make changes to their S3 Buckets, log output to CloudWatch 1. `AWSLambdaExecute` 2. `AWSLambdaBasicExecutionRole` - 3. `` + 3. `AWSLambdaRole` + 4. `` 6. Step 3 - Name, review, create 1. Enter `bolt_python_s3_storage` as your role name. To use a different name, make sure to update the role name in `aws_lambda_oauth_config.yaml` 2. Optionally enter a description for the role, such as "Bolt Python with S3 access role” @@ -166,12 +167,12 @@ SLACK_CLIENT_SECRET # Client Secret from Basic Information page SLACK_SCOPES= "app_mentions:read,chat:write" SLACK_INSTALLATION_S3_BUCKET_NAME: # The name of installations bucket SLACK_STATE_S3_BUCKET_NAME: # The name of the state store bucket -export +export ``` 6. Let's deploy the Lambda! Run `./deploy_oauth.sh`. By default it deploys to the us-east-1 region in AWS - you can customize this in `aws_lambda_oauth_config.yaml`. 7. Load up AWS Lambda inside the AWS Console - make sure you are in the correct region that you deployed your app to. You should see a `bolt_py_oauth_function` Lambda there. -### Set up AWS API Gateway +### Set up AWS API Gateway Your Lambda exists, but it is not accessible to the internet, so Slack cannot yet send events happening in your Slack workspace to your Lambda. Let's fix that by adding an AWS API Gateway in front of your Lambda so that your Lambda can accept HTTP requests 1. Click on your `bolt_py_oauth_function` Lambda @@ -198,7 +199,7 @@ Phew, congrats! Your Slack app is now accessible to the public. On the left side You can now install the app to any workspace! -### Test it out! +### Test it out! 1. Once installed to a Slack workspace, try typing `/hello-bolt-python-lambda` hello. 2. If you have issues, here are some debugging options: 1. _View lambda activity_: Head to the Monitor tab under your Lambda. Did the Lambda get invoked? Did it respond with an error? Investigate the graphs to see how your Lambda is behaving. diff --git a/examples/aws_lambda/aws_lambda_oauth.py b/examples/aws_lambda/aws_lambda_oauth.py index f30609a1c..06b2572a6 100644 --- a/examples/aws_lambda/aws_lambda_oauth.py +++ b/examples/aws_lambda/aws_lambda_oauth.py @@ -42,6 +42,8 @@ def handler(event, context): # AWS IAM Role: bolt_python_s3_storage # - AmazonS3FullAccess # - AWSLambdaBasicExecutionRole +# - AWSLambdaExecute +# - AWSLambdaRole # rm -rf latest_slack_bolt && cp -pr ../../src latest_slack_bolt # pip install python-lambda From 0184e21201c7cfdbb4ed413e5afa5d68750a773f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 22 Apr 2022 22:53:33 +0900 Subject: [PATCH 470/865] Upgrade pytype version to the latest (#636) --- scripts/run_pytype.sh | 2 +- slack_bolt/app/app.py | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index 267fb8efe..780bf87fc 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -5,5 +5,5 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ pip install -e ".[async]" && \ pip install -e ".[adapter]" && \ - pip install "pytype==2022.3.8" && \ + pip install "pytype==2022.4.15" && \ pytype slack_bolt/ diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 7ee3fb096..c94cbfebe 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -1453,13 +1453,19 @@ def do_GET(self): request_path, _, query = self.path.partition("?") if request_path == _bolt_oauth_flow.install_path: bolt_req = BoltRequest( - body="", query=query, headers=self.headers + body="", + query=query, + # email.message.Message's mapping interface is dict compatible + headers=self.headers, # type:ignore ) bolt_resp = _bolt_oauth_flow.handle_installation(bolt_req) self._send_bolt_response(bolt_resp) elif request_path == _bolt_oauth_flow.redirect_uri_path: bolt_req = BoltRequest( - body="", query=query, headers=self.headers + body="", + query=query, + # email.message.Message's mapping interface is dict compatible + headers=self.headers, # type:ignore ) bolt_resp = _bolt_oauth_flow.handle_callback(bolt_req) self._send_bolt_response(bolt_resp) @@ -1477,7 +1483,10 @@ def do_POST(self): len_header = self.headers.get("Content-Length") or 0 request_body = self.rfile.read(int(len_header)).decode("utf-8") bolt_req = BoltRequest( - body=request_body, query=query, headers=self.headers + body=request_body, + query=query, + # email.message.Message's mapping interface is dict compatible + headers=self.headers, # type:ignore ) bolt_resp: BoltResponse = _bolt_app.dispatch(bolt_req) self._send_bolt_response(bolt_resp) @@ -1503,7 +1512,7 @@ def _send_response( for k, vs in headers.items(): for v in vs: self.send_header(k, v) - self.send_header("Content-Length", len(body_bytes)) + self.send_header("Content-Length", str(len(body_bytes))) self.end_headers() self.wfile.write(body_bytes) From 8df95d5c0e18228dd58904e36a64d83ad7d6a627 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 23 Apr 2022 20:12:56 +0900 Subject: [PATCH 471/865] Remove a test suite that is no longer relevant --- tests/scenario_tests/test_app.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index 61d86659c..e7c105ea3 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -47,12 +47,6 @@ def handle_app_mention(body, say: Say, payload, event): # basic tests # -------------------------- - def test_signing_secret_absence(self): - with pytest.raises(BoltError): - App(signing_secret=None, token="xoxb-xxx") - with pytest.raises(BoltError): - App(signing_secret="", token="xoxb-xxx") - def simple_listener(self, ack): ack() From 319d000b96618bf3285e79f5ebd267c50b5c8615 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 25 Apr 2022 09:23:03 -0700 Subject: [PATCH 472/865] Enable Flake8 in the CI builds (#638) Co-authored-by: Fil Maj --- .flake8 | 3 +++ .github/workflows/flake8.yml | 23 +++++++++++++++++++ examples/django/myslackapp/urls.py | 2 +- examples/django/simple_app/models.py | 2 +- examples/getting_started/app.py | 1 + examples/google_cloud_functions/main.py | 1 + .../workflow_steps/async_steps_from_apps.py | 6 ++--- .../async_steps_from_apps_decorator.py | 2 +- .../async_steps_from_apps_primitive.py | 2 +- examples/workflow_steps/steps_from_apps.py | 2 +- .../steps_from_apps_decorator.py | 2 +- .../steps_from_apps_primitive.py | 2 +- scripts/run_flake8.sh | 7 ++++++ slack_bolt/__init__.py | 4 ++-- slack_bolt/adapter/aws_lambda/__init__.py | 2 +- slack_bolt/adapter/bottle/__init__.py | 2 +- slack_bolt/adapter/cherrypy/__init__.py | 2 +- slack_bolt/adapter/django/__init__.py | 2 +- slack_bolt/adapter/django/handler.py | 4 ++-- slack_bolt/adapter/falcon/__init__.py | 2 +- slack_bolt/adapter/flask/__init__.py | 2 +- slack_bolt/adapter/pyramid/__init__.py | 2 +- slack_bolt/adapter/sanic/__init__.py | 2 +- slack_bolt/adapter/socket_mode/__init__.py | 4 ++-- slack_bolt/adapter/starlette/__init__.py | 2 +- slack_bolt/adapter/tornado/__init__.py | 2 +- slack_bolt/app/__init__.py | 2 +- slack_bolt/async_app.py | 2 +- slack_bolt/authorization/__init__.py | 2 +- slack_bolt/context/__init__.py | 2 +- slack_bolt/context/ack/__init__.py | 2 +- slack_bolt/context/ack/internals.py | 2 +- slack_bolt/context/respond/__init__.py | 2 +- slack_bolt/context/say/__init__.py | 2 +- slack_bolt/kwargs_injection/__init__.py | 4 ++-- slack_bolt/kwargs_injection/utils.py | 2 +- slack_bolt/lazy_listener/async_runner.py | 2 +- slack_bolt/listener/builtins.py | 2 -- slack_bolt/listener_matcher/builtins.py | 3 +-- slack_bolt/logger/messages.py | 5 +++- slack_bolt/middleware/__init__.py | 17 ++++++++------ slack_bolt/middleware/async_builtins.py | 16 ++++++------- .../middleware/authorization/__init__.py | 6 ++--- slack_bolt/middleware/ssl_check/ssl_check.py | 2 +- slack_bolt/oauth/internals.py | 2 +- slack_bolt/request/__init__.py | 2 +- slack_bolt/response/__init__.py | 2 +- slack_bolt/workflows/step/__init__.py | 12 +++++----- slack_bolt/workflows/step/async_step.py | 6 ++--- slack_bolt/workflows/step/step.py | 6 ++--- 50 files changed, 115 insertions(+), 77 deletions(-) create mode 100644 .flake8 create mode 100644 .github/workflows/flake8.yml create mode 100755 scripts/run_flake8.sh diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..9960c210e --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 125 +ignore = F841,F821,W503,E402 diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml new file mode 100644 index 000000000..74a0d86b8 --- /dev/null +++ b/.github/workflows/flake8.yml @@ -0,0 +1,23 @@ +name: Run flake8 validation + +on: + push: + branches: [ main ] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + matrix: + python-version: ['3.9'] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Run flake8 verification + run: | + ./scripts/run_flake8.sh diff --git a/examples/django/myslackapp/urls.py b/examples/django/myslackapp/urls.py index 754cee871..f3d5c7268 100644 --- a/examples/django/myslackapp/urls.py +++ b/examples/django/myslackapp/urls.py @@ -13,7 +13,7 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.contrib import admin +from django.contrib import admin # noqa: F401 from django.urls import path # Set this flag to False if you want to enable oauth_app instead diff --git a/examples/django/simple_app/models.py b/examples/django/simple_app/models.py index 71a836239..82c4e7854 100644 --- a/examples/django/simple_app/models.py +++ b/examples/django/simple_app/models.py @@ -1,3 +1,3 @@ -from django.db import models +from django.db import models # noqa: F401 # Create your models here. diff --git a/examples/getting_started/app.py b/examples/getting_started/app.py index 96944a594..22cdf5f31 100644 --- a/examples/getting_started/app.py +++ b/examples/getting_started/app.py @@ -7,6 +7,7 @@ signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), ) + # Listens to incoming messages that contain "hello" # To learn available listener method arguments, # visit https://slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html diff --git a/examples/google_cloud_functions/main.py b/examples/google_cloud_functions/main.py index 633efb67b..c9f911a90 100644 --- a/examples/google_cloud_functions/main.py +++ b/examples/google_cloud_functions/main.py @@ -26,6 +26,7 @@ def event_test(body, say, logger): handler = SlackRequestHandler(app) + # Cloud Function def hello_bolt_app(request): """HTTP Cloud Function. diff --git a/examples/workflow_steps/async_steps_from_apps.py b/examples/workflow_steps/async_steps_from_apps.py index 06307be7d..e8831a113 100644 --- a/examples/workflow_steps/async_steps_from_apps.py +++ b/examples/workflow_steps/async_steps_from_apps.py @@ -28,7 +28,7 @@ async def edit(ack: AsyncAck, step: dict, configure: AsyncConfigure): "block_id": "intro-section", "text": { "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 }, }, { @@ -155,8 +155,8 @@ async def execute( "blocks": blocks, }, ) - except: - await fail(error={"message": "Something wrong!"}) + except Exception as e: + await fail(error={"message": f"Something wrong! (error: {e})"}) app.step( diff --git a/examples/workflow_steps/async_steps_from_apps_decorator.py b/examples/workflow_steps/async_steps_from_apps_decorator.py index 7c88a39a0..64e45a881 100644 --- a/examples/workflow_steps/async_steps_from_apps_decorator.py +++ b/examples/workflow_steps/async_steps_from_apps_decorator.py @@ -33,7 +33,7 @@ async def edit(ack: AsyncAck, step: dict, configure: AsyncConfigure): "block_id": "intro-section", "text": { "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 }, }, { diff --git a/examples/workflow_steps/async_steps_from_apps_primitive.py b/examples/workflow_steps/async_steps_from_apps_primitive.py index a343d0005..8d585be5a 100644 --- a/examples/workflow_steps/async_steps_from_apps_primitive.py +++ b/examples/workflow_steps/async_steps_from_apps_primitive.py @@ -27,7 +27,7 @@ async def edit(body: dict, ack: AsyncAck, client: AsyncWebClient): "block_id": "intro-section", "text": { "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 }, }, { diff --git a/examples/workflow_steps/steps_from_apps.py b/examples/workflow_steps/steps_from_apps.py index 147aa5d05..43e47c38d 100644 --- a/examples/workflow_steps/steps_from_apps.py +++ b/examples/workflow_steps/steps_from_apps.py @@ -31,7 +31,7 @@ def edit(ack: Ack, step, configure: Configure): "block_id": "intro-section", "text": { "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 }, }, { diff --git a/examples/workflow_steps/steps_from_apps_decorator.py b/examples/workflow_steps/steps_from_apps_decorator.py index 6ccccd975..0a8802731 100644 --- a/examples/workflow_steps/steps_from_apps_decorator.py +++ b/examples/workflow_steps/steps_from_apps_decorator.py @@ -35,7 +35,7 @@ def edit(ack: Ack, step, configure: Configure): "block_id": "intro-section", "text": { "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 }, }, { diff --git a/examples/workflow_steps/steps_from_apps_primitive.py b/examples/workflow_steps/steps_from_apps_primitive.py index 96ad0c97b..175bcde98 100644 --- a/examples/workflow_steps/steps_from_apps_primitive.py +++ b/examples/workflow_steps/steps_from_apps_primitive.py @@ -29,7 +29,7 @@ def edit(body: dict, ack: Ack, client: WebClient): "block_id": "intro-section", "text": { "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 }, }, { diff --git a/scripts/run_flake8.sh b/scripts/run_flake8.sh new file mode 100755 index 000000000..d9ec07ff6 --- /dev/null +++ b/scripts/run_flake8.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# ./scripts/run_flake8.sh + +script_dir=$(dirname $0) +cd ${script_dir}/.. && \ + pip install "flake8==4.0.1" && \ + flake8 slack_bolt/ && flake8 examples/ diff --git a/slack_bolt/__init__.py b/slack_bolt/__init__.py index 20563758d..4050d83a9 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -1,10 +1,10 @@ """ -A Python framework to build Slack apps in a flash with the latest platform features. Read the [getting started guide](https://slack.dev/bolt-python/tutorial/getting-started) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. +A Python framework to build Slack apps in a flash with the latest platform features.Read the [getting started guide](https://slack.dev/bolt-python/tutorial/getting-started) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. * Website: https://slack.dev/bolt-python/ * GitHub repository: https://github.com/slackapi/bolt-python * The class representing a Bolt app: `slack_bolt.app.app` -""" +""" # noqa: E501 # Don't add async module imports here from .app import App # noqa from .context import BoltContext # noqa diff --git a/slack_bolt/adapter/aws_lambda/__init__.py b/slack_bolt/adapter/aws_lambda/__init__.py index f08c97a5f..df8fd9d79 100644 --- a/slack_bolt/adapter/aws_lambda/__init__.py +++ b/slack_bolt/adapter/aws_lambda/__init__.py @@ -1 +1 @@ -from .handler import SlackRequestHandler +from .handler import SlackRequestHandler # noqa: F401 diff --git a/slack_bolt/adapter/bottle/__init__.py b/slack_bolt/adapter/bottle/__init__.py index f08c97a5f..df8fd9d79 100644 --- a/slack_bolt/adapter/bottle/__init__.py +++ b/slack_bolt/adapter/bottle/__init__.py @@ -1 +1 @@ -from .handler import SlackRequestHandler +from .handler import SlackRequestHandler # noqa: F401 diff --git a/slack_bolt/adapter/cherrypy/__init__.py b/slack_bolt/adapter/cherrypy/__init__.py index f08c97a5f..df8fd9d79 100644 --- a/slack_bolt/adapter/cherrypy/__init__.py +++ b/slack_bolt/adapter/cherrypy/__init__.py @@ -1 +1 @@ -from .handler import SlackRequestHandler +from .handler import SlackRequestHandler # noqa: F401 diff --git a/slack_bolt/adapter/django/__init__.py b/slack_bolt/adapter/django/__init__.py index f08c97a5f..df8fd9d79 100644 --- a/slack_bolt/adapter/django/__init__.py +++ b/slack_bolt/adapter/django/__init__.py @@ -1 +1 @@ -from .handler import SlackRequestHandler +from .handler import SlackRequestHandler # noqa: F401 diff --git a/slack_bolt/adapter/django/handler.py b/slack_bolt/adapter/django/handler.py index 7d86adfbe..5a4ffe71e 100644 --- a/slack_bolt/adapter/django/handler.py +++ b/slack_bolt/adapter/django/handler.py @@ -142,8 +142,8 @@ def __init__(self, app: App): # type: ignore # it's okay to skip calling the same connection clean-up method at the listener completion. message = """As you've already set app.listener_runner.listener_start_handler to your own one, Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerStartHandler. - - If you go with your own handler here, we highly recommend having the following lines of code + + If you go with your own handler here, we highly recommend having the following lines of code in your handle() method to clean up unmanaged stale/old database connections: from django.db import close_old_connections diff --git a/slack_bolt/adapter/falcon/__init__.py b/slack_bolt/adapter/falcon/__init__.py index e1a06662c..e54d2f1fc 100644 --- a/slack_bolt/adapter/falcon/__init__.py +++ b/slack_bolt/adapter/falcon/__init__.py @@ -1,2 +1,2 @@ # Don't add async module imports here -from .resource import SlackAppResource +from .resource import SlackAppResource # noqa: F401 diff --git a/slack_bolt/adapter/flask/__init__.py b/slack_bolt/adapter/flask/__init__.py index f08c97a5f..df8fd9d79 100644 --- a/slack_bolt/adapter/flask/__init__.py +++ b/slack_bolt/adapter/flask/__init__.py @@ -1 +1 @@ -from .handler import SlackRequestHandler +from .handler import SlackRequestHandler # noqa: F401 diff --git a/slack_bolt/adapter/pyramid/__init__.py b/slack_bolt/adapter/pyramid/__init__.py index f08c97a5f..df8fd9d79 100644 --- a/slack_bolt/adapter/pyramid/__init__.py +++ b/slack_bolt/adapter/pyramid/__init__.py @@ -1 +1 @@ -from .handler import SlackRequestHandler +from .handler import SlackRequestHandler # noqa: F401 diff --git a/slack_bolt/adapter/sanic/__init__.py b/slack_bolt/adapter/sanic/__init__.py index cf7d5ec69..02ee77873 100644 --- a/slack_bolt/adapter/sanic/__init__.py +++ b/slack_bolt/adapter/sanic/__init__.py @@ -1 +1 @@ -from .async_handler import AsyncSlackRequestHandler +from .async_handler import AsyncSlackRequestHandler # noqa: F401 diff --git a/slack_bolt/adapter/socket_mode/__init__.py b/slack_bolt/adapter/socket_mode/__init__.py index fbb17b0f7..0a00e1c11 100644 --- a/slack_bolt/adapter/socket_mode/__init__.py +++ b/slack_bolt/adapter/socket_mode/__init__.py @@ -4,7 +4,7 @@ * `slack_bolt.adapter.socket_mode.websocket_client` * `slack_bolt.adapter.socket_mode.aiohttp` * `slack_bolt.adapter.socket_mode.websockets` -""" +""" # noqa: E501 # Don't add async module imports here -from .builtin import SocketModeHandler # noqa +from .builtin import SocketModeHandler # noqa: F401 diff --git a/slack_bolt/adapter/starlette/__init__.py b/slack_bolt/adapter/starlette/__init__.py index 118f4bab1..848662494 100644 --- a/slack_bolt/adapter/starlette/__init__.py +++ b/slack_bolt/adapter/starlette/__init__.py @@ -1,2 +1,2 @@ # Don't add async module imports here -from .handler import SlackRequestHandler +from .handler import SlackRequestHandler # noqa: F401 diff --git a/slack_bolt/adapter/tornado/__init__.py b/slack_bolt/adapter/tornado/__init__.py index dfda87bf3..fa4896201 100644 --- a/slack_bolt/adapter/tornado/__init__.py +++ b/slack_bolt/adapter/tornado/__init__.py @@ -1 +1 @@ -from .handler import SlackEventsHandler, SlackOAuthHandler +from .handler import SlackEventsHandler, SlackOAuthHandler # noqa: F401 diff --git a/slack_bolt/app/__init__.py b/slack_bolt/app/__init__.py index de3ea4c23..b99c2c430 100644 --- a/slack_bolt/app/__init__.py +++ b/slack_bolt/app/__init__.py @@ -6,4 +6,4 @@ """ # Don't add async module imports here -from .app import App # type: ignore +from .app import App # noqa: F401 type: ignore diff --git a/slack_bolt/async_app.py b/slack_bolt/async_app.py index 5b17d8251..ed924f13b 100644 --- a/slack_bolt/async_app.py +++ b/slack_bolt/async_app.py @@ -43,7 +43,7 @@ async def command(ack, body, respond): Apps can be run the same way as the synchronous example above. If you'd prefer another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at [the built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) and their corresponding [examples](https://github.com/slackapi/bolt-python/tree/main/examples). Refer to `slack_bolt.app.async_app` for more details. -""" +""" # noqa: E501 from .app.async_app import AsyncApp # noqa from .context.ack.async_ack import AsyncAck # noqa from .context.async_context import AsyncBoltContext # noqa diff --git a/slack_bolt/authorization/__init__.py b/slack_bolt/authorization/__init__.py index 2e7e988f3..9ea3c8da5 100644 --- a/slack_bolt/authorization/__init__.py +++ b/slack_bolt/authorization/__init__.py @@ -3,4 +3,4 @@ Refer to https://slack.dev/bolt-python/concepts#authorization for details. """ -from .authorize_result import AuthorizeResult +from .authorize_result import AuthorizeResult # noqa diff --git a/slack_bolt/context/__init__.py b/slack_bolt/context/__init__.py index fabbfae98..951e00828 100644 --- a/slack_bolt/context/__init__.py +++ b/slack_bolt/context/__init__.py @@ -6,4 +6,4 @@ """ # Don't add async module imports here -from .context import BoltContext +from .context import BoltContext # noqa: F401 diff --git a/slack_bolt/context/ack/__init__.py b/slack_bolt/context/ack/__init__.py index 2f150ce24..8e5767113 100644 --- a/slack_bolt/context/ack/__init__.py +++ b/slack_bolt/context/ack/__init__.py @@ -1,2 +1,2 @@ # Don't add async module imports here -from .ack import Ack +from .ack import Ack # noqa: F401 diff --git a/slack_bolt/context/ack/internals.py b/slack_bolt/context/ack/internals.py index 965033b77..1a9ca92e8 100644 --- a/slack_bolt/context/ack/internals.py +++ b/slack_bolt/context/ack/internals.py @@ -61,7 +61,7 @@ def _set_response( ) else: raise ValueError( - f"errors field is required for response_action: errors" + "errors field is required for response_action: errors" ) else: body = {"response_action": response_action} diff --git a/slack_bolt/context/respond/__init__.py b/slack_bolt/context/respond/__init__.py index 2b240ebab..135f87277 100644 --- a/slack_bolt/context/respond/__init__.py +++ b/slack_bolt/context/respond/__init__.py @@ -1,2 +1,2 @@ # Don't add async module imports here -from .respond import Respond +from .respond import Respond # noqa: F401 diff --git a/slack_bolt/context/say/__init__.py b/slack_bolt/context/say/__init__.py index 828ad8877..82dad6a7f 100644 --- a/slack_bolt/context/say/__init__.py +++ b/slack_bolt/context/say/__init__.py @@ -1,2 +1,2 @@ # Don't add async module imports here -from .say import Say +from .say import Say # noqa: F401 diff --git a/slack_bolt/kwargs_injection/__init__.py b/slack_bolt/kwargs_injection/__init__.py index 8d8d1e6ac..56e1fa53e 100644 --- a/slack_bolt/kwargs_injection/__init__.py +++ b/slack_bolt/kwargs_injection/__init__.py @@ -5,5 +5,5 @@ """ # Don't add async module imports here -from .args import Args -from .utils import build_required_kwargs +from .args import Args # noqa: F401 +from .utils import build_required_kwargs # noqa: F401 diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index f0ad186a4..617e4de03 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -1,7 +1,7 @@ # pytype: skip-file import inspect import logging -from typing import Callable, Dict, Optional, Any, Sequence, List +from typing import Callable, Dict, Optional, Any, Sequence from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse diff --git a/slack_bolt/lazy_listener/async_runner.py b/slack_bolt/lazy_listener/async_runner.py index b98eb5492..a42098e06 100644 --- a/slack_bolt/lazy_listener/async_runner.py +++ b/slack_bolt/lazy_listener/async_runner.py @@ -1,6 +1,6 @@ from abc import abstractmethod, ABCMeta from logging import Logger -from typing import Callable, Awaitable, Any, Coroutine +from typing import Callable, Awaitable from slack_bolt.lazy_listener.async_internals import to_runnable_function from slack_bolt.request.async_request import AsyncBoltRequest diff --git a/slack_bolt/listener/builtins.py b/slack_bolt/listener/builtins.py index 66784d182..ee5891f27 100644 --- a/slack_bolt/listener/builtins.py +++ b/slack_bolt/listener/builtins.py @@ -1,5 +1,3 @@ -from slack_sdk.oauth import InstallationStore - from slack_bolt.context.context import BoltContext from slack_sdk.oauth.installation_store.installation_store import InstallationStore diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 043a497fa..ecac0012a 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -29,8 +29,7 @@ from re import _pattern_type as Pattern else: from re import Pattern -from typing import Callable, Awaitable, Any, Sequence, Optional, Union -from typing import Union, Optional, Dict +from typing import Callable, Awaitable, Any, Sequence, Optional, Union, Dict from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.request import BoltRequest diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 4920991b8..b9e94b52b 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -348,4 +348,7 @@ def debug_return_listener_middleware_response( listener_name: str, status: int, body: str, starting_time: float ) -> str: millis = int((time.time() - starting_time) * 1000) - return f"Responding with listener middleware's response - listener: {listener_name}, status: {status}, body: {body} ({millis} millis)" + return ( + "Responding with listener middleware's response - " + f"listener: {listener_name}, status: {status}, body: {body} ({millis} millis)" + ) diff --git a/slack_bolt/middleware/__init__.py b/slack_bolt/middleware/__init__.py index 752c1259f..a21747e92 100644 --- a/slack_bolt/middleware/__init__.py +++ b/slack_bolt/middleware/__init__.py @@ -6,13 +6,16 @@ """ # Don't add async module imports here -from .authorization import SingleTeamAuthorization, MultiTeamsAuthorization -from .custom_middleware import CustomMiddleware -from .ignoring_self_events import IgnoringSelfEvents -from .middleware import Middleware -from .request_verification import RequestVerification -from .ssl_check import SslCheck -from .url_verification import UrlVerification +from .authorization import ( + SingleTeamAuthorization, + MultiTeamsAuthorization, +) # noqa: F401 +from .custom_middleware import CustomMiddleware # noqa: F401 +from .ignoring_self_events import IgnoringSelfEvents # noqa: F401 +from .middleware import Middleware # noqa: F401 +from .request_verification import RequestVerification # noqa: F401 +from .ssl_check import SslCheck # noqa: F401 +from .url_verification import UrlVerification # noqa: F401 builtin_middleware_classes = [ SslCheck, diff --git a/slack_bolt/middleware/async_builtins.py b/slack_bolt/middleware/async_builtins.py index 09b5338e0..46cef9003 100644 --- a/slack_bolt/middleware/async_builtins.py +++ b/slack_bolt/middleware/async_builtins.py @@ -1,11 +1,11 @@ -from .ignoring_self_events.async_ignoring_self_events import ( +from .ignoring_self_events.async_ignoring_self_events import ( # noqa: F401 AsyncIgnoringSelfEvents, -) # noqa -from .request_verification.async_request_verification import ( +) +from .request_verification.async_request_verification import ( # noqa: F401 AsyncRequestVerification, -) # noqa -from .ssl_check.async_ssl_check import AsyncSslCheck # noqa -from .url_verification.async_url_verification import AsyncUrlVerification # noqa -from .message_listener_matches.async_message_listener_matches import ( +) +from .ssl_check.async_ssl_check import AsyncSslCheck # noqa: F401 +from .url_verification.async_url_verification import AsyncUrlVerification # noqa: F401 +from .message_listener_matches.async_message_listener_matches import ( # noqa: F401 AsyncMessageListenerMatches, -) # noqa +) diff --git a/slack_bolt/middleware/authorization/__init__.py b/slack_bolt/middleware/authorization/__init__.py index 832947b45..fd5b10263 100644 --- a/slack_bolt/middleware/authorization/__init__.py +++ b/slack_bolt/middleware/authorization/__init__.py @@ -1,4 +1,4 @@ # Don't add async module imports here -from .authorization import Authorization -from .multi_teams_authorization import MultiTeamsAuthorization -from .single_team_authorization import SingleTeamAuthorization +from .authorization import Authorization # noqa: F401 +from .multi_teams_authorization import MultiTeamsAuthorization # noqa: F401 +from .single_team_authorization import SingleTeamAuthorization # noqa: F401 diff --git a/slack_bolt/middleware/ssl_check/ssl_check.py b/slack_bolt/middleware/ssl_check/ssl_check.py index d8c92c72d..5e61773cd 100644 --- a/slack_bolt/middleware/ssl_check/ssl_check.py +++ b/slack_bolt/middleware/ssl_check/ssl_check.py @@ -23,7 +23,7 @@ def __init__( verification_token: The verification token to check (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) base_logger: The base logger - """ + """ # noqa: E501 self.verification_token = verification_token self.logger = get_bolt_logger(SslCheck, base_logger=base_logger) diff --git a/slack_bolt/oauth/internals.py b/slack_bolt/oauth/internals.py index 9fedc6e8f..1929f791a 100644 --- a/slack_bolt/oauth/internals.py +++ b/slack_bolt/oauth/internals.py @@ -91,7 +91,7 @@ def _build_default_install_page_html(url: str) -> str:

    -""" +""" # noqa: E501 # key: client_id, value: InstallationStore diff --git a/slack_bolt/request/__init__.py b/slack_bolt/request/__init__.py index e8dff275e..402d70cde 100644 --- a/slack_bolt/request/__init__.py +++ b/slack_bolt/request/__init__.py @@ -4,4 +4,4 @@ This interface encapsulates the difference between the two. """ # Don't add async module imports here -from .request import BoltRequest +from .request import BoltRequest # noqa: F401 diff --git a/slack_bolt/response/__init__.py b/slack_bolt/response/__init__.py index a159c701e..2dfd1d695 100644 --- a/slack_bolt/response/__init__.py +++ b/slack_bolt/response/__init__.py @@ -6,4 +6,4 @@ Refer to https://api.slack.com/apis/connections for the two types of connections. """ -from .response import BoltResponse +from .response import BoltResponse # noqa: F401 diff --git a/slack_bolt/workflows/step/__init__.py b/slack_bolt/workflows/step/__init__.py index 8bd0a067a..9418a2e4c 100644 --- a/slack_bolt/workflows/step/__init__.py +++ b/slack_bolt/workflows/step/__init__.py @@ -1,6 +1,6 @@ -from .step import WorkflowStep -from .step_middleware import WorkflowStepMiddleware -from .utilities.complete import Complete -from .utilities.configure import Configure -from .utilities.update import Update -from .utilities.fail import Fail +from .step import WorkflowStep # noqa: F401 +from .step_middleware import WorkflowStepMiddleware # noqa: F401 +from .utilities.complete import Complete # noqa: F401 +from .utilities.configure import Configure # noqa: F401 +from .utilities.update import Update # noqa: F401 +from .utilities.fail import Fail # noqa: F401 diff --git a/slack_bolt/workflows/step/async_step.py b/slack_bolt/workflows/step/async_step.py index 113c6c346..c8de638ca 100644 --- a/slack_bolt/workflows/step/async_step.py +++ b/slack_bolt/workflows/step/async_step.py @@ -230,11 +230,11 @@ def build(self, base_logger: Optional[Logger] = None) -> "AsyncWorkflowStep": An `AsyncWorkflowStep` object """ if self._edit is None: - raise BoltError(f"edit listener is not registered") + raise BoltError("edit listener is not registered") if self._save is None: - raise BoltError(f"save listener is not registered") + raise BoltError("save listener is not registered") if self._execute is None: - raise BoltError(f"execute listener is not registered") + raise BoltError("execute listener is not registered") return AsyncWorkflowStep( callback_id=self.callback_id, diff --git a/slack_bolt/workflows/step/step.py b/slack_bolt/workflows/step/step.py index dd17289fe..6c944af66 100644 --- a/slack_bolt/workflows/step/step.py +++ b/slack_bolt/workflows/step/step.py @@ -220,11 +220,11 @@ def build(self, base_logger: Optional[Logger] = None) -> "WorkflowStep": WorkflowStep object """ if self._edit is None: - raise BoltError(f"edit listener is not registered") + raise BoltError("edit listener is not registered") if self._save is None: - raise BoltError(f"save listener is not registered") + raise BoltError("save listener is not registered") if self._execute is None: - raise BoltError(f"execute listener is not registered") + raise BoltError("execute listener is not registered") return WorkflowStep( callback_id=self.callback_id, From 27a01c1e69b327944a2853d86261990bfe49606f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 30 Apr 2022 07:32:53 +0900 Subject: [PATCH 473/865] Fix #639 is_enterprise_install does not exist in context object (#640) * Fix #639 is_enterprise_install does not exist in context object * Remove unused imports --- slack_bolt/request/async_internals.py | 2 + slack_bolt/request/internals.py | 4 ++ tests/scenario_tests/test_events.py | 49 ++++++++++++++++++++++- tests/scenario_tests_async/test_events.py | 49 +++++++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/slack_bolt/request/async_internals.py b/slack_bolt/request/async_internals.py index 3b0eca3bf..b32ccb20e 100644 --- a/slack_bolt/request/async_internals.py +++ b/slack_bolt/request/async_internals.py @@ -3,6 +3,7 @@ from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.request.internals import ( extract_enterprise_id, + extract_is_enterprise_install, extract_team_id, extract_user_id, extract_channel_id, @@ -14,6 +15,7 @@ def build_async_context( context: AsyncBoltContext, body: Dict[str, Any], ) -> AsyncBoltContext: + context["is_enterprise_install"] = extract_is_enterprise_install(body) enterprise_id = extract_enterprise_id(body) if enterprise_id: context["enterprise_id"] = enterprise_id diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index b520cb821..31c2bc0f0 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -47,6 +47,10 @@ def parse_body(body: str, content_type: Optional[str]) -> Dict[str, Any]: def extract_is_enterprise_install(payload: Dict[str, Any]) -> Optional[bool]: + if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0: + # To make Events API handling functioning also for shared channels, + # we should use .authorizations[0].is_enterprise_install over .is_enterprise_install + return extract_is_enterprise_install(payload["authorizations"][0]) if "is_enterprise_install" in payload: is_enterprise_install = payload.get("is_enterprise_install") return is_enterprise_install is not None and ( diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index 49c1f409c..21dd48cd8 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -6,7 +6,7 @@ from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient -from slack_bolt import App, BoltRequest, Say +from slack_bolt import App, BoltRequest, Say, BoltContext from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -541,3 +541,50 @@ def handle(): app.event({"type": "message.channels"})(handle) with pytest.raises(ValueError): app.event({"type": re.compile("message\\..*")})(handle) + + def test_context_generation(self): + body = { + "token": "verification-token", + "enterprise_id": "E222", # intentionally inconsistent for testing + "team_id": "T222", # intentionally inconsistent for testing + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W111", + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610493715, + "authorizations": [ + { + "enterprise_id": "E333", + "user_id": "W222", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-G111", + } + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + + @app.event("member_left_channel") + def handle(context: BoltContext): + assert context.enterprise_id == "E333" + assert context.team_id is None + assert context.is_enterprise_install is True + assert context.user_id == "W111" + + timestamp, json_body = str(int(time())), json.dumps(body) + request: BoltRequest = BoltRequest( + body=json_body, headers=self.build_headers(timestamp, json_body) + ) + response = app.dispatch(request) + assert response.status == 200 diff --git a/tests/scenario_tests_async/test_events.py b/tests/scenario_tests_async/test_events.py index 5e5af42e2..d3e0f1f9d 100644 --- a/tests/scenario_tests_async/test_events.py +++ b/tests/scenario_tests_async/test_events.py @@ -9,6 +9,7 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.context.say.async_say import AsyncSay from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( @@ -552,6 +553,54 @@ async def handle(): with pytest.raises(ValueError): app.event({"type": re.compile("message\\..*")})(handle) + @pytest.mark.asyncio + async def test_context_generation(self): + body = { + "token": "verification-token", + "enterprise_id": "E222", # intentionally inconsistent for testing + "team_id": "T222", # intentionally inconsistent for testing + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W111", + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610493715, + "authorizations": [ + { + "enterprise_id": "E333", + "user_id": "W222", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-G111", + } + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + + @app.event("member_left_channel") + async def handle(context: AsyncBoltContext): + assert context.enterprise_id == "E333" + assert context.team_id is None + assert context.is_enterprise_install is True + assert context.user_id == "W111" + + timestamp, json_body = str(int(time())), json.dumps(body) + request: AsyncBoltRequest = AsyncBoltRequest( + body=json_body, headers=self.build_headers(timestamp, json_body) + ) + response = await app.async_dispatch(request) + assert response.status == 200 + app_mention_body = { "token": "verification_token", From a2189c3032b5b6aa2907972ae8725039b7abdcc3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 29 Apr 2022 15:34:53 -0700 Subject: [PATCH 474/865] version 1.13.1 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index d48ad3d3a..a5295f2c9 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.13.0" +__version__ = "1.13.1" From 3e1d1cae963d33caea6768bea2f5612b8f91c4c5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 29 Apr 2022 15:39:54 -0700 Subject: [PATCH 475/865] Update API docs --- .../slack_bolt/adapter/aws_lambda/index.html | 2 +- .../slack_bolt/adapter/bottle/index.html | 2 +- .../slack_bolt/adapter/cherrypy/index.html | 2 +- .../slack_bolt/adapter/django/handler.html | 8 ++--- .../slack_bolt/adapter/django/index.html | 2 +- .../slack_bolt/adapter/falcon/index.html | 2 +- .../slack_bolt/adapter/flask/index.html | 2 +- .../slack_bolt/adapter/pyramid/index.html | 2 +- .../slack_bolt/adapter/sanic/index.html | 2 +- .../slack_bolt/adapter/socket_mode/index.html | 4 +-- .../slack_bolt/adapter/starlette/index.html | 2 +- .../slack_bolt/adapter/tornado/index.html | 2 +- docs/api-docs/slack_bolt/app/app.html | 34 ++++++++++++++----- docs/api-docs/slack_bolt/app/index.html | 2 +- docs/api-docs/slack_bolt/async_app.html | 2 +- .../slack_bolt/authorization/index.html | 2 +- .../slack_bolt/context/ack/index.html | 2 +- .../slack_bolt/context/ack/internals.html | 2 +- docs/api-docs/slack_bolt/context/index.html | 2 +- .../slack_bolt/context/respond/index.html | 2 +- .../slack_bolt/context/say/index.html | 2 +- docs/api-docs/slack_bolt/index.html | 8 ++--- .../slack_bolt/kwargs_injection/index.html | 4 +-- .../slack_bolt/kwargs_injection/utils.html | 2 +- .../lazy_listener/async_runner.html | 2 +- .../slack_bolt/listener/builtins.html | 4 +-- .../slack_bolt/listener_matcher/builtins.html | 3 +- docs/api-docs/slack_bolt/logger/messages.html | 10 ++++-- .../slack_bolt/middleware/async_builtins.html | 16 ++++----- .../middleware/authorization/index.html | 6 ++-- .../api-docs/slack_bolt/middleware/index.html | 17 ++++++---- .../middleware/ssl_check/ssl_check.html | 4 +-- docs/api-docs/slack_bolt/oauth/internals.html | 2 +- .../slack_bolt/request/async_internals.html | 3 ++ docs/api-docs/slack_bolt/request/index.html | 2 +- .../slack_bolt/request/internals.html | 8 +++++ docs/api-docs/slack_bolt/response/index.html | 2 +- docs/api-docs/slack_bolt/version.html | 2 +- .../slack_bolt/workflows/step/async_step.html | 18 +++++----- .../slack_bolt/workflows/step/index.html | 12 +++---- .../slack_bolt/workflows/step/step.html | 18 +++++----- 41 files changed, 130 insertions(+), 95 deletions(-) diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html index c82448803..5cc172ec4 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html @@ -26,7 +26,7 @@

    Module slack_bolt.adapter.aws_lambda

    Expand source code -
    from .handler import SlackRequestHandler
    +
    from .handler import SlackRequestHandler  # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/adapter/bottle/index.html b/docs/api-docs/slack_bolt/adapter/bottle/index.html index 924b14de2..8701e3cb7 100644 --- a/docs/api-docs/slack_bolt/adapter/bottle/index.html +++ b/docs/api-docs/slack_bolt/adapter/bottle/index.html @@ -26,7 +26,7 @@

    Module slack_bolt.adapter.bottle

    Expand source code -
    from .handler import SlackRequestHandler
    +
    from .handler import SlackRequestHandler  # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/adapter/cherrypy/index.html b/docs/api-docs/slack_bolt/adapter/cherrypy/index.html index e1df7c0b1..7b4df5764 100644 --- a/docs/api-docs/slack_bolt/adapter/cherrypy/index.html +++ b/docs/api-docs/slack_bolt/adapter/cherrypy/index.html @@ -26,7 +26,7 @@

    Module slack_bolt.adapter.cherrypy

    Expand source code -
    from .handler import SlackRequestHandler
    +
    from .handler import SlackRequestHandler  # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/adapter/django/handler.html b/docs/api-docs/slack_bolt/adapter/django/handler.html index 1b47e3e35..3c1e537b6 100644 --- a/docs/api-docs/slack_bolt/adapter/django/handler.html +++ b/docs/api-docs/slack_bolt/adapter/django/handler.html @@ -170,8 +170,8 @@

    Module slack_bolt.adapter.django.handler

    # it's okay to skip calling the same connection clean-up method at the listener completion. message = """As you've already set app.listener_runner.listener_start_handler to your own one, Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerStartHandler. - - If you go with your own handler here, we highly recommend having the following lines of code + + If you go with your own handler here, we highly recommend having the following lines of code in your handle() method to clean up unmanaged stale/old database connections: from django.db import close_old_connections @@ -461,8 +461,8 @@

    Inherited members

    # it's okay to skip calling the same connection clean-up method at the listener completion. message = """As you've already set app.listener_runner.listener_start_handler to your own one, Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerStartHandler. - - If you go with your own handler here, we highly recommend having the following lines of code + + If you go with your own handler here, we highly recommend having the following lines of code in your handle() method to clean up unmanaged stale/old database connections: from django.db import close_old_connections diff --git a/docs/api-docs/slack_bolt/adapter/django/index.html b/docs/api-docs/slack_bolt/adapter/django/index.html index 9c41c9e8e..80b06b944 100644 --- a/docs/api-docs/slack_bolt/adapter/django/index.html +++ b/docs/api-docs/slack_bolt/adapter/django/index.html @@ -26,7 +26,7 @@

    Module slack_bolt.adapter.django

    Expand source code -
    from .handler import SlackRequestHandler
    +
    from .handler import SlackRequestHandler  # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/adapter/falcon/index.html b/docs/api-docs/slack_bolt/adapter/falcon/index.html index 85a95e9f3..a61724235 100644 --- a/docs/api-docs/slack_bolt/adapter/falcon/index.html +++ b/docs/api-docs/slack_bolt/adapter/falcon/index.html @@ -27,7 +27,7 @@

    Module slack_bolt.adapter.falcon

    Expand source code
    # Don't add async module imports here
    -from .resource import SlackAppResource
    +from .resource import SlackAppResource # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/adapter/flask/index.html b/docs/api-docs/slack_bolt/adapter/flask/index.html index a8d8efd5d..80baea429 100644 --- a/docs/api-docs/slack_bolt/adapter/flask/index.html +++ b/docs/api-docs/slack_bolt/adapter/flask/index.html @@ -26,7 +26,7 @@

    Module slack_bolt.adapter.flask

    Expand source code -
    from .handler import SlackRequestHandler
    +
    from .handler import SlackRequestHandler  # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/adapter/pyramid/index.html b/docs/api-docs/slack_bolt/adapter/pyramid/index.html index dbf5a8668..c9cd6120c 100644 --- a/docs/api-docs/slack_bolt/adapter/pyramid/index.html +++ b/docs/api-docs/slack_bolt/adapter/pyramid/index.html @@ -26,7 +26,7 @@

    Module slack_bolt.adapter.pyramid

    Expand source code -
    from .handler import SlackRequestHandler
    +
    from .handler import SlackRequestHandler  # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/adapter/sanic/index.html b/docs/api-docs/slack_bolt/adapter/sanic/index.html index 71cb0ad94..c1b61a875 100644 --- a/docs/api-docs/slack_bolt/adapter/sanic/index.html +++ b/docs/api-docs/slack_bolt/adapter/sanic/index.html @@ -26,7 +26,7 @@

    Module slack_bolt.adapter.sanic

    Expand source code -
    from .async_handler import AsyncSlackRequestHandler
    +
    from .async_handler import AsyncSlackRequestHandler  # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/index.html b/docs/api-docs/slack_bolt/adapter/socket_mode/index.html index 642bb5206..3666de4b4 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/index.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/index.html @@ -39,10 +39,10 @@

    Module slack_bolt.adapter.socket_mode

    * `slack_bolt.adapter.socket_mode.websocket_client` * `slack_bolt.adapter.socket_mode.aiohttp` * `slack_bolt.adapter.socket_mode.websockets` -""" +""" # noqa: E501 # Don't add async module imports here -from .builtin import SocketModeHandler # noqa +from .builtin import SocketModeHandler # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/adapter/starlette/index.html b/docs/api-docs/slack_bolt/adapter/starlette/index.html index bc4b3689f..b3c1dbecd 100644 --- a/docs/api-docs/slack_bolt/adapter/starlette/index.html +++ b/docs/api-docs/slack_bolt/adapter/starlette/index.html @@ -27,7 +27,7 @@

    Module slack_bolt.adapter.starlette

    Expand source code
    # Don't add async module imports here
    -from .handler import SlackRequestHandler
    +from .handler import SlackRequestHandler # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/adapter/tornado/index.html b/docs/api-docs/slack_bolt/adapter/tornado/index.html index 4e1719a18..037b2751f 100644 --- a/docs/api-docs/slack_bolt/adapter/tornado/index.html +++ b/docs/api-docs/slack_bolt/adapter/tornado/index.html @@ -26,7 +26,7 @@

    Module slack_bolt.adapter.tornado

    Expand source code -
    from .handler import SlackEventsHandler, SlackOAuthHandler
    +
    from .handler import SlackEventsHandler, SlackOAuthHandler  # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index e49fe3d59..b7eac7755 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -1481,13 +1481,19 @@

    Module slack_bolt.app.app

    request_path, _, query = self.path.partition("?") if request_path == _bolt_oauth_flow.install_path: bolt_req = BoltRequest( - body="", query=query, headers=self.headers + body="", + query=query, + # email.message.Message's mapping interface is dict compatible + headers=self.headers, # type:ignore ) bolt_resp = _bolt_oauth_flow.handle_installation(bolt_req) self._send_bolt_response(bolt_resp) elif request_path == _bolt_oauth_flow.redirect_uri_path: bolt_req = BoltRequest( - body="", query=query, headers=self.headers + body="", + query=query, + # email.message.Message's mapping interface is dict compatible + headers=self.headers, # type:ignore ) bolt_resp = _bolt_oauth_flow.handle_callback(bolt_req) self._send_bolt_response(bolt_resp) @@ -1505,7 +1511,10 @@

    Module slack_bolt.app.app

    len_header = self.headers.get("Content-Length") or 0 request_body = self.rfile.read(int(len_header)).decode("utf-8") bolt_req = BoltRequest( - body=request_body, query=query, headers=self.headers + body=request_body, + query=query, + # email.message.Message's mapping interface is dict compatible + headers=self.headers, # type:ignore ) bolt_resp: BoltResponse = _bolt_app.dispatch(bolt_req) self._send_bolt_response(bolt_resp) @@ -1531,7 +1540,7 @@

    Module slack_bolt.app.app

    for k, vs in headers.items(): for v in vs: self.send_header(k, v) - self.send_header("Content-Length", len(body_bytes)) + self.send_header("Content-Length", str(len(body_bytes))) self.end_headers() self.wfile.write(body_bytes) @@ -4516,13 +4525,19 @@

    Args

    request_path, _, query = self.path.partition("?") if request_path == _bolt_oauth_flow.install_path: bolt_req = BoltRequest( - body="", query=query, headers=self.headers + body="", + query=query, + # email.message.Message's mapping interface is dict compatible + headers=self.headers, # type:ignore ) bolt_resp = _bolt_oauth_flow.handle_installation(bolt_req) self._send_bolt_response(bolt_resp) elif request_path == _bolt_oauth_flow.redirect_uri_path: bolt_req = BoltRequest( - body="", query=query, headers=self.headers + body="", + query=query, + # email.message.Message's mapping interface is dict compatible + headers=self.headers, # type:ignore ) bolt_resp = _bolt_oauth_flow.handle_callback(bolt_req) self._send_bolt_response(bolt_resp) @@ -4540,7 +4555,10 @@

    Args

    len_header = self.headers.get("Content-Length") or 0 request_body = self.rfile.read(int(len_header)).decode("utf-8") bolt_req = BoltRequest( - body=request_body, query=query, headers=self.headers + body=request_body, + query=query, + # email.message.Message's mapping interface is dict compatible + headers=self.headers, # type:ignore ) bolt_resp: BoltResponse = _bolt_app.dispatch(bolt_req) self._send_bolt_response(bolt_resp) @@ -4566,7 +4584,7 @@

    Args

    for k, vs in headers.items(): for v in vs: self.send_header(k, v) - self.send_header("Content-Length", len(body_bytes)) + self.send_header("Content-Length", str(len(body_bytes))) self.end_headers() self.wfile.write(body_bytes) diff --git a/docs/api-docs/slack_bolt/app/index.html b/docs/api-docs/slack_bolt/app/index.html index 7951c6ce9..e54edaa72 100644 --- a/docs/api-docs/slack_bolt/app/index.html +++ b/docs/api-docs/slack_bolt/app/index.html @@ -38,7 +38,7 @@

    Module slack_bolt.app

    """ # Don't add async module imports here -from .app import App # type: ignore +from .app import App # noqa: F401 type: ignore
    diff --git a/docs/api-docs/slack_bolt/async_app.html b/docs/api-docs/slack_bolt/async_app.html index 6eb811be1..b31814f92 100644 --- a/docs/api-docs/slack_bolt/async_app.html +++ b/docs/api-docs/slack_bolt/async_app.html @@ -108,7 +108,7 @@

    Creating an async app

    Apps can be run the same way as the synchronous example above. If you'd prefer another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at [the built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) and their corresponding [examples](https://github.com/slackapi/bolt-python/tree/main/examples). Refer to `slack_bolt.app.async_app` for more details. -""" +""" # noqa: E501 from .app.async_app import AsyncApp # noqa from .context.ack.async_ack import AsyncAck # noqa from .context.async_context import AsyncBoltContext # noqa diff --git a/docs/api-docs/slack_bolt/authorization/index.html b/docs/api-docs/slack_bolt/authorization/index.html index d9f943a1e..6352b61c5 100644 --- a/docs/api-docs/slack_bolt/authorization/index.html +++ b/docs/api-docs/slack_bolt/authorization/index.html @@ -35,7 +35,7 @@

    Module slack_bolt.authorization

    Refer to https://slack.dev/bolt-python/concepts#authorization for details. """ -from .authorize_result import AuthorizeResult +from .authorize_result import AuthorizeResult # noqa
    diff --git a/docs/api-docs/slack_bolt/context/ack/index.html b/docs/api-docs/slack_bolt/context/ack/index.html index 514717149..2d49a6cb2 100644 --- a/docs/api-docs/slack_bolt/context/ack/index.html +++ b/docs/api-docs/slack_bolt/context/ack/index.html @@ -27,7 +27,7 @@

    Module slack_bolt.context.ack

    Expand source code
    # Don't add async module imports here
    -from .ack import Ack
    +from .ack import Ack # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/context/ack/internals.html b/docs/api-docs/slack_bolt/context/ack/internals.html index 56ca58bb2..cede24c62 100644 --- a/docs/api-docs/slack_bolt/context/ack/internals.html +++ b/docs/api-docs/slack_bolt/context/ack/internals.html @@ -89,7 +89,7 @@

    Module slack_bolt.context.ack.internals

    ) else: raise ValueError( - f"errors field is required for response_action: errors" + "errors field is required for response_action: errors" ) else: body = {"response_action": response_action} diff --git a/docs/api-docs/slack_bolt/context/index.html b/docs/api-docs/slack_bolt/context/index.html index 25952a7a2..1e2bea874 100644 --- a/docs/api-docs/slack_bolt/context/index.html +++ b/docs/api-docs/slack_bolt/context/index.html @@ -39,7 +39,7 @@

    Module slack_bolt.context

    """ # Don't add async module imports here -from .context import BoltContext +from .context import BoltContext # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/context/respond/index.html b/docs/api-docs/slack_bolt/context/respond/index.html index 24f1fdc1d..135e7242e 100644 --- a/docs/api-docs/slack_bolt/context/respond/index.html +++ b/docs/api-docs/slack_bolt/context/respond/index.html @@ -27,7 +27,7 @@

    Module slack_bolt.context.respond

    Expand source code
    # Don't add async module imports here
    -from .respond import Respond
    +from .respond import Respond # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/context/say/index.html b/docs/api-docs/slack_bolt/context/say/index.html index 7f6fc657b..9b3ef82c5 100644 --- a/docs/api-docs/slack_bolt/context/say/index.html +++ b/docs/api-docs/slack_bolt/context/say/index.html @@ -27,7 +27,7 @@

    Module slack_bolt.context.say

    Expand source code
    # Don't add async module imports here
    -from .say import Say
    +from .say import Say # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/index.html b/docs/api-docs/slack_bolt/index.html index 86e6b3646..ebaeecaae 100644 --- a/docs/api-docs/slack_bolt/index.html +++ b/docs/api-docs/slack_bolt/index.html @@ -5,7 +5,7 @@ slack_bolt API documentation - + @@ -22,7 +22,7 @@

    Package slack_bolt

    -

    A Python framework to build Slack apps in a flash with the latest platform features. Read the getting started guide and look at our code examples to learn how to build apps using Bolt.

    +

    A Python framework to build Slack apps in a flash with the latest platform features.Read the getting started guide and look at our code examples to learn how to build apps using Bolt.

    • Website: https://slack.dev/bolt-python/
    • GitHub repository: https://github.com/slackapi/bolt-python
    • @@ -33,12 +33,12 @@

      Package slack_bolt

      Expand source code
      """
      -A Python framework to build Slack apps in a flash with the latest platform features. Read the [getting started guide](https://slack.dev/bolt-python/tutorial/getting-started) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt.
      +A Python framework to build Slack apps in a flash with the latest platform features.Read the [getting started guide](https://slack.dev/bolt-python/tutorial/getting-started) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt.
       
       * Website: https://slack.dev/bolt-python/
       * GitHub repository: https://github.com/slackapi/bolt-python
       * The class representing a Bolt app: `slack_bolt.app.app`
      -"""
      +"""  # noqa: E501
       # Don't add async module imports here
       from .app import App  # noqa
       from .context import BoltContext  # noqa
      diff --git a/docs/api-docs/slack_bolt/kwargs_injection/index.html b/docs/api-docs/slack_bolt/kwargs_injection/index.html
      index d22d1e46c..0e0a1ac19 100644
      --- a/docs/api-docs/slack_bolt/kwargs_injection/index.html
      +++ b/docs/api-docs/slack_bolt/kwargs_injection/index.html
      @@ -36,8 +36,8 @@ 

      Module slack_bolt.kwargs_injection

      """ # Don't add async module imports here -from .args import Args -from .utils import build_required_kwargs
      +from .args import Args # noqa: F401 +from .utils import build_required_kwargs # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/kwargs_injection/utils.html b/docs/api-docs/slack_bolt/kwargs_injection/utils.html index 4b47237d3..3f84a30f3 100644 --- a/docs/api-docs/slack_bolt/kwargs_injection/utils.html +++ b/docs/api-docs/slack_bolt/kwargs_injection/utils.html @@ -29,7 +29,7 @@

    Module slack_bolt.kwargs_injection.utils

    # pytype: skip-file
     import inspect
     import logging
    -from typing import Callable, Dict, Optional, Any, Sequence, List
    +from typing import Callable, Dict, Optional, Any, Sequence
     
     from slack_bolt.request import BoltRequest
     from slack_bolt.response import BoltResponse
    diff --git a/docs/api-docs/slack_bolt/lazy_listener/async_runner.html b/docs/api-docs/slack_bolt/lazy_listener/async_runner.html
    index 8585b1c87..b8e56947d 100644
    --- a/docs/api-docs/slack_bolt/lazy_listener/async_runner.html
    +++ b/docs/api-docs/slack_bolt/lazy_listener/async_runner.html
    @@ -28,7 +28,7 @@ 

    Module slack_bolt.lazy_listener.async_runner

    from abc import abstractmethod, ABCMeta
     from logging import Logger
    -from typing import Callable, Awaitable, Any, Coroutine
    +from typing import Callable, Awaitable
     
     from slack_bolt.lazy_listener.async_internals import to_runnable_function
     from slack_bolt.request.async_request import AsyncBoltRequest
    diff --git a/docs/api-docs/slack_bolt/listener/builtins.html b/docs/api-docs/slack_bolt/listener/builtins.html
    index 205e85c58..0b892bbf9 100644
    --- a/docs/api-docs/slack_bolt/listener/builtins.html
    +++ b/docs/api-docs/slack_bolt/listener/builtins.html
    @@ -26,9 +26,7 @@ 

    Module slack_bolt.listener.builtins

    Expand source code -
    from slack_sdk.oauth import InstallationStore
    -
    -from slack_bolt.context.context import BoltContext
    +
    from slack_bolt.context.context import BoltContext
     from slack_sdk.oauth.installation_store.installation_store import InstallationStore
     
     
    diff --git a/docs/api-docs/slack_bolt/listener_matcher/builtins.html b/docs/api-docs/slack_bolt/listener_matcher/builtins.html
    index e55a7e489..53cac5ebe 100644
    --- a/docs/api-docs/slack_bolt/listener_matcher/builtins.html
    +++ b/docs/api-docs/slack_bolt/listener_matcher/builtins.html
    @@ -57,8 +57,7 @@ 

    Module slack_bolt.listener_matcher.builtins

    from re import _pattern_type as Pattern else: from re import Pattern -from typing import Callable, Awaitable, Any, Sequence, Optional, Union -from typing import Union, Optional, Dict +from typing import Callable, Awaitable, Any, Sequence, Optional, Union, Dict from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.request import BoltRequest diff --git a/docs/api-docs/slack_bolt/logger/messages.html b/docs/api-docs/slack_bolt/logger/messages.html index a29a8e9d0..565d3cf9b 100644 --- a/docs/api-docs/slack_bolt/logger/messages.html +++ b/docs/api-docs/slack_bolt/logger/messages.html @@ -376,7 +376,10 @@

    Module slack_bolt.logger.messages

    listener_name: str, status: int, body: str, starting_time: float ) -> str: millis = int((time.time() - starting_time) * 1000) - return f"Responding with listener middleware's response - listener: {listener_name}, status: {status}, body: {body} ({millis} millis)"
    + return ( + "Responding with listener middleware's response - " + f"listener: {listener_name}, status: {status}, body: {body} ({millis} millis)" + )
    @@ -438,7 +441,10 @@

    Functions

    listener_name: str, status: int, body: str, starting_time: float ) -> str: millis = int((time.time() - starting_time) * 1000) - return f"Responding with listener middleware's response - listener: {listener_name}, status: {status}, body: {body} ({millis} millis)"
    + return ( + "Responding with listener middleware's response - " + f"listener: {listener_name}, status: {status}, body: {body} ({millis} millis)" + )
    diff --git a/docs/api-docs/slack_bolt/middleware/async_builtins.html b/docs/api-docs/slack_bolt/middleware/async_builtins.html index 987ff15fc..0f5f35bdd 100644 --- a/docs/api-docs/slack_bolt/middleware/async_builtins.html +++ b/docs/api-docs/slack_bolt/middleware/async_builtins.html @@ -26,17 +26,17 @@

    Module slack_bolt.middleware.async_builtins

    Expand source code -
    from .ignoring_self_events.async_ignoring_self_events import (
    +
    from .ignoring_self_events.async_ignoring_self_events import (  # noqa: F401
         AsyncIgnoringSelfEvents,
    -)  # noqa
    -from .request_verification.async_request_verification import (
    +)
    +from .request_verification.async_request_verification import (  # noqa: F401
         AsyncRequestVerification,
    -)  # noqa
    -from .ssl_check.async_ssl_check import AsyncSslCheck  # noqa
    -from .url_verification.async_url_verification import AsyncUrlVerification  # noqa
    -from .message_listener_matches.async_message_listener_matches import (
    +)
    +from .ssl_check.async_ssl_check import AsyncSslCheck  # noqa: F401
    +from .url_verification.async_url_verification import AsyncUrlVerification  # noqa: F401
    +from .message_listener_matches.async_message_listener_matches import (  # noqa: F401
         AsyncMessageListenerMatches,
    -)  # noqa
    +)
    diff --git a/docs/api-docs/slack_bolt/middleware/authorization/index.html b/docs/api-docs/slack_bolt/middleware/authorization/index.html index 30c22c10b..1884327b1 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/index.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/index.html @@ -27,9 +27,9 @@

    Module slack_bolt.middleware.authorization

    Expand source code
    # Don't add async module imports here
    -from .authorization import Authorization
    -from .multi_teams_authorization import MultiTeamsAuthorization
    -from .single_team_authorization import SingleTeamAuthorization
    +from .authorization import Authorization # noqa: F401 +from .multi_teams_authorization import MultiTeamsAuthorization # noqa: F401 +from .single_team_authorization import SingleTeamAuthorization # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/middleware/index.html b/docs/api-docs/slack_bolt/middleware/index.html index d134d6366..b4c80965e 100644 --- a/docs/api-docs/slack_bolt/middleware/index.html +++ b/docs/api-docs/slack_bolt/middleware/index.html @@ -39,13 +39,16 @@

    Module slack_bolt.middleware

    """ # Don't add async module imports here -from .authorization import SingleTeamAuthorization, MultiTeamsAuthorization -from .custom_middleware import CustomMiddleware -from .ignoring_self_events import IgnoringSelfEvents -from .middleware import Middleware -from .request_verification import RequestVerification -from .ssl_check import SslCheck -from .url_verification import UrlVerification +from .authorization import ( + SingleTeamAuthorization, + MultiTeamsAuthorization, +) # noqa: F401 +from .custom_middleware import CustomMiddleware # noqa: F401 +from .ignoring_self_events import IgnoringSelfEvents # noqa: F401 +from .middleware import Middleware # noqa: F401 +from .request_verification import RequestVerification # noqa: F401 +from .ssl_check import SslCheck # noqa: F401 +from .url_verification import UrlVerification # noqa: F401 builtin_middleware_classes = [ SslCheck, diff --git a/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html b/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html index 20fd600ed..550b05fd2 100644 --- a/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html +++ b/docs/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html @@ -51,7 +51,7 @@

    Module slack_bolt.middleware.ssl_check.ssl_check< verification_token: The verification token to check (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) base_logger: The base logger - """ + """ # noqa: E501 self.verification_token = verification_token self.logger = get_bolt_logger(SslCheck, base_logger=base_logger) @@ -135,7 +135,7 @@

    Args

    verification_token: The verification token to check (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) base_logger: The base logger - """ + """ # noqa: E501 self.verification_token = verification_token self.logger = get_bolt_logger(SslCheck, base_logger=base_logger) diff --git a/docs/api-docs/slack_bolt/oauth/internals.html b/docs/api-docs/slack_bolt/oauth/internals.html index 23db0f934..030eed68f 100644 --- a/docs/api-docs/slack_bolt/oauth/internals.html +++ b/docs/api-docs/slack_bolt/oauth/internals.html @@ -119,7 +119,7 @@

    Module slack_bolt.oauth.internals

    <p><a href="{url}"><img alt=""Add to Slack"" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a></p> </body> </html> -""" +""" # noqa: E501 # key: client_id, value: InstallationStore diff --git a/docs/api-docs/slack_bolt/request/async_internals.html b/docs/api-docs/slack_bolt/request/async_internals.html index 516d8209b..0505fb696 100644 --- a/docs/api-docs/slack_bolt/request/async_internals.html +++ b/docs/api-docs/slack_bolt/request/async_internals.html @@ -31,6 +31,7 @@

    Module slack_bolt.request.async_internals

    from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.request.internals import ( extract_enterprise_id, + extract_is_enterprise_install, extract_team_id, extract_user_id, extract_channel_id, @@ -42,6 +43,7 @@

    Module slack_bolt.request.async_internals

    context: AsyncBoltContext, body: Dict[str, Any], ) -> AsyncBoltContext: + context["is_enterprise_install"] = extract_is_enterprise_install(body) enterprise_id = extract_enterprise_id(body) if enterprise_id: context["enterprise_id"] = enterprise_id @@ -87,6 +89,7 @@

    Functions

    context: AsyncBoltContext, body: Dict[str, Any], ) -> AsyncBoltContext: + context["is_enterprise_install"] = extract_is_enterprise_install(body) enterprise_id = extract_enterprise_id(body) if enterprise_id: context["enterprise_id"] = enterprise_id diff --git a/docs/api-docs/slack_bolt/request/index.html b/docs/api-docs/slack_bolt/request/index.html index 20cee996a..f9f32e0de 100644 --- a/docs/api-docs/slack_bolt/request/index.html +++ b/docs/api-docs/slack_bolt/request/index.html @@ -35,7 +35,7 @@

    Module slack_bolt.request

    This interface encapsulates the difference between the two. """ # Don't add async module imports here -from .request import BoltRequest +from .request import BoltRequest # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/request/internals.html b/docs/api-docs/slack_bolt/request/internals.html index 9a61d42f7..9bb091717 100644 --- a/docs/api-docs/slack_bolt/request/internals.html +++ b/docs/api-docs/slack_bolt/request/internals.html @@ -75,6 +75,10 @@

    Module slack_bolt.request.internals

    def extract_is_enterprise_install(payload: Dict[str, Any]) -> Optional[bool]: + if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0: + # To make Events API handling functioning also for shared channels, + # we should use .authorizations[0].is_enterprise_install over .is_enterprise_install + return extract_is_enterprise_install(payload["authorizations"][0]) if "is_enterprise_install" in payload: is_enterprise_install = payload.get("is_enterprise_install") return is_enterprise_install is not None and ( @@ -405,6 +409,10 @@

    Functions

    Expand source code
    def extract_is_enterprise_install(payload: Dict[str, Any]) -> Optional[bool]:
    +    if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0:
    +        # To make Events API handling functioning also for shared channels,
    +        # we should use .authorizations[0].is_enterprise_install over .is_enterprise_install
    +        return extract_is_enterprise_install(payload["authorizations"][0])
         if "is_enterprise_install" in payload:
             is_enterprise_install = payload.get("is_enterprise_install")
             return is_enterprise_install is not None and (
    diff --git a/docs/api-docs/slack_bolt/response/index.html b/docs/api-docs/slack_bolt/response/index.html
    index 93ad52e73..d1d1cc747 100644
    --- a/docs/api-docs/slack_bolt/response/index.html
    +++ b/docs/api-docs/slack_bolt/response/index.html
    @@ -38,7 +38,7 @@ 

    Module slack_bolt.response

    Refer to https://api.slack.com/apis/connections for the two types of connections. """ -from .response import BoltResponse
    +from .response import BoltResponse # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index dc5919bc8..1ae0d24f4 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.13.0"
    +__version__ = "1.13.1"
    diff --git a/docs/api-docs/slack_bolt/workflows/step/async_step.html b/docs/api-docs/slack_bolt/workflows/step/async_step.html index 1a560fb77..35c412f67 100644 --- a/docs/api-docs/slack_bolt/workflows/step/async_step.html +++ b/docs/api-docs/slack_bolt/workflows/step/async_step.html @@ -258,11 +258,11 @@

    Module slack_bolt.workflows.step.async_step

    An `AsyncWorkflowStep` object """ if self._edit is None: - raise BoltError(f"edit listener is not registered") + raise BoltError("edit listener is not registered") if self._save is None: - raise BoltError(f"save listener is not registered") + raise BoltError("save listener is not registered") if self._execute is None: - raise BoltError(f"execute listener is not registered") + raise BoltError("execute listener is not registered") return AsyncWorkflowStep( callback_id=self.callback_id, @@ -1052,11 +1052,11 @@

    Args

    An `AsyncWorkflowStep` object """ if self._edit is None: - raise BoltError(f"edit listener is not registered") + raise BoltError("edit listener is not registered") if self._save is None: - raise BoltError(f"save listener is not registered") + raise BoltError("save listener is not registered") if self._execute is None: - raise BoltError(f"execute listener is not registered") + raise BoltError("execute listener is not registered") return AsyncWorkflowStep( callback_id=self.callback_id, @@ -1210,11 +1210,11 @@

    Returns

    An `AsyncWorkflowStep` object """ if self._edit is None: - raise BoltError(f"edit listener is not registered") + raise BoltError("edit listener is not registered") if self._save is None: - raise BoltError(f"save listener is not registered") + raise BoltError("save listener is not registered") if self._execute is None: - raise BoltError(f"execute listener is not registered") + raise BoltError("execute listener is not registered") return AsyncWorkflowStep( callback_id=self.callback_id, diff --git a/docs/api-docs/slack_bolt/workflows/step/index.html b/docs/api-docs/slack_bolt/workflows/step/index.html index 3f4057cb1..afa34e048 100644 --- a/docs/api-docs/slack_bolt/workflows/step/index.html +++ b/docs/api-docs/slack_bolt/workflows/step/index.html @@ -26,12 +26,12 @@

    Module slack_bolt.workflows.step

    Expand source code -
    from .step import WorkflowStep
    -from .step_middleware import WorkflowStepMiddleware
    -from .utilities.complete import Complete
    -from .utilities.configure import Configure
    -from .utilities.update import Update
    -from .utilities.fail import Fail
    +
    from .step import WorkflowStep  # noqa: F401
    +from .step_middleware import WorkflowStepMiddleware  # noqa: F401
    +from .utilities.complete import Complete  # noqa: F401
    +from .utilities.configure import Configure  # noqa: F401
    +from .utilities.update import Update  # noqa: F401
    +from .utilities.fail import Fail  # noqa: F401
    diff --git a/docs/api-docs/slack_bolt/workflows/step/step.html b/docs/api-docs/slack_bolt/workflows/step/step.html index f64c784d3..cc2cab62d 100644 --- a/docs/api-docs/slack_bolt/workflows/step/step.html +++ b/docs/api-docs/slack_bolt/workflows/step/step.html @@ -248,11 +248,11 @@

    Module slack_bolt.workflows.step.step

    WorkflowStep object """ if self._edit is None: - raise BoltError(f"edit listener is not registered") + raise BoltError("edit listener is not registered") if self._save is None: - raise BoltError(f"save listener is not registered") + raise BoltError("save listener is not registered") if self._execute is None: - raise BoltError(f"execute listener is not registered") + raise BoltError("execute listener is not registered") return WorkflowStep( callback_id=self.callback_id, @@ -1068,11 +1068,11 @@

    Args

    WorkflowStep object """ if self._edit is None: - raise BoltError(f"edit listener is not registered") + raise BoltError("edit listener is not registered") if self._save is None: - raise BoltError(f"save listener is not registered") + raise BoltError("save listener is not registered") if self._execute is None: - raise BoltError(f"execute listener is not registered") + raise BoltError("execute listener is not registered") return WorkflowStep( callback_id=self.callback_id, @@ -1250,11 +1250,11 @@

    Returns

    WorkflowStep object """ if self._edit is None: - raise BoltError(f"edit listener is not registered") + raise BoltError("edit listener is not registered") if self._save is None: - raise BoltError(f"save listener is not registered") + raise BoltError("save listener is not registered") if self._execute is None: - raise BoltError(f"execute listener is not registered") + raise BoltError("execute listener is not registered") return WorkflowStep( callback_id=self.callback_id, From 20bfec888fa0fa55b7dfdfa6fce9e266d5a1adb3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 2 May 2022 09:08:42 +0900 Subject: [PATCH 476/865] Upgrade pytype to 2022.4.26 --- scripts/run_pytype.sh | 2 +- slack_bolt/app/__init__.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index 780bf87fc..c0290e261 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -5,5 +5,5 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ pip install -e ".[async]" && \ pip install -e ".[adapter]" && \ - pip install "pytype==2022.4.15" && \ + pip install "pytype==2022.4.26" && \ pytype slack_bolt/ diff --git a/slack_bolt/app/__init__.py b/slack_bolt/app/__init__.py index b99c2c430..5838f6dec 100644 --- a/slack_bolt/app/__init__.py +++ b/slack_bolt/app/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa """Application interface in Bolt. For most use cases, we recommend using `slack_bolt.app.app`. @@ -6,4 +7,4 @@ """ # Don't add async module imports here -from .app import App # noqa: F401 type: ignore +from .app import App # type: ignore From 97e5b158151cbd2ac2c0337002492ac3096eebde Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 12 May 2022 07:35:16 +0900 Subject: [PATCH 477/865] Fix #644 app.message listener does not handle events when a file is attached (#645) --- slack_bolt/app/app.py | 3 + slack_bolt/app/async_app.py | 3 + .../scenario_tests/test_message_file_share.py | 173 +++++++++++++++++ .../test_message_file_share.py | 179 ++++++++++++++++++ 4 files changed, 358 insertions(+) create mode 100644 tests/scenario_tests/test_message_file_share.py create mode 100644 tests/scenario_tests_async/test_message_file_share.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index c94cbfebe..fcf82935b 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -837,6 +837,9 @@ def __call__(*args, **kwargs): # If an end-user posts a message with "Also send to #channel" checked, # the message event comes with this subtype. "thread_broadcast", + # If an end-user posts a message with attached files, + # the message event comes with this subtype. + "file_share", ), } primary_matcher = builtin_matchers.message_event( diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index aafdfb4b0..2df9bf120 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -894,6 +894,9 @@ def __call__(*args, **kwargs): # If an end-user posts a message with "Also send to #channel" checked, # the message event comes with this subtype. "thread_broadcast", + # If an end-user posts a message with attached files, + # the message event comes with this subtype. + "file_share", ), } primary_matcher = builtin_matchers.message_event( diff --git a/tests/scenario_tests/test_message_file_share.py b/tests/scenario_tests/test_message_file_share.py new file mode 100644 index 000000000..8921b37c9 --- /dev/null +++ b/tests/scenario_tests/test_message_file_share.py @@ -0,0 +1,173 @@ +import json +import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestMessageFileShare: + 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_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, payload: dict) -> BoltRequest: + timestamp, body = str(int(time.time())), json.dumps(payload) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_message_handler(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + result = {"call_count": 0} + + @app.message("Hi there!") + def handle_messages(event, logger): + logger.info(event) + result["call_count"] = result["call_count"] + 1 + + request = self.build_request(event_payload) + response = app.dispatch(request) + assert response.status == 200 + + request = self.build_request(event_payload) + response = app.dispatch(request) + assert response.status == 200 + + assert_auth_test_count(self, 1) + time.sleep(1) # wait a bit after auto ack() + assert result["call_count"] == 2 + + +event_payload = { + "token": "xxx", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "message", + "text": "Hi there!", + "files": [ + { + "id": "F111", + "created": 1652227642, + "timestamp": 1652227642, + "name": "file.png", + "title": "file.png", + "mimetype": "image/png", + "filetype": "png", + "pretty_type": "PNG", + "user": "U111", + "editable": False, + "size": 92582, + "mode": "hosted", + "is_external": False, + "external_type": "", + "is_public": True, + "public_url_shared": False, + "display_as_bot": False, + "username": "", + "url_private": "https://files.slack.com/files-pri/T111-F111/file.png", + "url_private_download": "https://files.slack.com/files-pri/T111-F111/download/file.png", + "media_display_type": "unknown", + "thumb_64": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_64.png", + "thumb_80": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_80.png", + "thumb_360": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_360.png", + "thumb_360_w": 360, + "thumb_360_h": 115, + "thumb_480": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_480.png", + "thumb_480_w": 480, + "thumb_480_h": 153, + "thumb_160": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_160.png", + "thumb_720": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_720.png", + "thumb_720_w": 720, + "thumb_720_h": 230, + "thumb_800": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_800.png", + "thumb_800_w": 800, + "thumb_800_h": 255, + "thumb_960": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_960.png", + "thumb_960_w": 960, + "thumb_960_h": 306, + "thumb_1024": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_1024.png", + "thumb_1024_w": 1024, + "thumb_1024_h": 327, + "original_w": 1134, + "original_h": 362, + "thumb_tiny": "AwAPADCkCAOcUEj0zTaKAHZHpT9oxwR+VRVMBQA0r3yPypu0f3v0p5yBTCcmmI//2Q==", + "permalink": "https://xxx.slack.com/files/U111/F111/file.png", + "permalink_public": "https://slack-files.com/T111-F111-faecabecf7", + "has_rich_preview": False, + } + ], + "upload": False, + "user": "U111", + "display_as_bot": False, + "ts": "1652227646.593159", + "blocks": [ + { + "type": "rich_text", + "block_id": "ba4", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "Hi there!"}], + } + ], + } + ], + "client_msg_id": "ca088267-717f-41a8-9db8-c98ae14ad6a0", + "channel": "C111", + "subtype": "file_share", + "event_ts": "1652227646.593159", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev03EGJQAVMM", + "event_time": 1652227646, + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T111", + "user_id": "U222", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "4-xxx", +} diff --git a/tests/scenario_tests_async/test_message_file_share.py b/tests/scenario_tests_async/test_message_file_share.py new file mode 100644 index 000000000..4130c6fb7 --- /dev/null +++ b/tests/scenario_tests_async/test_message_file_share.py @@ -0,0 +1,179 @@ +import asyncio +import json +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +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, + assert_auth_test_count_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncMessageFileShare: + 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, + ) + + @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) + + 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, payload: dict) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(payload) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_string_keyword(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + result = {"call_count": 0} + + @app.message("Hi there!") + async def handle_messages(event, logger): + logger.info(event) + result["call_count"] = result["call_count"] + 1 + + request = self.build_request(event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + request = self.build_request(event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(1) # wait a bit after auto ack() + assert result["call_count"] == 2 + + +event_payload = { + "token": "xxx", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "message", + "text": "Hi there!", + "files": [ + { + "id": "F111", + "created": 1652227642, + "timestamp": 1652227642, + "name": "file.png", + "title": "file.png", + "mimetype": "image/png", + "filetype": "png", + "pretty_type": "PNG", + "user": "U111", + "editable": False, + "size": 92582, + "mode": "hosted", + "is_external": False, + "external_type": "", + "is_public": True, + "public_url_shared": False, + "display_as_bot": False, + "username": "", + "url_private": "https://files.slack.com/files-pri/T111-F111/file.png", + "url_private_download": "https://files.slack.com/files-pri/T111-F111/download/file.png", + "media_display_type": "unknown", + "thumb_64": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_64.png", + "thumb_80": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_80.png", + "thumb_360": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_360.png", + "thumb_360_w": 360, + "thumb_360_h": 115, + "thumb_480": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_480.png", + "thumb_480_w": 480, + "thumb_480_h": 153, + "thumb_160": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_160.png", + "thumb_720": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_720.png", + "thumb_720_w": 720, + "thumb_720_h": 230, + "thumb_800": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_800.png", + "thumb_800_w": 800, + "thumb_800_h": 255, + "thumb_960": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_960.png", + "thumb_960_w": 960, + "thumb_960_h": 306, + "thumb_1024": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_1024.png", + "thumb_1024_w": 1024, + "thumb_1024_h": 327, + "original_w": 1134, + "original_h": 362, + "thumb_tiny": "AwAPADCkCAOcUEj0zTaKAHZHpT9oxwR+VRVMBQA0r3yPypu0f3v0p5yBTCcmmI//2Q==", + "permalink": "https://xxx.slack.com/files/U111/F111/file.png", + "permalink_public": "https://slack-files.com/T111-F111-faecabecf7", + "has_rich_preview": False, + } + ], + "upload": False, + "user": "U111", + "display_as_bot": False, + "ts": "1652227646.593159", + "blocks": [ + { + "type": "rich_text", + "block_id": "ba4", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "Hi there!"}], + } + ], + } + ], + "client_msg_id": "ca088267-717f-41a8-9db8-c98ae14ad6a0", + "channel": "C111", + "subtype": "file_share", + "event_ts": "1652227646.593159", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev03EGJQAVMM", + "event_time": 1652227646, + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T111", + "user_id": "U222", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "4-xxx", +} From 8b10a02acc954626ee5fbab99da4f4fdd9b48562 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 12 May 2022 07:53:30 +0900 Subject: [PATCH 478/865] version 1.13.2 --- docs/api-docs/slack_bolt/app/app.html | 9 +++++++++ docs/api-docs/slack_bolt/app/async_app.html | 9 +++++++++ docs/api-docs/slack_bolt/app/index.html | 5 +++-- docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index b7eac7755..4df4d89ec 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -865,6 +865,9 @@

    Module slack_bolt.app.app

    # If an end-user posts a message with "Also send to #channel" checked, # the message event comes with this subtype. "thread_broadcast", + # If an end-user posts a message with attached files, + # the message event comes with this subtype. + "file_share", ), } primary_matcher = builtin_matchers.message_event( @@ -2410,6 +2413,9 @@

    Args

    # If an end-user posts a message with "Also send to #channel" checked, # the message event comes with this subtype. "thread_broadcast", + # If an end-user posts a message with attached files, + # the message event comes with this subtype. + "file_share", ), } primary_matcher = builtin_matchers.message_event( @@ -3827,6 +3833,9 @@

    Args

    # If an end-user posts a message with "Also send to #channel" checked, # the message event comes with this subtype. "thread_broadcast", + # If an end-user posts a message with attached files, + # the message event comes with this subtype. + "file_share", ), } primary_matcher = builtin_matchers.message_event( diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index d22e917ec..894ee048c 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -922,6 +922,9 @@

    Module slack_bolt.app.async_app

    # If an end-user posts a message with "Also send to #channel" checked, # the message event comes with this subtype. "thread_broadcast", + # If an end-user posts a message with attached files, + # the message event comes with this subtype. + "file_share", ), } primary_matcher = builtin_matchers.message_event( @@ -2389,6 +2392,9 @@

    Args

    # If an end-user posts a message with "Also send to #channel" checked, # the message event comes with this subtype. "thread_broadcast", + # If an end-user posts a message with attached files, + # the message event comes with this subtype. + "file_share", ), } primary_matcher = builtin_matchers.message_event( @@ -3831,6 +3837,9 @@

    Args

    # If an end-user posts a message with "Also send to #channel" checked, # the message event comes with this subtype. "thread_broadcast", + # If an end-user posts a message with attached files, + # the message event comes with this subtype. + "file_share", ), } primary_matcher = builtin_matchers.message_event( diff --git a/docs/api-docs/slack_bolt/app/index.html b/docs/api-docs/slack_bolt/app/index.html index e54edaa72..2ad72f98a 100644 --- a/docs/api-docs/slack_bolt/app/index.html +++ b/docs/api-docs/slack_bolt/app/index.html @@ -30,7 +30,8 @@

    Module slack_bolt.app

    Expand source code -
    """Application interface in Bolt.
    +
    # flake8: noqa
    +"""Application interface in Bolt.
     
     For most use cases, we recommend using `slack_bolt.app.app`.
     If you already have knowledge about asyncio and prefer the programming model,
    @@ -38,7 +39,7 @@ 

    Module slack_bolt.app

    """ # Don't add async module imports here -from .app import App # noqa: F401 type: ignore
    +from .app import App # type: ignore
    diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index 1ae0d24f4..b085300c3 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.13.1"
    +__version__ = "1.13.2"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index a5295f2c9..d0ccd73c6 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.13.1" +__version__ = "1.13.2" From b637c298dfb1132ffd545a46d18bfad061eb18e6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 16 May 2022 08:42:19 +0900 Subject: [PATCH 479/865] Remove noqa comments and add __all__ to __init__.py files (#647) --- scripts/run_pytype.sh | 2 +- slack_bolt/__init__.py | 33 +++++++++++++------ slack_bolt/adapter/aiohttp/__init__.py | 6 ++++ slack_bolt/adapter/aws_lambda/__init__.py | 6 +++- slack_bolt/adapter/bottle/__init__.py | 6 +++- slack_bolt/adapter/cherrypy/__init__.py | 6 +++- slack_bolt/adapter/django/__init__.py | 6 +++- slack_bolt/adapter/falcon/__init__.py | 6 +++- slack_bolt/adapter/fastapi/__init__.py | 6 +++- slack_bolt/adapter/fastapi/async_handler.py | 6 +++- slack_bolt/adapter/flask/__init__.py | 6 +++- slack_bolt/adapter/pyramid/__init__.py | 6 +++- slack_bolt/adapter/sanic/__init__.py | 6 +++- slack_bolt/adapter/socket_mode/__init__.py | 6 +++- .../adapter/socket_mode/async_handler.py | 6 +++- slack_bolt/adapter/starlette/__init__.py | 6 +++- slack_bolt/adapter/tornado/__init__.py | 7 +++- slack_bolt/app/__init__.py | 4 +++ slack_bolt/app/async_app.py | 2 +- slack_bolt/async_app.py | 27 ++++++++++----- slack_bolt/authorization/__init__.py | 6 +++- slack_bolt/context/__init__.py | 6 +++- slack_bolt/context/ack/__init__.py | 6 +++- slack_bolt/context/respond/__init__.py | 6 +++- slack_bolt/context/say/__init__.py | 6 +++- slack_bolt/kwargs_injection/__init__.py | 9 +++-- slack_bolt/lazy_listener/__init__.py | 9 +++-- slack_bolt/listener/__init__.py | 6 ++++ slack_bolt/listener_matcher/__init__.py | 6 ++++ slack_bolt/logger/__init__.py | 6 ++++ slack_bolt/middleware/__init__.py | 26 +++++++++++---- slack_bolt/middleware/async_builtins.py | 18 +++++++--- .../middleware/authorization/__init__.py | 12 +++++-- .../ignoring_self_events/__init__.py | 6 +++- .../message_listener_matches/__init__.py | 6 +++- .../request_verification/__init__.py | 6 +++- slack_bolt/middleware/ssl_check/__init__.py | 6 +++- .../middleware/url_verification/__init__.py | 6 +++- slack_bolt/oauth/__init__.py | 6 +++- slack_bolt/request/__init__.py | 6 +++- slack_bolt/response/__init__.py | 6 +++- slack_bolt/workflows/step/__init__.py | 21 ++++++++---- .../workflows/step/async_step_middleware.py | 2 +- 43 files changed, 279 insertions(+), 73 deletions(-) diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index c0290e261..8f3825228 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -5,5 +5,5 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ pip install -e ".[async]" && \ pip install -e ".[adapter]" && \ - pip install "pytype==2022.4.26" && \ + pip install "pytype==2022.5.10" && \ pytype slack_bolt/ diff --git a/slack_bolt/__init__.py b/slack_bolt/__init__.py index 4050d83a9..b1065ca93 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -6,13 +6,26 @@ * The class representing a Bolt app: `slack_bolt.app.app` """ # noqa: E501 # Don't add async module imports here -from .app import App # noqa -from .context import BoltContext # noqa -from .context.ack import Ack # noqa -from .context.respond import Respond # noqa -from .context.say import Say # noqa -from .kwargs_injection import Args # noqa -from .listener import Listener # noqa -from .listener_matcher import CustomListenerMatcher # noqa -from .request import BoltRequest # noqa -from .response import BoltResponse # noqa +from .app import App +from .context import BoltContext +from .context.ack import Ack +from .context.respond import Respond +from .context.say import Say +from .kwargs_injection import Args +from .listener import Listener +from .listener_matcher import CustomListenerMatcher +from .request import BoltRequest +from .response import BoltResponse + +__all__ = [ + "App", + "BoltContext", + "Ack", + "Respond", + "Say", + "Args", + "Listener", + "CustomListenerMatcher", + "BoltRequest", + "BoltResponse", +] diff --git a/slack_bolt/adapter/aiohttp/__init__.py b/slack_bolt/adapter/aiohttp/__init__.py index 02d6a1673..b3a12d49b 100644 --- a/slack_bolt/adapter/aiohttp/__init__.py +++ b/slack_bolt/adapter/aiohttp/__init__.py @@ -39,3 +39,9 @@ async def to_aiohttp_response(bolt_resp: BoltResponse) -> web.Response: httponly=True, ) return resp + + +__all__ = [ + "to_bolt_request", + "to_aiohttp_response", +] diff --git a/slack_bolt/adapter/aws_lambda/__init__.py b/slack_bolt/adapter/aws_lambda/__init__.py index df8fd9d79..83f4882db 100644 --- a/slack_bolt/adapter/aws_lambda/__init__.py +++ b/slack_bolt/adapter/aws_lambda/__init__.py @@ -1 +1,5 @@ -from .handler import SlackRequestHandler # noqa: F401 +from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/bottle/__init__.py b/slack_bolt/adapter/bottle/__init__.py index df8fd9d79..83f4882db 100644 --- a/slack_bolt/adapter/bottle/__init__.py +++ b/slack_bolt/adapter/bottle/__init__.py @@ -1 +1,5 @@ -from .handler import SlackRequestHandler # noqa: F401 +from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/cherrypy/__init__.py b/slack_bolt/adapter/cherrypy/__init__.py index df8fd9d79..83f4882db 100644 --- a/slack_bolt/adapter/cherrypy/__init__.py +++ b/slack_bolt/adapter/cherrypy/__init__.py @@ -1 +1,5 @@ -from .handler import SlackRequestHandler # noqa: F401 +from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/django/__init__.py b/slack_bolt/adapter/django/__init__.py index df8fd9d79..83f4882db 100644 --- a/slack_bolt/adapter/django/__init__.py +++ b/slack_bolt/adapter/django/__init__.py @@ -1 +1,5 @@ -from .handler import SlackRequestHandler # noqa: F401 +from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/falcon/__init__.py b/slack_bolt/adapter/falcon/__init__.py index e54d2f1fc..4efa37a4b 100644 --- a/slack_bolt/adapter/falcon/__init__.py +++ b/slack_bolt/adapter/falcon/__init__.py @@ -1,2 +1,6 @@ # Don't add async module imports here -from .resource import SlackAppResource # noqa: F401 +from .resource import SlackAppResource + +__all__ = [ + "SlackAppResource", +] diff --git a/slack_bolt/adapter/fastapi/__init__.py b/slack_bolt/adapter/fastapi/__init__.py index 9ef794b02..224fd5fa2 100644 --- a/slack_bolt/adapter/fastapi/__init__.py +++ b/slack_bolt/adapter/fastapi/__init__.py @@ -1,2 +1,6 @@ # Don't add async module imports here -from ..starlette.handler import SlackRequestHandler # noqa +from ..starlette.handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/fastapi/async_handler.py b/slack_bolt/adapter/fastapi/async_handler.py index 718f81874..f2c149c6d 100644 --- a/slack_bolt/adapter/fastapi/async_handler.py +++ b/slack_bolt/adapter/fastapi/async_handler.py @@ -1 +1,5 @@ -from ..starlette.async_handler import AsyncSlackRequestHandler # noqa +from ..starlette.async_handler import AsyncSlackRequestHandler + +__all__ = [ + "AsyncSlackRequestHandler", +] diff --git a/slack_bolt/adapter/flask/__init__.py b/slack_bolt/adapter/flask/__init__.py index df8fd9d79..83f4882db 100644 --- a/slack_bolt/adapter/flask/__init__.py +++ b/slack_bolt/adapter/flask/__init__.py @@ -1 +1,5 @@ -from .handler import SlackRequestHandler # noqa: F401 +from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/pyramid/__init__.py b/slack_bolt/adapter/pyramid/__init__.py index df8fd9d79..83f4882db 100644 --- a/slack_bolt/adapter/pyramid/__init__.py +++ b/slack_bolt/adapter/pyramid/__init__.py @@ -1 +1,5 @@ -from .handler import SlackRequestHandler # noqa: F401 +from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/sanic/__init__.py b/slack_bolt/adapter/sanic/__init__.py index 02ee77873..3805e26ed 100644 --- a/slack_bolt/adapter/sanic/__init__.py +++ b/slack_bolt/adapter/sanic/__init__.py @@ -1 +1,5 @@ -from .async_handler import AsyncSlackRequestHandler # noqa: F401 +from .async_handler import AsyncSlackRequestHandler + +__all__ = [ + "AsyncSlackRequestHandler", +] diff --git a/slack_bolt/adapter/socket_mode/__init__.py b/slack_bolt/adapter/socket_mode/__init__.py index 0a00e1c11..681f5b2a8 100644 --- a/slack_bolt/adapter/socket_mode/__init__.py +++ b/slack_bolt/adapter/socket_mode/__init__.py @@ -7,4 +7,8 @@ """ # noqa: E501 # Don't add async module imports here -from .builtin import SocketModeHandler # noqa: F401 +from .builtin import SocketModeHandler + +__all__ = [ + "SocketModeHandler", +] diff --git a/slack_bolt/adapter/socket_mode/async_handler.py b/slack_bolt/adapter/socket_mode/async_handler.py index 9c6e21ff0..0044b0e9c 100644 --- a/slack_bolt/adapter/socket_mode/async_handler.py +++ b/slack_bolt/adapter/socket_mode/async_handler.py @@ -1,2 +1,6 @@ """Default implementation is the aiohttp-based one.""" -from .aiohttp import AsyncSocketModeHandler # noqa +from .aiohttp import AsyncSocketModeHandler + +__all__ = [ + "AsyncSocketModeHandler", +] diff --git a/slack_bolt/adapter/starlette/__init__.py b/slack_bolt/adapter/starlette/__init__.py index 848662494..ed44db04c 100644 --- a/slack_bolt/adapter/starlette/__init__.py +++ b/slack_bolt/adapter/starlette/__init__.py @@ -1,2 +1,6 @@ # Don't add async module imports here -from .handler import SlackRequestHandler # noqa: F401 +from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/tornado/__init__.py b/slack_bolt/adapter/tornado/__init__.py index fa4896201..8ef57f2aa 100644 --- a/slack_bolt/adapter/tornado/__init__.py +++ b/slack_bolt/adapter/tornado/__init__.py @@ -1 +1,6 @@ -from .handler import SlackEventsHandler, SlackOAuthHandler # noqa: F401 +from .handler import SlackEventsHandler, SlackOAuthHandler + +__all__ = [ + "SlackEventsHandler", + "SlackOAuthHandler", +] diff --git a/slack_bolt/app/__init__.py b/slack_bolt/app/__init__.py index 5838f6dec..3b1d9daef 100644 --- a/slack_bolt/app/__init__.py +++ b/slack_bolt/app/__init__.py @@ -8,3 +8,7 @@ # Don't add async module imports here from .app import App # type: ignore + +__all__ = [ + "App", +] diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 2df9bf120..41463b54d 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -14,7 +14,7 @@ from slack_bolt.listener.async_listener_completion_handler import ( AsyncDefaultListenerCompletionHandler, ) -from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner +from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner # type: ignore from slack_bolt.middleware.async_middleware_error_handler import ( AsyncCustomMiddlewareErrorHandler, AsyncDefaultMiddlewareErrorHandler, diff --git a/slack_bolt/async_app.py b/slack_bolt/async_app.py index ed924f13b..9fdb5a794 100644 --- a/slack_bolt/async_app.py +++ b/slack_bolt/async_app.py @@ -44,11 +44,22 @@ async def command(ack, body, respond): Refer to `slack_bolt.app.async_app` for more details. """ # noqa: E501 -from .app.async_app import AsyncApp # noqa -from .context.ack.async_ack import AsyncAck # noqa -from .context.async_context import AsyncBoltContext # noqa -from .context.respond.async_respond import AsyncRespond # noqa -from .context.say.async_say import AsyncSay # noqa -from .listener.async_listener import AsyncListener # noqa -from .listener_matcher.async_listener_matcher import AsyncCustomListenerMatcher # noqa -from .request.async_request import AsyncBoltRequest # noqa +from .app.async_app import AsyncApp +from .context.ack.async_ack import AsyncAck +from .context.async_context import AsyncBoltContext +from .context.respond.async_respond import AsyncRespond +from .context.say.async_say import AsyncSay +from .listener.async_listener import AsyncListener +from .listener_matcher.async_listener_matcher import AsyncCustomListenerMatcher +from .request.async_request import AsyncBoltRequest + +__all__ = [ + "AsyncApp", + "AsyncAck", + "AsyncBoltContext", + "AsyncRespond", + "AsyncSay", + "AsyncListener", + "AsyncCustomListenerMatcher", + "AsyncBoltRequest", +] diff --git a/slack_bolt/authorization/__init__.py b/slack_bolt/authorization/__init__.py index 9ea3c8da5..efd9262a8 100644 --- a/slack_bolt/authorization/__init__.py +++ b/slack_bolt/authorization/__init__.py @@ -3,4 +3,8 @@ Refer to https://slack.dev/bolt-python/concepts#authorization for details. """ -from .authorize_result import AuthorizeResult # noqa +from .authorize_result import AuthorizeResult + +__all__ = [ + "AuthorizeResult", +] diff --git a/slack_bolt/context/__init__.py b/slack_bolt/context/__init__.py index 951e00828..fb3337c7a 100644 --- a/slack_bolt/context/__init__.py +++ b/slack_bolt/context/__init__.py @@ -6,4 +6,8 @@ """ # Don't add async module imports here -from .context import BoltContext # noqa: F401 +from .context import BoltContext + +__all__ = [ + "BoltContext", +] diff --git a/slack_bolt/context/ack/__init__.py b/slack_bolt/context/ack/__init__.py index 8e5767113..dfbb4736f 100644 --- a/slack_bolt/context/ack/__init__.py +++ b/slack_bolt/context/ack/__init__.py @@ -1,2 +1,6 @@ # Don't add async module imports here -from .ack import Ack # noqa: F401 +from .ack import Ack + +__all__ = [ + "Ack", +] diff --git a/slack_bolt/context/respond/__init__.py b/slack_bolt/context/respond/__init__.py index 135f87277..72670819f 100644 --- a/slack_bolt/context/respond/__init__.py +++ b/slack_bolt/context/respond/__init__.py @@ -1,2 +1,6 @@ # Don't add async module imports here -from .respond import Respond # noqa: F401 +from .respond import Respond + +__all__ = [ + "Respond", +] diff --git a/slack_bolt/context/say/__init__.py b/slack_bolt/context/say/__init__.py index 82dad6a7f..84ecd5be4 100644 --- a/slack_bolt/context/say/__init__.py +++ b/slack_bolt/context/say/__init__.py @@ -1,2 +1,6 @@ # Don't add async module imports here -from .say import Say # noqa: F401 +from .say import Say + +__all__ = [ + "Say", +] diff --git a/slack_bolt/kwargs_injection/__init__.py b/slack_bolt/kwargs_injection/__init__.py index 56e1fa53e..1fa97cc1b 100644 --- a/slack_bolt/kwargs_injection/__init__.py +++ b/slack_bolt/kwargs_injection/__init__.py @@ -5,5 +5,10 @@ """ # Don't add async module imports here -from .args import Args # noqa: F401 -from .utils import build_required_kwargs # noqa: F401 +from .args import Args +from .utils import build_required_kwargs + +__all__ = [ + "Args", + "build_required_kwargs", +] diff --git a/slack_bolt/lazy_listener/__init__.py b/slack_bolt/lazy_listener/__init__.py index 5c88ffa02..0a8e7c0b4 100644 --- a/slack_bolt/lazy_listener/__init__.py +++ b/slack_bolt/lazy_listener/__init__.py @@ -22,5 +22,10 @@ def run_long_process(respond, body): Refer to https://slack.dev/bolt-python/concepts#lazy-listeners for more details. """ # Don't add async module imports here -from .runner import LazyListenerRunner # noqa -from .thread_runner import ThreadLazyListenerRunner # noqa +from .runner import LazyListenerRunner +from .thread_runner import ThreadLazyListenerRunner + +__all__ = [ + "LazyListenerRunner", + "ThreadLazyListenerRunner", +] diff --git a/slack_bolt/listener/__init__.py b/slack_bolt/listener/__init__.py index b18276a54..a12a2b821 100644 --- a/slack_bolt/listener/__init__.py +++ b/slack_bolt/listener/__init__.py @@ -12,3 +12,9 @@ ] for cls in builtin_listener_classes: Listener.register(cls) + +__all__ = [ + "CustomListener", + "Listener", + "builtin_listener_classes", +] diff --git a/slack_bolt/listener_matcher/__init__.py b/slack_bolt/listener_matcher/__init__.py index 08432a1b0..352c35c48 100644 --- a/slack_bolt/listener_matcher/__init__.py +++ b/slack_bolt/listener_matcher/__init__.py @@ -11,3 +11,9 @@ ] for cls in builtin_listener_matcher_classes: ListenerMatcher.register(cls) + +__all__ = [ + "CustomListenerMatcher", + "ListenerMatcher", + "builtin_listener_matcher_classes", +] diff --git a/slack_bolt/logger/__init__.py b/slack_bolt/logger/__init__.py index 77e26a983..a096d8abc 100644 --- a/slack_bolt/logger/__init__.py +++ b/slack_bolt/logger/__init__.py @@ -44,3 +44,9 @@ def _configure_from_base_logger(new_logger: Logger, base_logger: Logger): def _configure_from_root(new_logger: Logger): new_logger.disabled = logging.root.disabled new_logger.level = logging.root.level + + +__all__ = [ + "get_bolt_logger", + "get_bolt_app_logger", +] diff --git a/slack_bolt/middleware/__init__.py b/slack_bolt/middleware/__init__.py index a21747e92..ef42a6a31 100644 --- a/slack_bolt/middleware/__init__.py +++ b/slack_bolt/middleware/__init__.py @@ -9,13 +9,13 @@ from .authorization import ( SingleTeamAuthorization, MultiTeamsAuthorization, -) # noqa: F401 -from .custom_middleware import CustomMiddleware # noqa: F401 -from .ignoring_self_events import IgnoringSelfEvents # noqa: F401 -from .middleware import Middleware # noqa: F401 -from .request_verification import RequestVerification # noqa: F401 -from .ssl_check import SslCheck # noqa: F401 -from .url_verification import UrlVerification # noqa: F401 +) +from .custom_middleware import CustomMiddleware +from .ignoring_self_events import IgnoringSelfEvents +from .middleware import Middleware +from .request_verification import RequestVerification +from .ssl_check import SslCheck +from .url_verification import UrlVerification builtin_middleware_classes = [ SslCheck, @@ -27,3 +27,15 @@ ] for cls in builtin_middleware_classes: Middleware.register(cls) + +__all__ = [ + "SingleTeamAuthorization", + "MultiTeamsAuthorization", + "CustomMiddleware", + "IgnoringSelfEvents", + "Middleware", + "RequestVerification", + "SslCheck", + "UrlVerification", + "builtin_middleware_classes", +] diff --git a/slack_bolt/middleware/async_builtins.py b/slack_bolt/middleware/async_builtins.py index 46cef9003..2b279cc27 100644 --- a/slack_bolt/middleware/async_builtins.py +++ b/slack_bolt/middleware/async_builtins.py @@ -1,11 +1,19 @@ -from .ignoring_self_events.async_ignoring_self_events import ( # noqa: F401 +from .ignoring_self_events.async_ignoring_self_events import ( AsyncIgnoringSelfEvents, ) -from .request_verification.async_request_verification import ( # noqa: F401 +from .request_verification.async_request_verification import ( AsyncRequestVerification, ) -from .ssl_check.async_ssl_check import AsyncSslCheck # noqa: F401 -from .url_verification.async_url_verification import AsyncUrlVerification # noqa: F401 -from .message_listener_matches.async_message_listener_matches import ( # noqa: F401 +from .ssl_check.async_ssl_check import AsyncSslCheck +from .url_verification.async_url_verification import AsyncUrlVerification +from .message_listener_matches.async_message_listener_matches import ( AsyncMessageListenerMatches, ) + +__all__ = [ + "AsyncIgnoringSelfEvents", + "AsyncRequestVerification", + "AsyncSslCheck", + "AsyncUrlVerification", + "AsyncMessageListenerMatches", +] diff --git a/slack_bolt/middleware/authorization/__init__.py b/slack_bolt/middleware/authorization/__init__.py index fd5b10263..714868274 100644 --- a/slack_bolt/middleware/authorization/__init__.py +++ b/slack_bolt/middleware/authorization/__init__.py @@ -1,4 +1,10 @@ # Don't add async module imports here -from .authorization import Authorization # noqa: F401 -from .multi_teams_authorization import MultiTeamsAuthorization # noqa: F401 -from .single_team_authorization import SingleTeamAuthorization # noqa: F401 +from .authorization import Authorization +from .multi_teams_authorization import MultiTeamsAuthorization +from .single_team_authorization import SingleTeamAuthorization + +__all__ = [ + "Authorization", + "MultiTeamsAuthorization", + "SingleTeamAuthorization", +] diff --git a/slack_bolt/middleware/ignoring_self_events/__init__.py b/slack_bolt/middleware/ignoring_self_events/__init__.py index b679760bb..1212e6468 100644 --- a/slack_bolt/middleware/ignoring_self_events/__init__.py +++ b/slack_bolt/middleware/ignoring_self_events/__init__.py @@ -1 +1,5 @@ -from .ignoring_self_events import IgnoringSelfEvents # noqa +from .ignoring_self_events import IgnoringSelfEvents + +__all__ = [ + "IgnoringSelfEvents", +] diff --git a/slack_bolt/middleware/message_listener_matches/__init__.py b/slack_bolt/middleware/message_listener_matches/__init__.py index d6825675e..090d5679e 100644 --- a/slack_bolt/middleware/message_listener_matches/__init__.py +++ b/slack_bolt/middleware/message_listener_matches/__init__.py @@ -1 +1,5 @@ -from .message_listener_matches import MessageListenerMatches # noqa +from .message_listener_matches import MessageListenerMatches + +__all__ = [ + "MessageListenerMatches", +] diff --git a/slack_bolt/middleware/request_verification/__init__.py b/slack_bolt/middleware/request_verification/__init__.py index f2e70fda7..a8c564886 100644 --- a/slack_bolt/middleware/request_verification/__init__.py +++ b/slack_bolt/middleware/request_verification/__init__.py @@ -1 +1,5 @@ -from .request_verification import RequestVerification # noqa +from .request_verification import RequestVerification + +__all__ = [ + "RequestVerification", +] diff --git a/slack_bolt/middleware/ssl_check/__init__.py b/slack_bolt/middleware/ssl_check/__init__.py index 3b2a137d7..33a3708d5 100644 --- a/slack_bolt/middleware/ssl_check/__init__.py +++ b/slack_bolt/middleware/ssl_check/__init__.py @@ -1 +1,5 @@ -from .ssl_check import SslCheck # noqa +from .ssl_check import SslCheck + +__all__ = [ + "SslCheck", +] diff --git a/slack_bolt/middleware/url_verification/__init__.py b/slack_bolt/middleware/url_verification/__init__.py index 73c2e2952..4fc29dfce 100644 --- a/slack_bolt/middleware/url_verification/__init__.py +++ b/slack_bolt/middleware/url_verification/__init__.py @@ -1 +1,5 @@ -from .url_verification import UrlVerification # noqa +from .url_verification import UrlVerification + +__all__ = [ + "UrlVerification", +] diff --git a/slack_bolt/oauth/__init__.py b/slack_bolt/oauth/__init__.py index d07b12785..c4f806698 100644 --- a/slack_bolt/oauth/__init__.py +++ b/slack_bolt/oauth/__init__.py @@ -4,4 +4,8 @@ """ # Don't add async module imports here -from .oauth_flow import OAuthFlow # noqa +from .oauth_flow import OAuthFlow + +__all__ = [ + "OAuthFlow", +] diff --git a/slack_bolt/request/__init__.py b/slack_bolt/request/__init__.py index 402d70cde..0a0620611 100644 --- a/slack_bolt/request/__init__.py +++ b/slack_bolt/request/__init__.py @@ -4,4 +4,8 @@ This interface encapsulates the difference between the two. """ # Don't add async module imports here -from .request import BoltRequest # noqa: F401 +from .request import BoltRequest + +__all__ = [ + "BoltRequest", +] diff --git a/slack_bolt/response/__init__.py b/slack_bolt/response/__init__.py index 2dfd1d695..373acccf2 100644 --- a/slack_bolt/response/__init__.py +++ b/slack_bolt/response/__init__.py @@ -6,4 +6,8 @@ Refer to https://api.slack.com/apis/connections for the two types of connections. """ -from .response import BoltResponse # noqa: F401 +from .response import BoltResponse + +__all__ = [ + "BoltResponse", +] diff --git a/slack_bolt/workflows/step/__init__.py b/slack_bolt/workflows/step/__init__.py index 9418a2e4c..d7402cb40 100644 --- a/slack_bolt/workflows/step/__init__.py +++ b/slack_bolt/workflows/step/__init__.py @@ -1,6 +1,15 @@ -from .step import WorkflowStep # noqa: F401 -from .step_middleware import WorkflowStepMiddleware # noqa: F401 -from .utilities.complete import Complete # noqa: F401 -from .utilities.configure import Configure # noqa: F401 -from .utilities.update import Update # noqa: F401 -from .utilities.fail import Fail # noqa: F401 +from .step import WorkflowStep +from .step_middleware import WorkflowStepMiddleware +from .utilities.complete import Complete +from .utilities.configure import Configure +from .utilities.update import Update +from .utilities.fail import Fail + +__all__ = [ + "WorkflowStep", + "WorkflowStepMiddleware", + "Complete", + "Configure", + "Update", + "Fail", +] diff --git a/slack_bolt/workflows/step/async_step_middleware.py b/slack_bolt/workflows/step/async_step_middleware.py index 150729764..1f70602d3 100644 --- a/slack_bolt/workflows/step/async_step_middleware.py +++ b/slack_bolt/workflows/step/async_step_middleware.py @@ -1,7 +1,7 @@ from typing import Callable, Optional, Awaitable from slack_bolt.listener.async_listener import AsyncListener -from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner +from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner # type: ignore from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse From 4fef42db215360b4e90ace9f83e7375090c93ddd Mon Sep 17 00:00:00 2001 From: Heston Hoffman Date: Sun, 15 May 2022 22:19:28 -0700 Subject: [PATCH 480/865] Clarify "Setting up events" section (#648) This adds a few extra directions to help users find the **Subscribe to bot events" section. --- docs/_tutorials/getting_started.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_tutorials/getting_started.md b/docs/_tutorials/getting_started.md index 105ae5a9e..99f45166e 100644 --- a/docs/_tutorials/getting_started.md +++ b/docs/_tutorials/getting_started.md @@ -144,7 +144,7 @@ It's time to tell Slack what events we'd like to listen for. When an event occurs, Slack will send your app some information about the event, like the user that triggered it and the channel it occurred in. Your app will process the details and can respond accordingly. -Scroll down to **Subscribe to Bot Events**. There are four events related to messages: +Navigate to **Event Subscriptions** on the left sidebar and toggle to enable. Under **Subscribe to Bot Events**, you can add events for your bot to respond to. There are four events related to messages: - [`message.channels`](https://api.slack.com/events/message.channels) listens for messages in public channels that your app is added to - [`message.groups`](https://api.slack.com/events/message.groups) listens for messages in 🔒 private channels that your app is added to - [`message.im`](https://api.slack.com/events/message.im) listens for messages in your app's DMs with users @@ -299,4 +299,4 @@ Now that you have a basic app up and running, you can start exploring how to mak * Bolt allows you to [call Web API methods](/bolt-python/concepts#web-api) with the client attached to your app. There are [over 220 methods](https://api.slack.com/methods) on our API site. -* Learn more about the different token types [on our API site](https://api.slack.com/docs/token-types). Your app may need different tokens depending on the actions you want it to perform. For apps that do not use Socket Mode, typically only the bot (`xoxb`) token and Signing Secret are required. For example of this, see our parallel guide [Getting Started with HTTP](/bolt-python/tutorial/getting-started-http). \ No newline at end of file +* Learn more about the different token types [on our API site](https://api.slack.com/docs/token-types). Your app may need different tokens depending on the actions you want it to perform. For apps that do not use Socket Mode, typically only the bot (`xoxb`) token and Signing Secret are required. For example of this, see our parallel guide [Getting Started with HTTP](/bolt-python/tutorial/getting-started-http). From 5d3ee55b738b8d8113979e99916e67e52aa2d6c0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 16 May 2022 14:22:37 +0900 Subject: [PATCH 481/865] Apply #648 changes to other pages too --- docs/_tutorials/getting_started_http.md | 2 +- docs/_tutorials/ja_getting_started.md | 2 +- docs/_tutorials/ja_getting_started_http.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/_tutorials/getting_started_http.md b/docs/_tutorials/getting_started_http.md index 795a1e891..a9de0e1df 100644 --- a/docs/_tutorials/getting_started_http.md +++ b/docs/_tutorials/getting_started_http.md @@ -143,7 +143,7 @@ Finally, it's time to tell Slack what events we'd like to listen for. When an event occurs, Slack will send your app some information about the event, like the user that triggered it and the channel it occurred in. Your app will process the details and can respond accordingly. -Scroll down to **Subscribe to Bot Events**. There are four events related to messages: +Navigate to **Event Subscriptions** on the left sidebar and toggle to enable. Under **Subscribe to Bot Events**, you can add events for your bot to respond to. There are four events related to messages: - [`message.channels`](https://api.slack.com/events/message.channels) listens for messages in public channels that your app is added to - [`message.groups`](https://api.slack.com/events/message.groups) listens for messages in 🔒 private channels that your app is added to - [`message.im`](https://api.slack.com/events/message.im) listens for messages in your app's DMs with users diff --git a/docs/_tutorials/ja_getting_started.md b/docs/_tutorials/ja_getting_started.md index 4aa564b11..ae34c3562 100644 --- a/docs/_tutorials/ja_getting_started.md +++ b/docs/_tutorials/ja_getting_started.md @@ -145,7 +145,7 @@ Slack ワークスペースで発生するイベント(メッセージが投 イベントが発生すると、そのイベントをトリガーしたユーザーやイベントが発生したチャンネルなど、イベントに関する情報が Slack からアプリに送信されます。アプリではこれらの情報を処理して、適切な応答を返します。 -**Subscribe to Bot Events** まで下にスクロールします。4つのメッセージに関するイベントがあります。 +左側のサイドバーから **Event Subscriptions** にアクセスして、機能を有効にしてください。 **Subscribe to Bot Events** 配下で、ボットが受け取れる イベントを追加することができます。4つのメッセージに関するイベントがあります。 - [`message.channels`](https://api.slack.com/events/message.channels) アプリが参加しているパブリックチャンネルのメッセージをリッスン - [`message.groups`](https://api.slack.com/events/message.groups) アプリが参加しているプライベートチャンネルのメッセージをリッスン - [`message.im`](https://api.slack.com/events/message.im) あなたのアプリとユーザーのダイレクトメッセージをリッスン diff --git a/docs/_tutorials/ja_getting_started_http.md b/docs/_tutorials/ja_getting_started_http.md index cf8bb35fb..fdd53bd94 100644 --- a/docs/_tutorials/ja_getting_started_http.md +++ b/docs/_tutorials/ja_getting_started_http.md @@ -148,7 +148,7 @@ Slack ワークスペースで発生するイベント(メッセージが投 イベントが発生すると、そのイベントをトリガーしたユーザーやイベントが発生したチャンネルなど、イベントに関する情報が Slack からアプリに送信されます。アプリではこれらの情報を処理して、適切な応答を返します。 -**Subscribe to Bot Events** まで下にスクロールします。4つのメッセージに関するイベントがあります。 +左側のサイドバーから **Event Subscriptions** にアクセスして、機能を有効にしてください。 **Subscribe to Bot Events** 配下で、ボットが受け取れる イベントを追加することができます。4つのメッセージに関するイベントがあります。 - [`message.channels`](https://api.slack.com/events/message.channels) アプリが参加しているパブリックチャンネルのメッセージをリッスン - [`message.groups`](https://api.slack.com/events/message.groups) アプリが参加しているプライベートチャンネルのメッセージをリッスン - [`message.im`](https://api.slack.com/events/message.im) あなたのアプリとユーザーのダイレクトメッセージをリッスン From 0a2cee6a19e486eef51a66cd0bdd110076ffd0bc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 18 May 2022 09:14:17 +0900 Subject: [PATCH 482/865] Add Google Cloud Functions adapter (ref #646) (#649) --- .../.env.yaml.oauth-sample | 4 + examples/google_cloud_functions/.gitignore | 3 +- examples/google_cloud_functions/datastore.py | 252 ++++++++++++++++++ examples/google_cloud_functions/handler.py | 66 +++++ examples/google_cloud_functions/oauth_main.py | 101 +++++++ .../google_cloud_functions/requirements.txt | 3 +- .../{main.py => simple_main.py} | 31 ++- .../google_cloud_functions/__init__.py | 5 + .../adapter/google_cloud_functions/handler.py | 43 +++ .../google_cloud_functions/__init__.py | 0 .../test_google_cloud_functions.py | 242 +++++++++++++++++ 11 files changed, 742 insertions(+), 8 deletions(-) create mode 100644 examples/google_cloud_functions/.env.yaml.oauth-sample create mode 100644 examples/google_cloud_functions/datastore.py create mode 100644 examples/google_cloud_functions/handler.py create mode 100644 examples/google_cloud_functions/oauth_main.py rename examples/google_cloud_functions/{main.py => simple_main.py} (64%) create mode 100644 slack_bolt/adapter/google_cloud_functions/__init__.py create mode 100644 slack_bolt/adapter/google_cloud_functions/handler.py create mode 100644 tests/adapter_tests/google_cloud_functions/__init__.py create mode 100644 tests/adapter_tests/google_cloud_functions/test_google_cloud_functions.py diff --git a/examples/google_cloud_functions/.env.yaml.oauth-sample b/examples/google_cloud_functions/.env.yaml.oauth-sample new file mode 100644 index 000000000..75ff16f8b --- /dev/null +++ b/examples/google_cloud_functions/.env.yaml.oauth-sample @@ -0,0 +1,4 @@ +SLACK_CLIENT_ID: '1111.222' +SLACK_CLIENT_SECRET: 'xxx' +SLACK_SIGNING_SECRET: 'yyy' +SLACK_SCOPES: 'app_mentions:read,chat:write,commands' diff --git a/examples/google_cloud_functions/.gitignore b/examples/google_cloud_functions/.gitignore index 69748e961..312fe55e1 100644 --- a/examples/google_cloud_functions/.gitignore +++ b/examples/google_cloud_functions/.gitignore @@ -1 +1,2 @@ -.env.yaml \ No newline at end of file +.env.yaml +main.py diff --git a/examples/google_cloud_functions/datastore.py b/examples/google_cloud_functions/datastore.py new file mode 100644 index 000000000..dfdfbcbc8 --- /dev/null +++ b/examples/google_cloud_functions/datastore.py @@ -0,0 +1,252 @@ +# +# Please note that this is an example implementation. +# You can reuse this implementation for your app, +# but we don't have short-term plans to add this code to slack-sdk package. +# Please maintain the code on your own if you copy this file. +# +# Also, please refer to the following gist for more discussion and better implementation: +# https://gist.github.com/seratch/d81a445ef4467b16f047156bf859cda8 +# + +import logging +from logging import Logger +from typing import Optional +from uuid import uuid4 + +from google.cloud import datastore +from google.cloud.datastore import Client, Entity, Query +from slack_sdk.oauth import OAuthStateStore, InstallationStore +from slack_sdk.oauth.installation_store import Installation, Bot + + +class GoogleDatastoreInstallationStore(InstallationStore): + datastore_client: Client + + def __init__( + self, + *, + datastore_client: Client, + logger: Logger, + ): + self.datastore_client = datastore_client + self._logger = logger + + @property + def logger(self) -> Logger: + if self._logger is None: + self._logger = logging.getLogger(__name__) + return self._logger + + def installation_key( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str], + suffix: Optional[str] = None, + is_enterprise_install: Optional[bool] = None, + ): + enterprise_id = enterprise_id or "none" + team_id = "none" if is_enterprise_install else team_id or "none" + name = ( + f"{enterprise_id}-{team_id}-{user_id}" + if user_id + else f"{enterprise_id}-{team_id}" + ) + if suffix is not None: + name += "-" + suffix + return self.datastore_client.key("installations", name) + + def bot_key( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + suffix: Optional[str] = None, + is_enterprise_install: Optional[bool] = None, + ): + enterprise_id = enterprise_id or "none" + team_id = "none" if is_enterprise_install else team_id or "none" + name = f"{enterprise_id}-{team_id}" + if suffix is not None: + name += "-" + suffix + return self.datastore_client.key("bots", name) + + def save(self, i: Installation): + # the latest installation in the workspace + installation_entity: Entity = datastore.Entity( + key=self.installation_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + user_id=None, # user_id is removed + is_enterprise_install=i.is_enterprise_install, + ) + ) + installation_entity.update(**i.to_dict()) + self.datastore_client.put(installation_entity) + + # the latest installation associated with a user + user_entity: Entity = datastore.Entity( + key=self.installation_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + user_id=i.user_id, + is_enterprise_install=i.is_enterprise_install, + ) + ) + user_entity.update(**i.to_dict()) + self.datastore_client.put(user_entity) + # history data + user_entity.key = self.installation_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + user_id=i.user_id, + is_enterprise_install=i.is_enterprise_install, + suffix=str(i.installed_at), + ) + self.datastore_client.put(user_entity) + + # the latest bot authorization in the workspace + bot = i.to_bot() + bot_entity: Entity = datastore.Entity( + key=self.bot_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + is_enterprise_install=i.is_enterprise_install, + ) + ) + bot_entity.update(**bot.to_dict()) + self.datastore_client.put(bot_entity) + # history data + bot_entity.key = self.bot_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + is_enterprise_install=i.is_enterprise_install, + suffix=str(i.installed_at), + ) + self.datastore_client.put(bot_entity) + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + entity: Entity = self.datastore_client.get( + self.bot_key( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=is_enterprise_install, + ) + ) + if entity is not None: + entity["installed_at"] = entity["installed_at"].timestamp() + return Bot(**entity) + return None + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + entity: Entity = self.datastore_client.get( + self.installation_key( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=is_enterprise_install, + ) + ) + if entity is not None: + entity["installed_at"] = entity["installed_at"].timestamp() + return Installation(**entity) + return None + + def delete_installation( + self, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str], + ) -> None: + installation_key = self.installation_key( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + ) + q: Query = self.datastore_client.query() + q.key_filter(installation_key, ">=") + for entity in q.fetch(): + if entity.key.name.startswith(installation_key.name): + self.datastore_client.delete(entity.key) + else: + break + + def delete_bot( + self, + enterprise_id: Optional[str], + team_id: Optional[str], + ) -> None: + bot_key = self.bot_key( + enterprise_id=enterprise_id, + team_id=team_id, + ) + q: Query = self.datastore_client.query() + q.key_filter(bot_key, ">=") + for entity in q.fetch(): + if entity.key.name.startswith(bot_key.name): + self.datastore_client.delete(entity.key) + else: + break + + def delete_all( + self, + enterprise_id: Optional[str], + team_id: Optional[str], + ): + self.delete_bot(enterprise_id=enterprise_id, team_id=team_id) + self.delete_installation( + enterprise_id=enterprise_id, team_id=team_id, user_id=None + ) + + +class GoogleDatastoreOAuthStateStore(OAuthStateStore): + logger: Logger + datastore_client: Client + collection_id: str + + def __init__( + self, + *, + datastore_client: Client, + logger: Logger, + ): + self.datastore_client = datastore_client + self._logger = logger + self.collection_id = "oauth_state_values" + + @property + def logger(self) -> Logger: + if self._logger is None: + self._logger = logging.getLogger(__name__) + return self._logger + + def consume(self, state: str) -> bool: + key = self.datastore_client.key(self.collection_id, state) + entity = self.datastore_client.get(key) + if entity is not None: + self.datastore_client.delete(key) + return True + return False + + def issue(self, *args, **kwargs) -> str: + state_value = str(uuid4()) + entity: Entity = datastore.Entity( + key=self.datastore_client.key(self.collection_id, state_value) + ) + entity.update(value=state_value) + self.datastore_client.put(entity) + return state_value diff --git a/examples/google_cloud_functions/handler.py b/examples/google_cloud_functions/handler.py new file mode 100644 index 000000000..df5b9a351 --- /dev/null +++ b/examples/google_cloud_functions/handler.py @@ -0,0 +1,66 @@ +# TODO: Once this once a new version newer than 1.13.2, delete this file + +from typing import Callable + +from flask import Request, Response, make_response + +from slack_bolt.app import App +from slack_bolt.error import BoltError +from slack_bolt.lazy_listener import LazyListenerRunner +from slack_bolt.oauth import OAuthFlow +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +def to_bolt_request(req: Request) -> BoltRequest: + return BoltRequest( # type: ignore + body=req.get_data(as_text=True), + query=req.query_string.decode("utf-8"), + headers=req.headers, # type: ignore + ) # type: ignore + + +def to_flask_response(bolt_resp: BoltResponse) -> Response: + resp: Response = make_response(bolt_resp.body, bolt_resp.status) + for k, values in bolt_resp.headers.items(): + if k.lower() == "content-type" and resp.headers.get("content-type") is not None: + # Remove the one set by Flask + resp.headers.pop("content-type") + for v in values: + resp.headers.add_header(k, v) + return resp + + +class NoopLazyListenerRunner(LazyListenerRunner): + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + raise BoltError( + "The google_cloud_functions adapter does not support lazy listeners. " + "Please consider either having a queue to pass the request to a different function or " + "rewriting your code not to use lazy listeners." + ) + + +class SlackRequestHandler: + def __init__(self, app: App): # type: ignore + self.app = app + # Note that lazy listener is not supported + self.app.listener_runner.lazy_listener_runner = NoopLazyListenerRunner() + if self.app.oauth_flow is not None: + self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?" + + def handle(self, req: Request) -> Response: + if req.method == "GET": + if self.app.oauth_flow is not None: + oauth_flow: OAuthFlow = self.app.oauth_flow + bolt_req = to_bolt_request(req) + if "code" in req.args or "error" in req.args or "state" in req.args: + bolt_resp = oauth_flow.handle_callback(bolt_req) + return to_flask_response(bolt_resp) + else: + bolt_resp = oauth_flow.handle_installation(bolt_req) + return to_flask_response(bolt_resp) + elif req.method == "POST": + bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req)) + return to_flask_response(bolt_resp) + + return make_response("Not Found", 404) diff --git a/examples/google_cloud_functions/oauth_main.py b/examples/google_cloud_functions/oauth_main.py new file mode 100644 index 000000000..f6355f779 --- /dev/null +++ b/examples/google_cloud_functions/oauth_main.py @@ -0,0 +1,101 @@ +# https://cloud.google.com/functions/docs/first-python + +import logging + +from slack_bolt.oauth.oauth_settings import OAuthSettings + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App +from datastore import GoogleDatastoreInstallationStore, GoogleDatastoreOAuthStateStore + +from google.cloud import datastore + +datastore_client = datastore.Client() +logger = logging.getLogger(__name__) + +# process_before_response must be True when running on FaaS +app = App( + process_before_response=True, + installation_store=GoogleDatastoreInstallationStore( + datastore_client=datastore_client, + logger=logger, + ), + oauth_settings=OAuthSettings( + state_store=GoogleDatastoreOAuthStateStore( + datastore_client=datastore_client, + logger=logger, + ), + ), +) + + +@app.command("/hello-bolt-python-gcp") +def hello_command(ack): + ack("Hi from Google Cloud Functions!") + + +@app.event("app_mention") +def event_test(body, say, logger): + logger.info(body) + say("Hi from Google Cloud Functions!") + + +# Flask adapter +# TODO: Once this once a new version newer than 1.13.2, delete handler and enable the slack_bolt.adapter import instead +# from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler +from handler import SlackRequestHandler +from flask import Request + + +handler = SlackRequestHandler(app) + + +# Cloud Function +def hello_bolt_app(req: Request): + """HTTP Cloud Function. + Args: + req (flask.Request): The request object. + + Returns: + The response text, or any set of values that can be turned into a + Response object using `make_response` + . + """ + return handler.handle(req) + + +# For local development +# python main.py +if __name__ == "__main__": + from flask import Flask, request + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["GET", "POST"]) + def handle_anything(): + return handler.handle(request) + + flask_app.run(port=3000) + + +# Step1: Create a new Slack App: https://api.slack.com/apps +# Bot Token Scopes: app_mentions:read,chat:write,commands + +# Step2: Set env variables +# cp .env.yaml.oauth-sample .env.yaml +# vi .env.yaml + +# Step3: Create a new Google Cloud project +# gcloud projects create YOUR_PROJECT_NAME +# gcloud config set project YOUR_PROJECT_NAME + +# Step4: Deploy a function in the project +# cp oauth_main.py main.py +# gcloud functions deploy hello_bolt_app --runtime python38 --trigger-http --allow-unauthenticated --env-vars-file .env.yaml +# gcloud functions describe hello_bolt_app + +# Step5: Set Request URL +# Set https://us-central1-YOUR_PROJECT_NAME.cloudfunctions.net/hello_bolt_app to the following: +# * slash command: /hello-bolt-python-gcp +# * Events Subscriptions & add `app_mention` event diff --git a/examples/google_cloud_functions/requirements.txt b/examples/google_cloud_functions/requirements.txt index d682b1606..7ebbe8ab2 100644 --- a/examples/google_cloud_functions/requirements.txt +++ b/examples/google_cloud_functions/requirements.txt @@ -1,2 +1,3 @@ Flask>1 -slack_bolt \ No newline at end of file +slack_bolt +google-cloud-datastore>=2.1.0,<3 \ No newline at end of file diff --git a/examples/google_cloud_functions/main.py b/examples/google_cloud_functions/simple_main.py similarity index 64% rename from examples/google_cloud_functions/main.py rename to examples/google_cloud_functions/simple_main.py index c9f911a90..bc8f593a3 100644 --- a/examples/google_cloud_functions/main.py +++ b/examples/google_cloud_functions/simple_main.py @@ -22,27 +22,45 @@ def event_test(body, say, logger): # Flask adapter -from slack_bolt.adapter.flask import SlackRequestHandler +# TODO: Once this once a new version newer than 1.13.2, delete handler and enable the slack_bolt.adapter import instead +# from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler +from handler import SlackRequestHandler +from flask import Request + handler = SlackRequestHandler(app) # Cloud Function -def hello_bolt_app(request): +def hello_bolt_app(req: Request): """HTTP Cloud Function. Args: - request (flask.Request): The request object. + req (flask.Request): The request object. Returns: The response text, or any set of values that can be turned into a Response object using `make_response` . """ - return handler.handle(request) + return handler.handle(req) + + +# For local development +# python main.py +if __name__ == "__main__": + from flask import Flask, request + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["GET", "POST"]) + def handle_anything(): + return handler.handle(request) + + flask_app.run(port=3000) # Step1: Create a new Slack App: https://api.slack.com/apps -# Bot Token Scopes: chat:write, commands, app_mentions:read +# Bot Token Scopes: app_mentions:read,chat:write,commands # Step2: Set env variables # cp .env.yaml.sample .env.yaml @@ -53,7 +71,8 @@ def hello_bolt_app(request): # gcloud config set project YOUR_PROJECT_NAME # Step4: Deploy a function in the project -# gcloud functions deploy hello_bolt_app --runtime python38 --trigger-http --allow-unauthenticated --env-vars-file .env.yaml +# cp simple_main.py main.py +# gcloud functions deploy hello_bolt_app --runtime python39 --trigger-http --allow-unauthenticated --env-vars-file .env.yaml # gcloud functions describe hello_bolt_app # Step5: Set Request URL diff --git a/slack_bolt/adapter/google_cloud_functions/__init__.py b/slack_bolt/adapter/google_cloud_functions/__init__.py new file mode 100644 index 000000000..83f4882db --- /dev/null +++ b/slack_bolt/adapter/google_cloud_functions/__init__.py @@ -0,0 +1,5 @@ +from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/google_cloud_functions/handler.py b/slack_bolt/adapter/google_cloud_functions/handler.py new file mode 100644 index 000000000..46a9c0523 --- /dev/null +++ b/slack_bolt/adapter/google_cloud_functions/handler.py @@ -0,0 +1,43 @@ +from typing import Callable + +from flask import Request, Response, make_response + +from slack_bolt.adapter.flask.handler import to_bolt_request, to_flask_response +from slack_bolt.app import App +from slack_bolt.error import BoltError +from slack_bolt.lazy_listener import LazyListenerRunner +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +class NoopLazyListenerRunner(LazyListenerRunner): + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + raise BoltError( + "The google_cloud_functions adapter does not support lazy listeners. " + "Please consider either having a queue to pass the request to a different function or " + "rewriting your code not to use lazy listeners." + ) + + +class SlackRequestHandler: + def __init__(self, app: App): # type: ignore + self.app = app + # Note that lazy listener is not supported + self.app.listener_runner.lazy_listener_runner = NoopLazyListenerRunner() + if self.app.oauth_flow is not None: + self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?" + + def handle(self, req: Request) -> Response: + if req.method == "GET" and self.app.oauth_flow is not None: + bolt_req = to_bolt_request(req) + if "code" in req.args or "error" in req.args or "state" in req.args: + bolt_resp = self.app.oauth_flow.handle_callback(bolt_req) + return to_flask_response(bolt_resp) + else: + bolt_resp = self.app.oauth_flow.handle_installation(bolt_req) + return to_flask_response(bolt_resp) + elif req.method == "POST": + bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req)) + return to_flask_response(bolt_resp) + + return make_response("Not Found", 404) diff --git a/tests/adapter_tests/google_cloud_functions/__init__.py b/tests/adapter_tests/google_cloud_functions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/google_cloud_functions/test_google_cloud_functions.py b/tests/adapter_tests/google_cloud_functions/test_google_cloud_functions.py new file mode 100644 index 000000000..18420bdfe --- /dev/null +++ b/tests/adapter_tests/google_cloud_functions/test_google_cloud_functions.py @@ -0,0 +1,242 @@ +import json +from time import time +from urllib.parse import quote + +from flask import Flask, request +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestGoogleCloudFunctions: + 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_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 test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "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"], + } + timestamp, body = str(int(time())), json.dumps(input) + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/function", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.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) + + 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))}" + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/function", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + 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) + + 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 + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/function", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.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"], + ), + ) + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["GET"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.get("/function") + assert rv.headers.get("content-type") == "text/html; charset=utf-8" + assert rv.status_code == 200 + + def test_url_verification(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + input = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/function", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) From 647701e1773d8d848280588bb544a5b1a9e75931 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 18 May 2022 09:22:26 +0900 Subject: [PATCH 483/865] version 1.14.0 --- .../slack_bolt/adapter/aiohttp/index.html | 8 +- .../slack_bolt/adapter/aws_lambda/index.html | 171 +- .../slack_bolt/adapter/bottle/index.html | 87 +- .../slack_bolt/adapter/cherrypy/index.html | 91 +- .../slack_bolt/adapter/django/index.html | 136 +- .../slack_bolt/adapter/falcon/index.html | 146 +- .../adapter/fastapi/async_handler.html | 103 +- .../slack_bolt/adapter/fastapi/index.html | 103 +- .../slack_bolt/adapter/flask/index.html | 79 +- .../google_cloud_functions/handler.html | 220 + .../adapter/google_cloud_functions/index.html | 150 + docs/api-docs/slack_bolt/adapter/index.html | 5 + .../slack_bolt/adapter/pyramid/index.html | 81 +- .../slack_bolt/adapter/sanic/index.html | 95 +- .../adapter/socket_mode/async_handler.html | 93 +- .../slack_bolt/adapter/socket_mode/index.html | 153 +- .../slack_bolt/adapter/starlette/index.html | 103 +- .../slack_bolt/adapter/tornado/index.html | 168 +- docs/api-docs/slack_bolt/app/app.html | 2 +- docs/api-docs/slack_bolt/app/async_app.html | 2 +- docs/api-docs/slack_bolt/app/index.html | 2953 ++++++++++- docs/api-docs/slack_bolt/async_app.html | 4196 ++++++++++++++- .../slack_bolt/authorization/index.html | 202 +- .../slack_bolt/context/ack/index.html | 73 +- docs/api-docs/slack_bolt/context/index.html | 389 +- .../slack_bolt/context/respond/index.html | 104 +- .../slack_bolt/context/say/index.html | 91 +- docs/api-docs/slack_bolt/index.html | 4486 ++++++++++++++++- .../slack_bolt/kwargs_injection/index.html | 371 +- .../slack_bolt/lazy_listener/index.html | 195 +- .../slack_bolt/lazy_listener/runner.html | 1 + docs/api-docs/slack_bolt/listener/index.html | 351 +- .../slack_bolt/listener_matcher/index.html | 165 +- docs/api-docs/slack_bolt/logger/index.html | 8 +- .../slack_bolt/middleware/async_builtins.html | 343 +- .../middleware/authorization/index.html | 262 +- .../ignoring_self_events/index.html | 98 +- .../api-docs/slack_bolt/middleware/index.html | 862 +++- .../message_listener_matches/index.html | 70 +- .../request_verification/index.html | 108 +- .../middleware/ssl_check/index.html | 123 +- .../middleware/url_verification/index.html | 87 +- docs/api-docs/slack_bolt/oauth/index.html | 838 ++- docs/api-docs/slack_bolt/request/index.html | 185 +- docs/api-docs/slack_bolt/response/index.html | 157 +- docs/api-docs/slack_bolt/version.html | 2 +- .../workflows/step/async_step_middleware.html | 2 +- .../slack_bolt/workflows/step/index.html | 701 ++- slack_bolt/version.py | 2 +- 49 files changed, 19294 insertions(+), 127 deletions(-) create mode 100644 docs/api-docs/slack_bolt/adapter/google_cloud_functions/handler.html create mode 100644 docs/api-docs/slack_bolt/adapter/google_cloud_functions/index.html diff --git a/docs/api-docs/slack_bolt/adapter/aiohttp/index.html b/docs/api-docs/slack_bolt/adapter/aiohttp/index.html index a565a067d..693657840 100644 --- a/docs/api-docs/slack_bolt/adapter/aiohttp/index.html +++ b/docs/api-docs/slack_bolt/adapter/aiohttp/index.html @@ -66,7 +66,13 @@

    Module slack_bolt.adapter.aiohttp

    secure=True, httponly=True, ) - return resp + return resp + + +__all__ = [ + "to_bolt_request", + "to_aiohttp_response", +]
    diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html index 5cc172ec4..3d293b0db 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/index.html @@ -26,7 +26,11 @@

    Module slack_bolt.adapter.aws_lambda

    Expand source code -
    from .handler import SlackRequestHandler  # noqa: F401
    +
    from .handler import SlackRequestHandler
    +
    +__all__ = [
    +    "SlackRequestHandler",
    +]
    @@ -67,6 +71,160 @@

    Sub-modules

    +

    Classes

    +
    +
    +class SlackRequestHandler +(app: App) +
    +
    +
    +
    + +Expand source code + +
    class SlackRequestHandler:
    +    def __init__(self, app: App):  # type: ignore
    +        self.app = app
    +        self.logger = get_bolt_app_logger(app.name, SlackRequestHandler, app.logger)
    +        self.app.listener_runner.lazy_listener_runner = LambdaLazyListenerRunner(
    +            self.logger
    +        )
    +        if self.app.oauth_flow is not None:
    +            self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?"
    +
    +    @classmethod
    +    def clear_all_log_handlers(cls):
    +        # https://stackoverflow.com/questions/37703609/using-python-logging-with-aws-lambda
    +        root = logging.getLogger()
    +        if root.handlers:
    +            for handler in root.handlers:
    +                root.removeHandler(handler)
    +
    +    def handle(self, event, context):
    +        self.logger.debug(f"Incoming event: {event}, context: {context}")
    +
    +        method = event.get("requestContext", {}).get("http", {}).get("method")
    +        if method is None:
    +            method = event.get("requestContext", {}).get("httpMethod")
    +
    +        if method is None:
    +            return not_found()
    +        if method == "GET":
    +            if self.app.oauth_flow is not None:
    +                oauth_flow: OAuthFlow = self.app.oauth_flow
    +                bolt_req: BoltRequest = to_bolt_request(event)
    +                query = bolt_req.query
    +                is_callback = query is not None and (
    +                    (
    +                        _first_value(query, "code") is not None
    +                        and _first_value(query, "state") is not None
    +                    )
    +                    or _first_value(query, "error") is not None
    +                )
    +                if is_callback:
    +                    bolt_resp = oauth_flow.handle_callback(bolt_req)
    +                    return to_aws_response(bolt_resp)
    +                else:
    +                    bolt_resp = oauth_flow.handle_installation(bolt_req)
    +                    return to_aws_response(bolt_resp)
    +        elif method == "POST":
    +            bolt_req = to_bolt_request(event)
    +            # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html
    +            aws_lambda_function_name = context.function_name
    +            bolt_req.context["aws_lambda_function_name"] = aws_lambda_function_name
    +            bolt_req.context["lambda_request"] = event
    +            bolt_resp = self.app.dispatch(bolt_req)
    +            aws_response = to_aws_response(bolt_resp)
    +            return aws_response
    +        elif method == "NONE":
    +            bolt_req = to_bolt_request(event)
    +            bolt_resp = self.app.dispatch(bolt_req)
    +            aws_response = to_aws_response(bolt_resp)
    +            return aws_response
    +
    +        return not_found()
    +
    +

    Static methods

    +
    +
    +def clear_all_log_handlers() +
    +
    +
    +
    + +Expand source code + +
    @classmethod
    +def clear_all_log_handlers(cls):
    +    # https://stackoverflow.com/questions/37703609/using-python-logging-with-aws-lambda
    +    root = logging.getLogger()
    +    if root.handlers:
    +        for handler in root.handlers:
    +            root.removeHandler(handler)
    +
    +
    +
    +

    Methods

    +
    +
    +def handle(self, event, context) +
    +
    +
    +
    + +Expand source code + +
    def handle(self, event, context):
    +    self.logger.debug(f"Incoming event: {event}, context: {context}")
    +
    +    method = event.get("requestContext", {}).get("http", {}).get("method")
    +    if method is None:
    +        method = event.get("requestContext", {}).get("httpMethod")
    +
    +    if method is None:
    +        return not_found()
    +    if method == "GET":
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: OAuthFlow = self.app.oauth_flow
    +            bolt_req: BoltRequest = to_bolt_request(event)
    +            query = bolt_req.query
    +            is_callback = query is not None and (
    +                (
    +                    _first_value(query, "code") is not None
    +                    and _first_value(query, "state") is not None
    +                )
    +                or _first_value(query, "error") is not None
    +            )
    +            if is_callback:
    +                bolt_resp = oauth_flow.handle_callback(bolt_req)
    +                return to_aws_response(bolt_resp)
    +            else:
    +                bolt_resp = oauth_flow.handle_installation(bolt_req)
    +                return to_aws_response(bolt_resp)
    +    elif method == "POST":
    +        bolt_req = to_bolt_request(event)
    +        # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html
    +        aws_lambda_function_name = context.function_name
    +        bolt_req.context["aws_lambda_function_name"] = aws_lambda_function_name
    +        bolt_req.context["lambda_request"] = event
    +        bolt_resp = self.app.dispatch(bolt_req)
    +        aws_response = to_aws_response(bolt_resp)
    +        return aws_response
    +    elif method == "NONE":
    +        bolt_req = to_bolt_request(event)
    +        bolt_resp = self.app.dispatch(bolt_req)
    +        aws_response = to_aws_response(bolt_resp)
    +        return aws_response
    +
    +    return not_found()
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/adapter/bottle/index.html b/docs/api-docs/slack_bolt/adapter/bottle/index.html index 8701e3cb7..c6d7707fe 100644 --- a/docs/api-docs/slack_bolt/adapter/bottle/index.html +++ b/docs/api-docs/slack_bolt/adapter/bottle/index.html @@ -26,7 +26,11 @@

    Module slack_bolt.adapter.bottle

    Expand source code -
    from .handler import SlackRequestHandler  # noqa: F401
    +
    from .handler import SlackRequestHandler
    +
    +__all__ = [
    +    "SlackRequestHandler",
    +]
    @@ -43,6 +47,77 @@

    Sub-modules

    +

    Classes

    +
    +
    +class SlackRequestHandler +(app: App) +
    +
    +
    +
    + +Expand source code + +
    class SlackRequestHandler:
    +    def __init__(self, app: App):  # type: ignore
    +        self.app = app
    +
    +    def handle(self, req: Request, resp: Response) -> str:
    +        if req.method == "GET":
    +            if self.app.oauth_flow is not None:
    +                oauth_flow: OAuthFlow = self.app.oauth_flow
    +                if req.path == oauth_flow.install_path:
    +                    bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
    +                    set_response(bolt_resp, resp)
    +                    return bolt_resp.body or ""
    +                elif req.path == oauth_flow.redirect_uri_path:
    +                    bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
    +                    set_response(bolt_resp, resp)
    +                    return bolt_resp.body or ""
    +        elif req.method == "POST":
    +            bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req))
    +            set_response(bolt_resp, resp)
    +            return bolt_resp.body or ""
    +
    +        resp.status = 404
    +        return "Not Found"
    +
    +

    Methods

    +
    +
    +def handle(self, req: bottle.BaseRequest, resp: bottle.BaseResponse) ‑> str +
    +
    +
    +
    + +Expand source code + +
    def handle(self, req: Request, resp: Response) -> str:
    +    if req.method == "GET":
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: OAuthFlow = self.app.oauth_flow
    +            if req.path == oauth_flow.install_path:
    +                bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
    +                set_response(bolt_resp, resp)
    +                return bolt_resp.body or ""
    +            elif req.path == oauth_flow.redirect_uri_path:
    +                bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
    +                set_response(bolt_resp, resp)
    +                return bolt_resp.body or ""
    +    elif req.method == "POST":
    +        bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req))
    +        set_response(bolt_resp, resp)
    +        return bolt_resp.body or ""
    +
    +    resp.status = 404
    +    return "Not Found"
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/adapter/cherrypy/index.html b/docs/api-docs/slack_bolt/adapter/cherrypy/index.html index 7b4df5764..396d15eec 100644 --- a/docs/api-docs/slack_bolt/adapter/cherrypy/index.html +++ b/docs/api-docs/slack_bolt/adapter/cherrypy/index.html @@ -26,7 +26,11 @@

    Module slack_bolt.adapter.cherrypy

    Expand source code -
    from .handler import SlackRequestHandler  # noqa: F401
    +
    from .handler import SlackRequestHandler
    +
    +__all__ = [
    +    "SlackRequestHandler",
    +]
    @@ -43,6 +47,81 @@

    Sub-modules

    +

    Classes

    +
    +
    +class SlackRequestHandler +(app: App) +
    +
    +
    +
    + +Expand source code + +
    class SlackRequestHandler:
    +    def __init__(self, app: App):  # type: ignore
    +        self.app = app
    +
    +    def handle(self) -> bytes:
    +        req = cherrypy.request
    +        if req.method == "GET":
    +            if self.app.oauth_flow is not None:
    +                oauth_flow: OAuthFlow = self.app.oauth_flow
    +                request_path = req.wsgi_environ["REQUEST_URI"].split("?")[0]
    +                if request_path == oauth_flow.install_path:
    +                    bolt_resp = oauth_flow.handle_installation(build_bolt_request())
    +                    set_response_status_and_headers(bolt_resp)
    +                    return (bolt_resp.body or "").encode("utf-8")
    +                elif request_path == oauth_flow.redirect_uri_path:
    +                    bolt_resp = oauth_flow.handle_callback(build_bolt_request())
    +                    set_response_status_and_headers(bolt_resp)
    +                    return (bolt_resp.body or "").encode("utf-8")
    +        elif req.method == "POST":
    +            bolt_resp: BoltResponse = self.app.dispatch(build_bolt_request())
    +            set_response_status_and_headers(bolt_resp)
    +            return (bolt_resp.body or "").encode("utf-8")
    +
    +        cherrypy.response.status = 404
    +        return "Not Found".encode("utf-8")
    +
    +

    Methods

    +
    +
    +def handle(self) ‑> bytes +
    +
    +
    +
    + +Expand source code + +
    def handle(self) -> bytes:
    +    req = cherrypy.request
    +    if req.method == "GET":
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: OAuthFlow = self.app.oauth_flow
    +            request_path = req.wsgi_environ["REQUEST_URI"].split("?")[0]
    +            if request_path == oauth_flow.install_path:
    +                bolt_resp = oauth_flow.handle_installation(build_bolt_request())
    +                set_response_status_and_headers(bolt_resp)
    +                return (bolt_resp.body or "").encode("utf-8")
    +            elif request_path == oauth_flow.redirect_uri_path:
    +                bolt_resp = oauth_flow.handle_callback(build_bolt_request())
    +                set_response_status_and_headers(bolt_resp)
    +                return (bolt_resp.body or "").encode("utf-8")
    +    elif req.method == "POST":
    +        bolt_resp: BoltResponse = self.app.dispatch(build_bolt_request())
    +        set_response_status_and_headers(bolt_resp)
    +        return (bolt_resp.body or "").encode("utf-8")
    +
    +    cherrypy.response.status = 404
    +    return "Not Found".encode("utf-8")
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/adapter/django/index.html b/docs/api-docs/slack_bolt/adapter/django/index.html index 80b06b944..6154ab619 100644 --- a/docs/api-docs/slack_bolt/adapter/django/index.html +++ b/docs/api-docs/slack_bolt/adapter/django/index.html @@ -26,7 +26,11 @@

    Module slack_bolt.adapter.django

    Expand source code -
    from .handler import SlackRequestHandler  # noqa: F401
    +
    from .handler import SlackRequestHandler
    +
    +__all__ = [
    +    "SlackRequestHandler",
    +]
    @@ -43,6 +47,126 @@

    Sub-modules

    +

    Classes

    +
    +
    +class SlackRequestHandler +(app: App) +
    +
    +
    +
    + +Expand source code + +
    class SlackRequestHandler:
    +    def __init__(self, app: App):  # type: ignore
    +        self.app = app
    +        listener_runner = self.app.listener_runner
    +        # This runner closes all thread-local connections in the thread when an execution completes
    +        self.app.listener_runner.lazy_listener_runner = DjangoThreadLazyListenerRunner(
    +            logger=listener_runner.logger,
    +            executor=listener_runner.listener_executor,
    +        )
    +
    +        if not isinstance(listener_runner, ThreadListenerRunner):
    +            raise BoltError(
    +                "Custom listener_runners are not compatible with this Django adapter."
    +            )
    +
    +        if app.process_before_response is True:
    +            # As long as the app access Django models in the same thread,
    +            # Django cleans the connections up for you.
    +            self.app.logger.debug("App.process_before_response is set to True")
    +            return
    +
    +        current_start_handler = listener_runner.listener_start_handler
    +        if current_start_handler is not None and not isinstance(
    +            current_start_handler, DefaultListenerStartHandler
    +        ):
    +            # As we run release_thread_local_connections() before listener executions,
    +            # it's okay to skip calling the same connection clean-up method at the listener completion.
    +            message = """As you've already set app.listener_runner.listener_start_handler to your own one,
    +            Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerStartHandler.
    +
    +            If you go with your own handler here, we highly recommend having the following lines of code
    +            in your handle() method to clean up unmanaged stale/old database connections:
    +
    +            from django.db import close_old_connections
    +            close_old_connections()
    +            """
    +            self.app.logger.info(message)
    +        else:
    +            # for proper management of thread-local Django DB connections
    +            self.app.listener_runner.listener_start_handler = (
    +                DjangoListenerStartHandler()
    +            )
    +            self.app.logger.debug("DjangoListenerStartHandler has been enabled")
    +
    +        current_completion_handler = listener_runner.listener_completion_handler
    +        if current_completion_handler is not None and not isinstance(
    +            current_completion_handler, DefaultListenerCompletionHandler
    +        ):
    +            # As we run release_thread_local_connections() before listener executions,
    +            # it's okay to skip calling the same connection clean-up method at the listener completion.
    +            message = """As you've already set app.listener_runner.listener_completion_handler to your own one,
    +            Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerCompletionHandler.
    +            """
    +            self.app.logger.info(message)
    +            return
    +        # for proper management of thread-local Django DB connections
    +        self.app.listener_runner.listener_completion_handler = (
    +            DjangoListenerCompletionHandler()
    +        )
    +        self.app.logger.debug("DjangoListenerCompletionHandler has been enabled")
    +
    +    def handle(self, req: HttpRequest) -> HttpResponse:
    +        if req.method == "GET":
    +            if self.app.oauth_flow is not None:
    +                oauth_flow: OAuthFlow = self.app.oauth_flow
    +                if req.path == oauth_flow.install_path:
    +                    bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
    +                    return to_django_response(bolt_resp)
    +                elif req.path == oauth_flow.redirect_uri_path:
    +                    bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
    +                    return to_django_response(bolt_resp)
    +        elif req.method == "POST":
    +            bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req))
    +            return to_django_response(bolt_resp)
    +
    +        return HttpResponse(status=404, content=b"Not Found")
    +
    +

    Methods

    +
    +
    +def handle(self, req: django.http.request.HttpRequest) ‑> django.http.response.HttpResponse +
    +
    +
    +
    + +Expand source code + +
    def handle(self, req: HttpRequest) -> HttpResponse:
    +    if req.method == "GET":
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: OAuthFlow = self.app.oauth_flow
    +            if req.path == oauth_flow.install_path:
    +                bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
    +                return to_django_response(bolt_resp)
    +            elif req.path == oauth_flow.redirect_uri_path:
    +                bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
    +                return to_django_response(bolt_resp)
    +    elif req.method == "POST":
    +        bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req))
    +        return to_django_response(bolt_resp)
    +
    +    return HttpResponse(status=404, content=b"Not Found")
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/adapter/falcon/index.html b/docs/api-docs/slack_bolt/adapter/falcon/index.html index a61724235..792d51a81 100644 --- a/docs/api-docs/slack_bolt/adapter/falcon/index.html +++ b/docs/api-docs/slack_bolt/adapter/falcon/index.html @@ -27,7 +27,11 @@

    Module slack_bolt.adapter.falcon

    Expand source code
    # Don't add async module imports here
    -from .resource import SlackAppResource  # noqa: F401
    +from .resource import SlackAppResource + +__all__ = [ + "SlackAppResource", +]
    @@ -48,6 +52,135 @@

    Sub-modules

    +

    Classes

    +
    +
    +class SlackAppResource +(app: App) +
    +
    +

    from slack_bolt import App +app = App()

    +

    import falcon +api = application = falcon.API() +api.add_route("/slack/events", SlackAppResource(app))

    +
    + +Expand source code + +
    class SlackAppResource:
    +    """
    +    from slack_bolt import App
    +    app = App()
    +
    +    import falcon
    +    api = application = falcon.API()
    +    api.add_route("/slack/events", SlackAppResource(app))
    +    """
    +
    +    def __init__(self, app: App):  # type: ignore
    +        self.app = app
    +
    +    def on_get(self, req: Request, resp: Response):
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: OAuthFlow = self.app.oauth_flow
    +            if req.path == oauth_flow.install_path:
    +                bolt_resp = oauth_flow.handle_installation(self._to_bolt_request(req))
    +                self._write_response(bolt_resp, resp)
    +                return
    +            elif req.path == oauth_flow.redirect_uri_path:
    +                bolt_resp = oauth_flow.handle_callback(self._to_bolt_request(req))
    +                self._write_response(bolt_resp, resp)
    +                return
    +
    +        resp.status = "404"
    +        resp.body = "The page is not found..."
    +
    +    def on_post(self, req: Request, resp: Response):
    +        bolt_req = self._to_bolt_request(req)
    +        bolt_resp = self.app.dispatch(bolt_req)
    +        self._write_response(bolt_resp, resp)
    +
    +    def _to_bolt_request(self, req: Request) -> BoltRequest:
    +        return BoltRequest(
    +            body=req.stream.read(req.content_length or 0).decode("utf-8"),
    +            query=req.query_string,
    +            headers={k.lower(): v for k, v in req.headers.items()},
    +        )
    +
    +    def _write_response(self, bolt_resp: BoltResponse, resp: Response):
    +        if falcon_version.__version__.startswith("2."):
    +            resp.body = bolt_resp.body
    +        else:
    +            resp.text = bolt_resp.body
    +
    +        status = HTTPStatus(bolt_resp.status)
    +        resp.status = str(f"{status.value} {status.phrase}")
    +        resp.set_headers(bolt_resp.first_headers_without_set_cookie())
    +        for cookie in bolt_resp.cookies():
    +            for name, c in cookie.items():
    +                expire_value = c.get("expires")
    +                expire = (
    +                    datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z")
    +                    if expire_value
    +                    else None
    +                )
    +                resp.set_cookie(
    +                    name=name,
    +                    value=c.value,
    +                    expires=expire,
    +                    max_age=c.get("max-age"),
    +                    domain=c.get("domain"),
    +                    path=c.get("path"),
    +                    secure=True,
    +                    http_only=True,
    +                )
    +
    +

    Methods

    +
    +
    +def on_get(self, req: falcon.request.Request, resp: falcon.response.Response) +
    +
    +
    +
    + +Expand source code + +
    def on_get(self, req: Request, resp: Response):
    +    if self.app.oauth_flow is not None:
    +        oauth_flow: OAuthFlow = self.app.oauth_flow
    +        if req.path == oauth_flow.install_path:
    +            bolt_resp = oauth_flow.handle_installation(self._to_bolt_request(req))
    +            self._write_response(bolt_resp, resp)
    +            return
    +        elif req.path == oauth_flow.redirect_uri_path:
    +            bolt_resp = oauth_flow.handle_callback(self._to_bolt_request(req))
    +            self._write_response(bolt_resp, resp)
    +            return
    +
    +    resp.status = "404"
    +    resp.body = "The page is not found..."
    +
    +
    +
    +def on_post(self, req: falcon.request.Request, resp: falcon.response.Response) +
    +
    +
    +
    + +Expand source code + +
    def on_post(self, req: Request, resp: Response):
    +    bolt_req = self._to_bolt_request(req)
    +    bolt_resp = self.app.dispatch(bolt_req)
    +    self._write_response(bolt_resp, resp)
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/adapter/fastapi/async_handler.html b/docs/api-docs/slack_bolt/adapter/fastapi/async_handler.html index d43ab4b33..cb4c2fdf7 100644 --- a/docs/api-docs/slack_bolt/adapter/fastapi/async_handler.html +++ b/docs/api-docs/slack_bolt/adapter/fastapi/async_handler.html @@ -26,7 +26,11 @@

    Module slack_bolt.adapter.fastapi.async_handler Expand source code -
    from ..starlette.async_handler import AsyncSlackRequestHandler  # noqa
    +
    from ..starlette.async_handler import AsyncSlackRequestHandler
    +
    +__all__ = [
    +    "AsyncSlackRequestHandler",
    +]

    @@ -36,6 +40,93 @@

    Module slack_bolt.adapter.fastapi.async_handler

    +

    Classes

    +
    +
    +class AsyncSlackRequestHandler +(app: AsyncApp) +
    +
    +
    +
    + +Expand source code + +
    class AsyncSlackRequestHandler:
    +    def __init__(self, app: AsyncApp):  # type: ignore
    +        self.app = app
    +
    +    async def handle(
    +        self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None
    +    ) -> Response:
    +        body = await req.body()
    +        if req.method == "GET":
    +            if self.app.oauth_flow is not None:
    +                oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
    +                if req.url.path == oauth_flow.install_path:
    +                    bolt_resp = await oauth_flow.handle_installation(
    +                        to_async_bolt_request(req, body, addition_context_properties)
    +                    )
    +                    return to_starlette_response(bolt_resp)
    +                elif req.url.path == oauth_flow.redirect_uri_path:
    +                    bolt_resp = await oauth_flow.handle_callback(
    +                        to_async_bolt_request(req, body, addition_context_properties)
    +                    )
    +                    return to_starlette_response(bolt_resp)
    +        elif req.method == "POST":
    +            bolt_resp = await self.app.async_dispatch(
    +                to_async_bolt_request(req, body, addition_context_properties)
    +            )
    +            return to_starlette_response(bolt_resp)
    +
    +        return Response(
    +            status_code=404,
    +            content="Not found",
    +        )
    +
    +

    Methods

    +
    +
    +async def handle(self, req: starlette.requests.Request, addition_context_properties: Optional[Dict[str, Any]] = None) ‑> starlette.responses.Response +
    +
    +
    +
    + +Expand source code + +
    async def handle(
    +    self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None
    +) -> Response:
    +    body = await req.body()
    +    if req.method == "GET":
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
    +            if req.url.path == oauth_flow.install_path:
    +                bolt_resp = await oauth_flow.handle_installation(
    +                    to_async_bolt_request(req, body, addition_context_properties)
    +                )
    +                return to_starlette_response(bolt_resp)
    +            elif req.url.path == oauth_flow.redirect_uri_path:
    +                bolt_resp = await oauth_flow.handle_callback(
    +                    to_async_bolt_request(req, body, addition_context_properties)
    +                )
    +                return to_starlette_response(bolt_resp)
    +    elif req.method == "POST":
    +        bolt_resp = await self.app.async_dispatch(
    +            to_async_bolt_request(req, body, addition_context_properties)
    +        )
    +        return to_starlette_response(bolt_resp)
    +
    +    return Response(
    +        status_code=404,
    +        content="Not found",
    +    )
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/adapter/fastapi/index.html b/docs/api-docs/slack_bolt/adapter/fastapi/index.html index ba5c9e031..ffc0d66b9 100644 --- a/docs/api-docs/slack_bolt/adapter/fastapi/index.html +++ b/docs/api-docs/slack_bolt/adapter/fastapi/index.html @@ -27,7 +27,11 @@

    Module slack_bolt.adapter.fastapi

    Expand source code
    # Don't add async module imports here
    -from ..starlette.handler import SlackRequestHandler  # noqa
    +from ..starlette.handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +]
    @@ -44,6 +48,93 @@

    Sub-modules

    +

    Classes

    +
    +
    +class SlackRequestHandler +(app: App) +
    +
    +
    +
    + +Expand source code + +
    class SlackRequestHandler:
    +    def __init__(self, app: App):  # type: ignore
    +        self.app = app
    +
    +    async def handle(
    +        self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None
    +    ) -> Response:
    +        body = await req.body()
    +        if req.method == "GET":
    +            if self.app.oauth_flow is not None:
    +                oauth_flow: OAuthFlow = self.app.oauth_flow
    +                if req.url.path == oauth_flow.install_path:
    +                    bolt_resp = oauth_flow.handle_installation(
    +                        to_bolt_request(req, body, addition_context_properties)
    +                    )
    +                    return to_starlette_response(bolt_resp)
    +                elif req.url.path == oauth_flow.redirect_uri_path:
    +                    bolt_resp = oauth_flow.handle_callback(
    +                        to_bolt_request(req, body, addition_context_properties)
    +                    )
    +                    return to_starlette_response(bolt_resp)
    +        elif req.method == "POST":
    +            bolt_resp = self.app.dispatch(
    +                to_bolt_request(req, body, addition_context_properties)
    +            )
    +            return to_starlette_response(bolt_resp)
    +
    +        return Response(
    +            status_code=404,
    +            content="Not found",
    +        )
    +
    +

    Methods

    +
    +
    +async def handle(self, req: starlette.requests.Request, addition_context_properties: Optional[Dict[str, Any]] = None) ‑> starlette.responses.Response +
    +
    +
    +
    + +Expand source code + +
    async def handle(
    +    self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None
    +) -> Response:
    +    body = await req.body()
    +    if req.method == "GET":
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: OAuthFlow = self.app.oauth_flow
    +            if req.url.path == oauth_flow.install_path:
    +                bolt_resp = oauth_flow.handle_installation(
    +                    to_bolt_request(req, body, addition_context_properties)
    +                )
    +                return to_starlette_response(bolt_resp)
    +            elif req.url.path == oauth_flow.redirect_uri_path:
    +                bolt_resp = oauth_flow.handle_callback(
    +                    to_bolt_request(req, body, addition_context_properties)
    +                )
    +                return to_starlette_response(bolt_resp)
    +    elif req.method == "POST":
    +        bolt_resp = self.app.dispatch(
    +            to_bolt_request(req, body, addition_context_properties)
    +        )
    +        return to_starlette_response(bolt_resp)
    +
    +    return Response(
    +        status_code=404,
    +        content="Not found",
    +    )
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/adapter/flask/index.html b/docs/api-docs/slack_bolt/adapter/flask/index.html index 80baea429..02c5dd6b8 100644 --- a/docs/api-docs/slack_bolt/adapter/flask/index.html +++ b/docs/api-docs/slack_bolt/adapter/flask/index.html @@ -26,7 +26,11 @@

    Module slack_bolt.adapter.flask

    Expand source code -
    from .handler import SlackRequestHandler  # noqa: F401
    +
    from .handler import SlackRequestHandler
    +
    +__all__ = [
    +    "SlackRequestHandler",
    +]
    @@ -43,6 +47,69 @@

    Sub-modules

    +

    Classes

    +
    +
    +class SlackRequestHandler +(app: App) +
    +
    +
    +
    + +Expand source code + +
    class SlackRequestHandler:
    +    def __init__(self, app: App):  # type: ignore
    +        self.app = app
    +
    +    def handle(self, req: Request) -> Response:
    +        if req.method == "GET":
    +            if self.app.oauth_flow is not None:
    +                oauth_flow: OAuthFlow = self.app.oauth_flow
    +                if req.path == oauth_flow.install_path:
    +                    bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
    +                    return to_flask_response(bolt_resp)
    +                elif req.path == oauth_flow.redirect_uri_path:
    +                    bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
    +                    return to_flask_response(bolt_resp)
    +        elif req.method == "POST":
    +            bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req))
    +            return to_flask_response(bolt_resp)
    +
    +        return make_response("Not Found", 404)
    +
    +

    Methods

    +
    +
    +def handle(self, req: flask.wrappers.Request) ‑> flask.wrappers.Response +
    +
    +
    +
    + +Expand source code + +
    def handle(self, req: Request) -> Response:
    +    if req.method == "GET":
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: OAuthFlow = self.app.oauth_flow
    +            if req.path == oauth_flow.install_path:
    +                bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
    +                return to_flask_response(bolt_resp)
    +            elif req.path == oauth_flow.redirect_uri_path:
    +                bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
    +                return to_flask_response(bolt_resp)
    +    elif req.method == "POST":
    +        bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req))
    +        return to_flask_response(bolt_resp)
    +
    +    return make_response("Not Found", 404)
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/adapter/google_cloud_functions/handler.html b/docs/api-docs/slack_bolt/adapter/google_cloud_functions/handler.html new file mode 100644 index 000000000..652bc59e3 --- /dev/null +++ b/docs/api-docs/slack_bolt/adapter/google_cloud_functions/handler.html @@ -0,0 +1,220 @@ + + + + + + +slack_bolt.adapter.google_cloud_functions.handler API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.adapter.google_cloud_functions.handler

    +
    +
    +
    + +Expand source code + +
    from typing import Callable
    +
    +from flask import Request, Response, make_response
    +
    +from slack_bolt.adapter.flask.handler import to_bolt_request, to_flask_response
    +from slack_bolt.app import App
    +from slack_bolt.error import BoltError
    +from slack_bolt.lazy_listener import LazyListenerRunner
    +from slack_bolt.request import BoltRequest
    +from slack_bolt.response import BoltResponse
    +
    +
    +class NoopLazyListenerRunner(LazyListenerRunner):
    +    def start(self, function: Callable[..., None], request: BoltRequest) -> None:
    +        raise BoltError(
    +            "The google_cloud_functions adapter does not support lazy listeners. "
    +            "Please consider either having a queue to pass the request to a different function or "
    +            "rewriting your code not to use lazy listeners."
    +        )
    +
    +
    +class SlackRequestHandler:
    +    def __init__(self, app: App):  # type: ignore
    +        self.app = app
    +        # Note that lazy listener is not supported
    +        self.app.listener_runner.lazy_listener_runner = NoopLazyListenerRunner()
    +        if self.app.oauth_flow is not None:
    +            self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?"
    +
    +    def handle(self, req: Request) -> Response:
    +        if req.method == "GET" and self.app.oauth_flow is not None:
    +            bolt_req = to_bolt_request(req)
    +            if "code" in req.args or "error" in req.args or "state" in req.args:
    +                bolt_resp = self.app.oauth_flow.handle_callback(bolt_req)
    +                return to_flask_response(bolt_resp)
    +            else:
    +                bolt_resp = self.app.oauth_flow.handle_installation(bolt_req)
    +                return to_flask_response(bolt_resp)
    +        elif req.method == "POST":
    +            bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req))
    +            return to_flask_response(bolt_resp)
    +
    +        return make_response("Not Found", 404)
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class NoopLazyListenerRunner +
    +
    +
    +
    + +Expand source code + +
    class NoopLazyListenerRunner(LazyListenerRunner):
    +    def start(self, function: Callable[..., None], request: BoltRequest) -> None:
    +        raise BoltError(
    +            "The google_cloud_functions adapter does not support lazy listeners. "
    +            "Please consider either having a queue to pass the request to a different function or "
    +            "rewriting your code not to use lazy listeners."
    +        )
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var logger : logging.Logger
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class SlackRequestHandler +(app: App) +
    +
    +
    +
    + +Expand source code + +
    class SlackRequestHandler:
    +    def __init__(self, app: App):  # type: ignore
    +        self.app = app
    +        # Note that lazy listener is not supported
    +        self.app.listener_runner.lazy_listener_runner = NoopLazyListenerRunner()
    +        if self.app.oauth_flow is not None:
    +            self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?"
    +
    +    def handle(self, req: Request) -> Response:
    +        if req.method == "GET" and self.app.oauth_flow is not None:
    +            bolt_req = to_bolt_request(req)
    +            if "code" in req.args or "error" in req.args or "state" in req.args:
    +                bolt_resp = self.app.oauth_flow.handle_callback(bolt_req)
    +                return to_flask_response(bolt_resp)
    +            else:
    +                bolt_resp = self.app.oauth_flow.handle_installation(bolt_req)
    +                return to_flask_response(bolt_resp)
    +        elif req.method == "POST":
    +            bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req))
    +            return to_flask_response(bolt_resp)
    +
    +        return make_response("Not Found", 404)
    +
    +

    Methods

    +
    +
    +def handle(self, req: flask.wrappers.Request) ‑> flask.wrappers.Response +
    +
    +
    +
    + +Expand source code + +
    def handle(self, req: Request) -> Response:
    +    if req.method == "GET" and self.app.oauth_flow is not None:
    +        bolt_req = to_bolt_request(req)
    +        if "code" in req.args or "error" in req.args or "state" in req.args:
    +            bolt_resp = self.app.oauth_flow.handle_callback(bolt_req)
    +            return to_flask_response(bolt_resp)
    +        else:
    +            bolt_resp = self.app.oauth_flow.handle_installation(bolt_req)
    +            return to_flask_response(bolt_resp)
    +    elif req.method == "POST":
    +        bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req))
    +        return to_flask_response(bolt_resp)
    +
    +    return make_response("Not Found", 404)
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/google_cloud_functions/index.html b/docs/api-docs/slack_bolt/adapter/google_cloud_functions/index.html new file mode 100644 index 000000000..46726a39a --- /dev/null +++ b/docs/api-docs/slack_bolt/adapter/google_cloud_functions/index.html @@ -0,0 +1,150 @@ + + + + + + +slack_bolt.adapter.google_cloud_functions API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.adapter.google_cloud_functions

    +
    +
    +
    + +Expand source code + +
    from .handler import SlackRequestHandler
    +
    +__all__ = [
    +    "SlackRequestHandler",
    +]
    +
    +
    +
    +

    Sub-modules

    +
    +
    slack_bolt.adapter.google_cloud_functions.handler
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class SlackRequestHandler +(app: App) +
    +
    +
    +
    + +Expand source code + +
    class SlackRequestHandler:
    +    def __init__(self, app: App):  # type: ignore
    +        self.app = app
    +        # Note that lazy listener is not supported
    +        self.app.listener_runner.lazy_listener_runner = NoopLazyListenerRunner()
    +        if self.app.oauth_flow is not None:
    +            self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?"
    +
    +    def handle(self, req: Request) -> Response:
    +        if req.method == "GET" and self.app.oauth_flow is not None:
    +            bolt_req = to_bolt_request(req)
    +            if "code" in req.args or "error" in req.args or "state" in req.args:
    +                bolt_resp = self.app.oauth_flow.handle_callback(bolt_req)
    +                return to_flask_response(bolt_resp)
    +            else:
    +                bolt_resp = self.app.oauth_flow.handle_installation(bolt_req)
    +                return to_flask_response(bolt_resp)
    +        elif req.method == "POST":
    +            bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req))
    +            return to_flask_response(bolt_resp)
    +
    +        return make_response("Not Found", 404)
    +
    +

    Methods

    +
    +
    +def handle(self, req: flask.wrappers.Request) ‑> flask.wrappers.Response +
    +
    +
    +
    + +Expand source code + +
    def handle(self, req: Request) -> Response:
    +    if req.method == "GET" and self.app.oauth_flow is not None:
    +        bolt_req = to_bolt_request(req)
    +        if "code" in req.args or "error" in req.args or "state" in req.args:
    +            bolt_resp = self.app.oauth_flow.handle_callback(bolt_req)
    +            return to_flask_response(bolt_resp)
    +        else:
    +            bolt_resp = self.app.oauth_flow.handle_installation(bolt_req)
    +            return to_flask_response(bolt_resp)
    +    elif req.method == "POST":
    +        bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req))
    +        return to_flask_response(bolt_resp)
    +
    +    return make_response("Not Found", 404)
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/index.html b/docs/api-docs/slack_bolt/adapter/index.html index c05016afd..9cfe9bc51 100644 --- a/docs/api-docs/slack_bolt/adapter/index.html +++ b/docs/api-docs/slack_bolt/adapter/index.html @@ -66,6 +66,10 @@

    Sub-modules

    +
    slack_bolt.adapter.google_cloud_functions
    +
    +
    +
    slack_bolt.adapter.pyramid
    @@ -116,6 +120,7 @@

    Index

  • slack_bolt.adapter.falcon
  • slack_bolt.adapter.fastapi
  • slack_bolt.adapter.flask
  • +
  • slack_bolt.adapter.google_cloud_functions
  • slack_bolt.adapter.pyramid
  • slack_bolt.adapter.sanic
  • slack_bolt.adapter.socket_mode
  • diff --git a/docs/api-docs/slack_bolt/adapter/pyramid/index.html b/docs/api-docs/slack_bolt/adapter/pyramid/index.html index c9cd6120c..368a48f9c 100644 --- a/docs/api-docs/slack_bolt/adapter/pyramid/index.html +++ b/docs/api-docs/slack_bolt/adapter/pyramid/index.html @@ -26,7 +26,11 @@

    Module slack_bolt.adapter.pyramid

    Expand source code -
    from .handler import SlackRequestHandler  # noqa: F401
    +
    from .handler import SlackRequestHandler
    +
    +__all__ = [
    +    "SlackRequestHandler",
    +]
    @@ -43,6 +47,71 @@

    Sub-modules

    +

    Classes

    +
    +
    +class SlackRequestHandler +(app: App) +
    +
    +
    +
    + +Expand source code + +
    class SlackRequestHandler:
    +    def __init__(self, app: App):  # type: ignore
    +        self.app = app
    +
    +    def handle(self, request: Request) -> Response:
    +        if request.method == "GET":
    +            if self.app.oauth_flow is not None:
    +                oauth_flow: OAuthFlow = self.app.oauth_flow
    +                if request.path == oauth_flow.install_path:
    +                    bolt_resp = oauth_flow.handle_installation(to_bolt_request(request))
    +                    return to_pyramid_response(bolt_resp)
    +                elif request.path == oauth_flow.redirect_uri_path:
    +                    bolt_resp = oauth_flow.handle_callback(to_bolt_request(request))
    +                    return to_pyramid_response(bolt_resp)
    +        elif request.method == "POST":
    +            bolt_req = to_bolt_request(request)
    +            bolt_resp = self.app.dispatch(bolt_req)
    +            return to_pyramid_response(bolt_resp)
    +
    +        return Response(status=404, body="Not found")
    +
    +

    Methods

    +
    +
    +def handle(self, request: pyramid.request.Request) ‑> pyramid.response.Response +
    +
    +
    +
    + +Expand source code + +
    def handle(self, request: Request) -> Response:
    +    if request.method == "GET":
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: OAuthFlow = self.app.oauth_flow
    +            if request.path == oauth_flow.install_path:
    +                bolt_resp = oauth_flow.handle_installation(to_bolt_request(request))
    +                return to_pyramid_response(bolt_resp)
    +            elif request.path == oauth_flow.redirect_uri_path:
    +                bolt_resp = oauth_flow.handle_callback(to_bolt_request(request))
    +                return to_pyramid_response(bolt_resp)
    +    elif request.method == "POST":
    +        bolt_req = to_bolt_request(request)
    +        bolt_resp = self.app.dispatch(bolt_req)
    +        return to_pyramid_response(bolt_resp)
    +
    +    return Response(status=404, body="Not found")
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/adapter/sanic/index.html b/docs/api-docs/slack_bolt/adapter/sanic/index.html index c1b61a875..985a605b8 100644 --- a/docs/api-docs/slack_bolt/adapter/sanic/index.html +++ b/docs/api-docs/slack_bolt/adapter/sanic/index.html @@ -26,7 +26,11 @@

    Module slack_bolt.adapter.sanic

    Expand source code -
    from .async_handler import AsyncSlackRequestHandler  # noqa: F401
    +
    from .async_handler import AsyncSlackRequestHandler
    +
    +__all__ = [
    +    "AsyncSlackRequestHandler",
    +]
    @@ -43,6 +47,85 @@

    Sub-modules

    +

    Classes

    +
    +
    +class AsyncSlackRequestHandler +(app: AsyncApp) +
    +
    +
    +
    + +Expand source code + +
    class AsyncSlackRequestHandler:
    +    def __init__(self, app: AsyncApp):  # type: ignore
    +        self.app = app
    +
    +    async def handle(self, req: Request) -> HTTPResponse:
    +        if req.method == "GET":
    +            if self.app.oauth_flow is not None:
    +                oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
    +                if req.path == oauth_flow.install_path:
    +                    bolt_resp = await oauth_flow.handle_installation(
    +                        to_async_bolt_request(req)
    +                    )
    +                    return to_sanic_response(bolt_resp)
    +                elif req.path == oauth_flow.redirect_uri_path:
    +                    bolt_resp = await oauth_flow.handle_callback(
    +                        to_async_bolt_request(req)
    +                    )
    +                    return to_sanic_response(bolt_resp)
    +
    +        elif req.method == "POST":
    +            bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req))
    +            return to_sanic_response(bolt_resp)
    +
    +        return HTTPResponse(
    +            status=404,
    +            body="Not found",
    +        )
    +
    +

    Methods

    +
    +
    +async def handle(self, req: sanic.request.Request) ‑> sanic.response.HTTPResponse +
    +
    +
    +
    + +Expand source code + +
    async def handle(self, req: Request) -> HTTPResponse:
    +    if req.method == "GET":
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
    +            if req.path == oauth_flow.install_path:
    +                bolt_resp = await oauth_flow.handle_installation(
    +                    to_async_bolt_request(req)
    +                )
    +                return to_sanic_response(bolt_resp)
    +            elif req.path == oauth_flow.redirect_uri_path:
    +                bolt_resp = await oauth_flow.handle_callback(
    +                    to_async_bolt_request(req)
    +                )
    +                return to_sanic_response(bolt_resp)
    +
    +    elif req.method == "POST":
    +        bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req))
    +        return to_sanic_response(bolt_resp)
    +
    +    return HTTPResponse(
    +        status=404,
    +        body="Not found",
    +    )
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/async_handler.html b/docs/api-docs/slack_bolt/adapter/socket_mode/async_handler.html index 239bd0fbe..a67e8cc42 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/async_handler.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/async_handler.html @@ -28,7 +28,11 @@

    Module slack_bolt.adapter.socket_mode.async_handlerExpand source code
    """Default implementation is the aiohttp-based one."""
    -from .aiohttp import AsyncSocketModeHandler  # noqa
    +from .aiohttp import AsyncSocketModeHandler + +__all__ = [ + "AsyncSocketModeHandler", +]

    @@ -38,6 +42,81 @@

    Module slack_bolt.adapter.socket_mode.async_handler

    +

    Classes

    +
    +
    +class AsyncSocketModeHandler +(app: AsyncApp, app_token: Optional[str] = None, logger: Optional[logging.Logger] = None, web_client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, proxy: Optional[str] = None, ping_interval: float = 10) +
    +
    +
    +
    + +Expand source code + +
    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)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var appAsyncApp
    +
    +
    +
    +
    var app_token : str
    +
    +
    +
    +
    var client : slack_sdk.socket_mode.aiohttp.SocketModeClient
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    diff --git a/docs/api-docs/slack_bolt/adapter/socket_mode/index.html b/docs/api-docs/slack_bolt/adapter/socket_mode/index.html index 3666de4b4..ba71be2ef 100644 --- a/docs/api-docs/slack_bolt/adapter/socket_mode/index.html +++ b/docs/api-docs/slack_bolt/adapter/socket_mode/index.html @@ -42,7 +42,11 @@

    Module slack_bolt.adapter.socket_mode

    """ # noqa: E501 # Don't add async module imports here -from .builtin import SocketModeHandler # noqa: F401
    +from .builtin import SocketModeHandler + +__all__ = [ + "SocketModeHandler", +]
    @@ -93,6 +97,141 @@

    Sub-modules

    +

    Classes

    +
    +
    +class SocketModeHandler +(app: App, app_token: Optional[str] = None, logger: Optional[logging.Logger] = None, web_client: Optional[slack_sdk.web.client.WebClient] = None, proxy: Optional[str] = None, proxy_headers: Optional[Dict[str, 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) +
    +
    +

    Socket Mode adapter for Bolt apps

    +

    Args

    +
    +
    app
    +
    The Bolt app
    +
    app_token
    +
    App-level token starting with xapp-
    +
    logger
    +
    Custom logger
    +
    web_client
    +
    custom slack_sdk.web.WebClient instance
    +
    proxy
    +
    HTTP proxy URL
    +
    proxy_headers
    +
    Additional request header for proxy connections
    +
    auto_reconnect_enabled
    +
    True if the auto-reconnect logic works
    +
    trace_enabled
    +
    True if trace-level logging is enabled
    +
    all_message_trace_enabled
    +
    True if trace-logging for all received WebSocket messages is enabled
    +
    ping_pong_trace_enabled
    +
    True if trace-logging for all ping-pong communications
    +
    ping_interval
    +
    The ping-pong internal (seconds)
    +
    receive_buffer_size
    +
    The data length for a single socket recv operation
    +
    concurrency
    +
    The size of the underlying thread pool
    +
    +
    + +Expand source code + +
    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,
    +        proxy_headers: Optional[Dict[str, 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,
    +    ):
    +        """Socket Mode adapter for Bolt apps
    +
    +        Args:
    +            app: The Bolt app
    +            app_token: App-level token starting with `xapp-`
    +            logger: Custom logger
    +            web_client: custom `slack_sdk.web.WebClient` instance
    +            proxy: HTTP proxy URL
    +            proxy_headers: Additional request header for proxy connections
    +            auto_reconnect_enabled: True if the auto-reconnect logic works
    +            trace_enabled: True if trace-level logging is enabled
    +            all_message_trace_enabled: True if trace-logging for all received WebSocket messages is enabled
    +            ping_pong_trace_enabled: True if trace-logging for all ping-pong communications
    +            ping_interval: The ping-pong internal (seconds)
    +            receive_buffer_size: The data length for a single socket recv operation
    +            concurrency: The size of the underlying thread pool
    +        """
    +        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,
    +            proxy_headers=proxy_headers,
    +            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)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var appApp
    +
    +
    +
    +
    var app_token : str
    +
    +
    +
    +
    var client : slack_sdk.socket_mode.builtin.client.SocketModeClient
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    diff --git a/docs/api-docs/slack_bolt/adapter/starlette/index.html b/docs/api-docs/slack_bolt/adapter/starlette/index.html index b3c1dbecd..35673fa37 100644 --- a/docs/api-docs/slack_bolt/adapter/starlette/index.html +++ b/docs/api-docs/slack_bolt/adapter/starlette/index.html @@ -27,7 +27,11 @@

    Module slack_bolt.adapter.starlette

    Expand source code
    # Don't add async module imports here
    -from .handler import SlackRequestHandler  # noqa: F401
    +from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +]
    @@ -48,6 +52,93 @@

    Sub-modules

    +

    Classes

    +
    +
    +class SlackRequestHandler +(app: App) +
    +
    +
    +
    + +Expand source code + +
    class SlackRequestHandler:
    +    def __init__(self, app: App):  # type: ignore
    +        self.app = app
    +
    +    async def handle(
    +        self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None
    +    ) -> Response:
    +        body = await req.body()
    +        if req.method == "GET":
    +            if self.app.oauth_flow is not None:
    +                oauth_flow: OAuthFlow = self.app.oauth_flow
    +                if req.url.path == oauth_flow.install_path:
    +                    bolt_resp = oauth_flow.handle_installation(
    +                        to_bolt_request(req, body, addition_context_properties)
    +                    )
    +                    return to_starlette_response(bolt_resp)
    +                elif req.url.path == oauth_flow.redirect_uri_path:
    +                    bolt_resp = oauth_flow.handle_callback(
    +                        to_bolt_request(req, body, addition_context_properties)
    +                    )
    +                    return to_starlette_response(bolt_resp)
    +        elif req.method == "POST":
    +            bolt_resp = self.app.dispatch(
    +                to_bolt_request(req, body, addition_context_properties)
    +            )
    +            return to_starlette_response(bolt_resp)
    +
    +        return Response(
    +            status_code=404,
    +            content="Not found",
    +        )
    +
    +

    Methods

    +
    +
    +async def handle(self, req: starlette.requests.Request, addition_context_properties: Optional[Dict[str, Any]] = None) ‑> starlette.responses.Response +
    +
    +
    +
    + +Expand source code + +
    async def handle(
    +    self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None
    +) -> Response:
    +    body = await req.body()
    +    if req.method == "GET":
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: OAuthFlow = self.app.oauth_flow
    +            if req.url.path == oauth_flow.install_path:
    +                bolt_resp = oauth_flow.handle_installation(
    +                    to_bolt_request(req, body, addition_context_properties)
    +                )
    +                return to_starlette_response(bolt_resp)
    +            elif req.url.path == oauth_flow.redirect_uri_path:
    +                bolt_resp = oauth_flow.handle_callback(
    +                    to_bolt_request(req, body, addition_context_properties)
    +                )
    +                return to_starlette_response(bolt_resp)
    +    elif req.method == "POST":
    +        bolt_resp = self.app.dispatch(
    +            to_bolt_request(req, body, addition_context_properties)
    +        )
    +        return to_starlette_response(bolt_resp)
    +
    +    return Response(
    +        status_code=404,
    +        content="Not found",
    +    )
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/adapter/tornado/index.html b/docs/api-docs/slack_bolt/adapter/tornado/index.html index 037b2751f..95ed67cc6 100644 --- a/docs/api-docs/slack_bolt/adapter/tornado/index.html +++ b/docs/api-docs/slack_bolt/adapter/tornado/index.html @@ -26,7 +26,12 @@

    Module slack_bolt.adapter.tornado

    Expand source code -
    from .handler import SlackEventsHandler, SlackOAuthHandler  # noqa: F401
    +
    from .handler import SlackEventsHandler, SlackOAuthHandler
    +
    +__all__ = [
    +    "SlackEventsHandler",
    +    "SlackOAuthHandler",
    +]
    @@ -43,6 +48,149 @@

    Sub-modules

    +

    Classes

    +
    +
    +class SlackEventsHandler +(application: Application, request: tornado.httputil.HTTPServerRequest, **kwargs: Any) +
    +
    +

    Base class for HTTP request handlers.

    +

    Subclasses must define at least one of the methods defined in the +"Entry points" section below.

    +

    Applications should not construct RequestHandler objects +directly and subclasses should not override __init__ (override +~RequestHandler.initialize instead).

    +
    + +Expand source code + +
    class SlackEventsHandler(RequestHandler):
    +    def initialize(self, app: App):  # type: ignore
    +        self.app = app
    +
    +    def post(self):
    +        bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(self.request))
    +        set_response(self, bolt_resp)
    +        return
    +
    +

    Ancestors

    +
      +
    • tornado.web.RequestHandler
    • +
    +

    Methods

    +
    +
    +def initialize(self, app: App) +
    +
    +
    +
    + +Expand source code + +
    def initialize(self, app: App):  # type: ignore
    +    self.app = app
    +
    +
    +
    +def post(self) +
    +
    +
    +
    + +Expand source code + +
    def post(self):
    +    bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(self.request))
    +    set_response(self, bolt_resp)
    +    return
    +
    +
    +
    +
    +
    +class SlackOAuthHandler +(application: Application, request: tornado.httputil.HTTPServerRequest, **kwargs: Any) +
    +
    +

    Base class for HTTP request handlers.

    +

    Subclasses must define at least one of the methods defined in the +"Entry points" section below.

    +

    Applications should not construct RequestHandler objects +directly and subclasses should not override __init__ (override +~RequestHandler.initialize instead).

    +
    + +Expand source code + +
    class SlackOAuthHandler(RequestHandler):
    +    def initialize(self, app: App):  # type: ignore
    +        self.app = app
    +
    +    def get(self):
    +        if self.app.oauth_flow is not None:  # type: ignore
    +            oauth_flow: OAuthFlow = self.app.oauth_flow  # type: ignore
    +            if self.request.path == oauth_flow.install_path:
    +                bolt_resp = oauth_flow.handle_installation(
    +                    to_bolt_request(self.request)
    +                )
    +                set_response(self, bolt_resp)
    +                return
    +            elif self.request.path == oauth_flow.redirect_uri_path:
    +                bolt_resp = oauth_flow.handle_callback(to_bolt_request(self.request))
    +                set_response(self, bolt_resp)
    +                return
    +        self.set_status(404)
    +
    +

    Ancestors

    +
      +
    • tornado.web.RequestHandler
    • +
    +

    Methods

    +
    +
    +def get(self) +
    +
    +
    +
    + +Expand source code + +
    def get(self):
    +    if self.app.oauth_flow is not None:  # type: ignore
    +        oauth_flow: OAuthFlow = self.app.oauth_flow  # type: ignore
    +        if self.request.path == oauth_flow.install_path:
    +            bolt_resp = oauth_flow.handle_installation(
    +                to_bolt_request(self.request)
    +            )
    +            set_response(self, bolt_resp)
    +            return
    +        elif self.request.path == oauth_flow.redirect_uri_path:
    +            bolt_resp = oauth_flow.handle_callback(to_bolt_request(self.request))
    +            set_response(self, bolt_resp)
    +            return
    +    self.set_status(404)
    +
    +
    +
    +def initialize(self, app: App) +
    +
    +
    +
    + +Expand source code + +
    def initialize(self, app: App):  # type: ignore
    +    self.app = app
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index 4df4d89ec..d6fc78148 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -1648,7 +1648,7 @@

    Args

    oauth_settings
    The settings related to Slack app installation flow (OAuth flow)
    oauth_flow
    -
    Instantiated slack_bolt.oauth.OAuthFlow. This is always prioritized over oauth_settings.
    +
    Instantiated OAuthFlow. This is always prioritized over oauth_settings.
    verification_token
    Deprecated verification mechanism. This can used only for ssl_check requests.
    listener_executor
    diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index 894ee048c..ff6aa28e8 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -42,7 +42,7 @@

    Module slack_bolt.app.async_app

    from slack_bolt.listener.async_listener_completion_handler import ( AsyncDefaultListenerCompletionHandler, ) -from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner +from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner # type: ignore from slack_bolt.middleware.async_middleware_error_handler import ( AsyncCustomMiddlewareErrorHandler, AsyncDefaultMiddlewareErrorHandler, diff --git a/docs/api-docs/slack_bolt/app/index.html b/docs/api-docs/slack_bolt/app/index.html index 2ad72f98a..b0924b7e7 100644 --- a/docs/api-docs/slack_bolt/app/index.html +++ b/docs/api-docs/slack_bolt/app/index.html @@ -39,7 +39,11 @@

    Module slack_bolt.app

    """ # Don't add async module imports here -from .app import App # type: ignore +from .app import App # type: ignore + +__all__ = [ + "App", +]
    @@ -49,21 +53,2910 @@

    Sub-modules

    -
    slack_bolt.app.async_app
    +
    slack_bolt.app.async_app
    +
    +
    +
    +
    slack_bolt.app.async_server
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class App +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None) +
    +
    +

    Bolt App that provides functionalities to register middleware/listeners.

    +
    import os
    +from slack_bolt import App
    +
    +# Initializes your app with your bot token and signing secret
    +app = App(
    +    token=os.environ.get("SLACK_BOT_TOKEN"),
    +    signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
    +)
    +
    +# Listens to incoming messages that contain "hello"
    +@app.message("hello")
    +def message_hello(message, say):
    +    # say() sends a message to the channel where the event was triggered
    +    say(f"Hey there <@{message['user']}>!")
    +
    +# Start your app
    +if __name__ == "__main__":
    +    app.start(port=int(os.environ.get("PORT", 3000)))
    +
    +

    Refer to https://slack.dev/bolt-python/tutorial/getting-started for details.

    +

    If you would like to build an OAuth app for enabling the app to run with multiple workspaces, +refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app.

    +

    Args

    +
    +
    logger
    +
    The custom logger that can be used in this app.
    +
    name
    +
    The application name that will be used in logging. If absent, the source file name will be used.
    +
    process_before_response
    +
    True if this app runs on Function as a Service. (Default: False)
    +
    raise_error_for_unhandled_request
    +
    True if you want to raise exceptions for unhandled requests +and use @app.error listeners instead of +the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
    +
    signing_secret
    +
    The Signing Secret value used for verifying requests from Slack.
    +
    token
    +
    The bot/user access token required only for single-workspace app.
    +
    token_verification_enabled
    +
    Verifies the validity of the given token if True.
    +
    client
    +
    The singleton slack_sdk.WebClient instance for this app.
    +
    authorize
    +
    The function to authorize an incoming request from Slack +by checking if there is a team/user in the installation data.
    +
    installation_store
    +
    The module offering save/find operations of installation data
    +
    installation_store_bot_only
    +
    Use InstallationStore#find_bot() if True (Default: False)
    +
    request_verification_enabled
    +
    False if you would like to disable the built-in middleware (Default: True). +RequestVerification is a built-in middleware that verifies the signature in HTTP Mode requests. +Make sure if it's safe enough when you turn a built-in middleware off. +We strongly recommend using RequestVerification for better security. +If you have a proxy that verifies request signature in front of the Bolt app, +it's totally fine to disable RequestVerification to avoid duplication of work. +Don't turn it off just for easiness of development.
    +
    ignoring_self_events_enabled
    +
    False if you would like to disable the built-in middleware (Default: True). +IgnoringSelfEvents is a built-in middleware that enables Bolt apps to easily skip the events +generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
    +
    url_verification_enabled
    +
    False if you would like to disable the built-in middleware (Default: True). +UrlVerification is a built-in middleware that handles url_verification requests +that verify the endpoint for Events API in HTTP Mode requests.
    +
    ssl_check_enabled
    +
    bool = False if you would like to disable the built-in middleware (Default: True). +SslCheck is a built-in middleware that handles ssl_check requests from Slack.
    +
    oauth_settings
    +
    The settings related to Slack app installation flow (OAuth flow)
    +
    oauth_flow
    +
    Instantiated OAuthFlow. This is always prioritized over oauth_settings.
    +
    verification_token
    +
    Deprecated verification mechanism. This can used only for ssl_check requests.
    +
    listener_executor
    +
    Custom executor to run background tasks. If absent, the default ThreadPoolExecutor will +be used.
    +
    +
    + +Expand source code + +
    class App:
    +    def __init__(
    +        self,
    +        *,
    +        logger: Optional[logging.Logger] = None,
    +        # Used in logger
    +        name: Optional[str] = None,
    +        # Set True when you run this app on a FaaS platform
    +        process_before_response: bool = False,
    +        # Set True if you want to handle an unhandled request as an exception
    +        raise_error_for_unhandled_request: bool = False,
    +        # Basic Information > Credentials > Signing Secret
    +        signing_secret: Optional[str] = None,
    +        # for single-workspace apps
    +        token: Optional[str] = None,
    +        token_verification_enabled: bool = True,
    +        client: Optional[WebClient] = None,
    +        # for multi-workspace apps
    +        authorize: Optional[Callable[..., AuthorizeResult]] = None,
    +        installation_store: Optional[InstallationStore] = None,
    +        # for either only bot scope usage or v1.0.x compatibility
    +        installation_store_bot_only: Optional[bool] = None,
    +        # for customizing the built-in middleware
    +        request_verification_enabled: bool = True,
    +        ignoring_self_events_enabled: bool = True,
    +        ssl_check_enabled: bool = True,
    +        url_verification_enabled: bool = True,
    +        # for the OAuth flow
    +        oauth_settings: Optional[OAuthSettings] = None,
    +        oauth_flow: Optional[OAuthFlow] = None,
    +        # No need to set (the value is used only in response to ssl_check requests)
    +        verification_token: Optional[str] = None,
    +        # Set this one only when you want to customize the executor
    +        listener_executor: Optional[Executor] = None,
    +    ):
    +        """Bolt App that provides functionalities to register middleware/listeners.
    +
    +            import os
    +            from slack_bolt import App
    +
    +            # Initializes your app with your bot token and signing secret
    +            app = App(
    +                token=os.environ.get("SLACK_BOT_TOKEN"),
    +                signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
    +            )
    +
    +            # Listens to incoming messages that contain "hello"
    +            @app.message("hello")
    +            def message_hello(message, say):
    +                # say() sends a message to the channel where the event was triggered
    +                say(f"Hey there <@{message['user']}>!")
    +
    +            # Start your app
    +            if __name__ == "__main__":
    +                app.start(port=int(os.environ.get("PORT", 3000)))
    +
    +        Refer to https://slack.dev/bolt-python/tutorial/getting-started for details.
    +
    +        If you would like to build an OAuth app for enabling the app to run with multiple workspaces,
    +        refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app.
    +
    +        Args:
    +            logger: The custom logger that can be used in this app.
    +            name: The application name that will be used in logging. If absent, the source file name will be used.
    +            process_before_response: True if this app runs on Function as a Service. (Default: False)
    +            raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests
    +                and use @app.error listeners instead of
    +                the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
    +            signing_secret: The Signing Secret value used for verifying requests from Slack.
    +            token: The bot/user access token required only for single-workspace app.
    +            token_verification_enabled: Verifies the validity of the given token if True.
    +            client: The singleton `slack_sdk.WebClient` instance for this app.
    +            authorize: The function to authorize an incoming request from Slack
    +                by checking if there is a team/user in the installation data.
    +            installation_store: The module offering save/find operations of installation data
    +            installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False)
    +            request_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
    +                `RequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests.
    +                Make sure if it's safe enough when you turn a built-in middleware off.
    +                We strongly recommend using RequestVerification for better security.
    +                If you have a proxy that verifies request signature in front of the Bolt app,
    +                it's totally fine to disable RequestVerification to avoid duplication of work.
    +                Don't turn it off just for easiness of development.
    +            ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True).
    +                `IgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events
    +                generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
    +            url_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
    +                `UrlVerification` is a built-in middleware that handles url_verification requests
    +                that verify the endpoint for Events API in HTTP Mode requests.
    +            ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True).
    +                `SslCheck` is a built-in middleware that handles ssl_check requests from Slack.
    +            oauth_settings: The settings related to Slack app installation flow (OAuth flow)
    +            oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings.
    +            verification_token: Deprecated verification mechanism. This can used only for ssl_check requests.
    +            listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will
    +                be used.
    +        """
    +        signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", "")
    +        token = token or os.environ.get("SLACK_BOT_TOKEN")
    +
    +        self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1]
    +        self._signing_secret: str = signing_secret
    +
    +        self._verification_token: Optional[str] = verification_token or os.environ.get(
    +            "SLACK_VERIFICATION_TOKEN", None
    +        )
    +        # If a logger is explicitly passed when initializing, the logger works as the base logger.
    +        # The base logger's logging settings will be propagated to all the loggers created by bolt-python.
    +        self._base_logger = logger
    +        # The framework logger is supposed to be used for the internal logging.
    +        # Also, it's accessible via `app.logger` as the app's singleton logger.
    +        self._framework_logger = logger or get_bolt_logger(App)
    +        self._raise_error_for_unhandled_request = raise_error_for_unhandled_request
    +
    +        self._token: Optional[str] = token
    +
    +        if client is not None:
    +            if not isinstance(client, WebClient):
    +                raise BoltError(error_client_invalid_type())
    +            self._client = client
    +            self._token = client.token
    +            if token is not None:
    +                self._framework_logger.warning(
    +                    warning_client_prioritized_and_token_skipped()
    +                )
    +        else:
    +            self._client = create_web_client(
    +                # NOTE: the token here can be None
    +                token=token,
    +                logger=self._framework_logger,
    +            )
    +
    +        # --------------------------------------
    +        # Authorize & OAuthFlow initialization
    +        # --------------------------------------
    +
    +        self._authorize: Optional[Authorize] = None
    +        if authorize is not None:
    +            if oauth_settings is not None or oauth_flow is not None:
    +                raise BoltError(error_authorize_conflicts())
    +            self._authorize = CallableAuthorize(
    +                logger=self._framework_logger, func=authorize
    +            )
    +
    +        self._installation_store: Optional[InstallationStore] = installation_store
    +        if self._installation_store is not None and self._authorize is None:
    +            settings = oauth_flow.settings if oauth_flow is not None else oauth_settings
    +            self._authorize = InstallationStoreAuthorize(
    +                installation_store=self._installation_store,
    +                client_id=settings.client_id if settings is not None else None,
    +                client_secret=settings.client_secret if settings is not None else None,
    +                logger=self._framework_logger,
    +                bot_only=installation_store_bot_only,
    +                client=self._client,  # for proxy use cases etc.
    +            )
    +
    +        self._oauth_flow: Optional[OAuthFlow] = None
    +
    +        if (
    +            oauth_settings is None
    +            and os.environ.get("SLACK_CLIENT_ID") is not None
    +            and os.environ.get("SLACK_CLIENT_SECRET") is not None
    +        ):
    +            # initialize with the default settings
    +            oauth_settings = OAuthSettings()
    +
    +            if oauth_flow is None and installation_store is None:
    +                # show info-level log for avoiding confusions
    +                self._framework_logger.info(info_default_oauth_settings_loaded())
    +
    +        if oauth_flow is not None:
    +            self._oauth_flow = oauth_flow
    +            installation_store = select_consistent_installation_store(
    +                client_id=self._oauth_flow.client_id,
    +                app_store=self._installation_store,
    +                oauth_flow_store=self._oauth_flow.settings.installation_store,
    +                logger=self._framework_logger,
    +            )
    +            self._installation_store = installation_store
    +            self._oauth_flow.settings.installation_store = installation_store
    +
    +            if self._oauth_flow._client is None:
    +                self._oauth_flow._client = self._client
    +            if self._authorize is None:
    +                self._authorize = self._oauth_flow.settings.authorize
    +        elif oauth_settings is not None:
    +            installation_store = select_consistent_installation_store(
    +                client_id=oauth_settings.client_id,
    +                app_store=self._installation_store,
    +                oauth_flow_store=oauth_settings.installation_store,
    +                logger=self._framework_logger,
    +            )
    +            self._installation_store = installation_store
    +            oauth_settings.installation_store = installation_store
    +            self._oauth_flow = OAuthFlow(
    +                client=self.client, logger=self.logger, settings=oauth_settings
    +            )
    +            if self._authorize is None:
    +                self._authorize = self._oauth_flow.settings.authorize
    +            self._authorize.token_rotation_expiration_minutes = (
    +                oauth_settings.token_rotation_expiration_minutes
    +            )
    +
    +        if (
    +            self._installation_store is not None or self._authorize is not None
    +        ) and self._token is not None:
    +            self._token = None
    +            self._framework_logger.warning(warning_token_skipped())
    +
    +        # after setting bot_only here, __init__ cannot replace authorize function
    +        if installation_store_bot_only is not None and self._oauth_flow is not None:
    +            app_bot_only = installation_store_bot_only or False
    +            oauth_flow_bot_only = self._oauth_flow.settings.installation_store_bot_only
    +            if app_bot_only != oauth_flow_bot_only:
    +                self.logger.warning(warning_bot_only_conflicts())
    +                self._oauth_flow.settings.installation_store_bot_only = app_bot_only
    +                self._authorize.bot_only = app_bot_only
    +
    +        self._tokens_revocation_listeners: Optional[TokenRevocationListeners] = None
    +        if self._installation_store is not None:
    +            self._tokens_revocation_listeners = TokenRevocationListeners(
    +                self._installation_store
    +            )
    +
    +        # --------------------------------------
    +        # Middleware Initialization
    +        # --------------------------------------
    +
    +        self._middleware_list: List[Middleware] = []
    +        self._listeners: List[Listener] = []
    +
    +        if listener_executor is None:
    +            listener_executor = ThreadPoolExecutor(max_workers=5)
    +
    +        self._process_before_response = process_before_response
    +        self._listener_runner = ThreadListenerRunner(
    +            logger=self._framework_logger,
    +            process_before_response=process_before_response,
    +            listener_error_handler=DefaultListenerErrorHandler(
    +                logger=self._framework_logger
    +            ),
    +            listener_start_handler=DefaultListenerStartHandler(
    +                logger=self._framework_logger
    +            ),
    +            listener_completion_handler=DefaultListenerCompletionHandler(
    +                logger=self._framework_logger
    +            ),
    +            listener_executor=listener_executor,
    +            lazy_listener_runner=ThreadLazyListenerRunner(
    +                logger=self._framework_logger,
    +                executor=listener_executor,
    +            ),
    +        )
    +        self._middleware_error_handler = DefaultMiddlewareErrorHandler(
    +            logger=self._framework_logger,
    +        )
    +
    +        self._init_middleware_list_done = False
    +        self._init_middleware_list(
    +            token_verification_enabled=token_verification_enabled,
    +            request_verification_enabled=request_verification_enabled,
    +            ignoring_self_events_enabled=ignoring_self_events_enabled,
    +            ssl_check_enabled=ssl_check_enabled,
    +            url_verification_enabled=url_verification_enabled,
    +        )
    +
    +    def _init_middleware_list(
    +        self,
    +        token_verification_enabled: bool = True,
    +        request_verification_enabled: bool = True,
    +        ignoring_self_events_enabled: bool = True,
    +        ssl_check_enabled: bool = True,
    +        url_verification_enabled: bool = True,
    +    ):
    +        if self._init_middleware_list_done:
    +            return
    +        if ssl_check_enabled is True:
    +            self._middleware_list.append(
    +                SslCheck(
    +                    verification_token=self._verification_token,
    +                    base_logger=self._base_logger,
    +                )
    +            )
    +        if request_verification_enabled is True:
    +            self._middleware_list.append(
    +                RequestVerification(self._signing_secret, base_logger=self._base_logger)
    +            )
    +
    +        # As authorize is required for making a Bolt app function, we don't offer the flag to disable this
    +        if self._oauth_flow is None:
    +            if self._token is not None:
    +                try:
    +                    auth_test_result = None
    +                    if token_verification_enabled:
    +                        # This API call is for eagerly validating the token
    +                        auth_test_result = self._client.auth_test(token=self._token)
    +                    self._middleware_list.append(
    +                        SingleTeamAuthorization(
    +                            auth_test_result=auth_test_result,
    +                            base_logger=self._base_logger,
    +                        )
    +                    )
    +                except SlackApiError as err:
    +                    raise BoltError(error_auth_test_failure(err.response))
    +            elif self._authorize is not None:
    +                self._middleware_list.append(
    +                    MultiTeamsAuthorization(
    +                        authorize=self._authorize, base_logger=self._base_logger
    +                    )
    +                )
    +            else:
    +                raise BoltError(error_token_required())
    +        else:
    +            self._middleware_list.append(
    +                MultiTeamsAuthorization(
    +                    authorize=self._authorize, base_logger=self._base_logger
    +                )
    +            )
    +        if ignoring_self_events_enabled is True:
    +            self._middleware_list.append(
    +                IgnoringSelfEvents(base_logger=self._base_logger)
    +            )
    +        if url_verification_enabled is True:
    +            self._middleware_list.append(UrlVerification(base_logger=self._base_logger))
    +        self._init_middleware_list_done = True
    +
    +    # -------------------------
    +    # accessors
    +
    +    @property
    +    def name(self) -> str:
    +        """The name of this app (default: the filename)"""
    +        return self._name
    +
    +    @property
    +    def oauth_flow(self) -> Optional[OAuthFlow]:
    +        """Configured `OAuthFlow` object if exists."""
    +        return self._oauth_flow
    +
    +    @property
    +    def logger(self) -> logging.Logger:
    +        """The logger this app uses."""
    +        return self._framework_logger
    +
    +    @property
    +    def client(self) -> WebClient:
    +        """The singleton `slack_sdk.WebClient` instance in this app."""
    +        return self._client
    +
    +    @property
    +    def installation_store(self) -> Optional[InstallationStore]:
    +        """The `slack_sdk.oauth.InstallationStore` that can be used in the `authorize` middleware."""
    +        return self._installation_store
    +
    +    @property
    +    def listener_runner(self) -> ThreadListenerRunner:
    +        """The thread executor for asynchronously running listeners."""
    +        return self._listener_runner
    +
    +    @property
    +    def process_before_response(self) -> bool:
    +        return self._process_before_response or False
    +
    +    # -------------------------
    +    # standalone server
    +
    +    def start(
    +        self,
    +        port: int = 3000,
    +        path: str = "/slack/events",
    +        http_server_logger_enabled: bool = True,
    +    ) -> None:
    +        """Starts a web server for local development.
    +
    +            # With the default settings, `http://localhost:3000/slack/events`
    +            # is available for handling incoming requests from Slack
    +            app.start()
    +
    +        This method internally starts a Web server process built with the `http.server` module.
    +        For production, consider using a production-ready WSGI server such as Gunicorn.
    +
    +        Args:
    +            port: The port to listen on (Default: 3000)
    +            path: The path to handle request from Slack (Default: `/slack/events`)
    +            http_server_logger_enabled: The flag to enable http.server logging if True (Default: True)
    +        """
    +        self._development_server = SlackAppDevelopmentServer(
    +            port=port,
    +            path=path,
    +            app=self,
    +            oauth_flow=self.oauth_flow,
    +            http_server_logger_enabled=http_server_logger_enabled,
    +        )
    +        self._development_server.start()
    +
    +    # -------------------------
    +    # main dispatcher
    +
    +    def dispatch(self, req: BoltRequest) -> BoltResponse:
    +        """Applies all middleware and dispatches an incoming request from Slack to the right code path.
    +
    +        Args:
    +            req: An incoming request from Slack
    +
    +        Returns:
    +            The response generated by this Bolt app
    +        """
    +        starting_time = time.time()
    +        self._init_context(req)
    +
    +        resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
    +        middleware_state = {"next_called": False}
    +
    +        def middleware_next():
    +            middleware_state["next_called"] = True
    +
    +        try:
    +            for middleware in self._middleware_list:
    +                middleware_state["next_called"] = False
    +                if self._framework_logger.level <= logging.DEBUG:
    +                    self._framework_logger.debug(
    +                        debug_applying_middleware(middleware.name)
    +                    )
    +                resp = middleware.process(req=req, resp=resp, next=middleware_next)
    +                if not middleware_state["next_called"]:
    +                    if resp is None:
    +                        # next() method was not called without providing the response to return to Slack
    +                        # This should not be an intentional handling in usual use cases.
    +                        resp = BoltResponse(
    +                            status=404, body={"error": "no next() calls in middleware"}
    +                        )
    +                        if self._raise_error_for_unhandled_request is True:
    +                            self._listener_runner.listener_error_handler.handle(
    +                                error=BoltUnhandledRequestError(
    +                                    request=req,
    +                                    current_response=resp,
    +                                    last_global_middleware_name=middleware.name,
    +                                ),
    +                                request=req,
    +                                response=resp,
    +                            )
    +                            return resp
    +                        self._framework_logger.warning(
    +                            warning_unhandled_by_global_middleware(middleware.name, req)
    +                        )
    +                        return resp
    +                    return resp
    +
    +            for listener in self._listeners:
    +                listener_name = get_name_for_callable(listener.ack_function)
    +                self._framework_logger.debug(debug_checking_listener(listener_name))
    +                if listener.matches(req=req, resp=resp):
    +                    # run all the middleware attached to this listener first
    +                    middleware_resp, next_was_not_called = listener.run_middleware(
    +                        req=req, resp=resp
    +                    )
    +                    if next_was_not_called:
    +                        if middleware_resp is not None:
    +                            if self._framework_logger.level <= logging.DEBUG:
    +                                debug_message = (
    +                                    debug_return_listener_middleware_response(
    +                                        listener_name,
    +                                        middleware_resp.status,
    +                                        middleware_resp.body,
    +                                        starting_time,
    +                                    )
    +                                )
    +                                self._framework_logger.debug(debug_message)
    +                            return middleware_resp
    +                        # The last listener middleware didn't call next() method.
    +                        # This means the listener is not for this incoming request.
    +                        continue
    +
    +                    if middleware_resp is not None:
    +                        resp = middleware_resp
    +
    +                    self._framework_logger.debug(debug_running_listener(listener_name))
    +                    listener_response: Optional[
    +                        BoltResponse
    +                    ] = self._listener_runner.run(
    +                        request=req,
    +                        response=resp,
    +                        listener_name=listener_name,
    +                        listener=listener,
    +                    )
    +                    if listener_response is not None:
    +                        return listener_response
    +
    +            if resp is None:
    +                resp = BoltResponse(status=404, body={"error": "unhandled request"})
    +            if self._raise_error_for_unhandled_request is True:
    +                self._listener_runner.listener_error_handler.handle(
    +                    error=BoltUnhandledRequestError(
    +                        request=req,
    +                        current_response=resp,
    +                    ),
    +                    request=req,
    +                    response=resp,
    +                )
    +                return resp
    +            return self._handle_unmatched_requests(req, resp)
    +        except Exception as error:
    +            resp = BoltResponse(status=500, body="")
    +            self._middleware_error_handler.handle(
    +                error=error,
    +                request=req,
    +                response=resp,
    +            )
    +            return resp
    +
    +    def _handle_unmatched_requests(
    +        self, req: BoltRequest, resp: BoltResponse
    +    ) -> BoltResponse:
    +        self._framework_logger.warning(warning_unhandled_request(req))
    +        return resp
    +
    +    # -------------------------
    +    # middleware
    +
    +    def use(self, *args) -> Optional[Callable]:
    +        """Registers a new global middleware to this app. This method can be used as either a decorator or a method.
    +
    +        Refer to `App#middleware()` method's docstring for details."""
    +        return self.middleware(*args)
    +
    +    def middleware(self, *args) -> Optional[Callable]:
    +        """Registers a new middleware to this app.
    +        This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.middleware
    +            def middleware_func(logger, body, next):
    +                logger.info(f"request body: {body}")
    +                next()
    +
    +            # Pass a function to this method
    +            app.middleware(middleware_func)
    +
    +        Refer to https://slack.dev/bolt-python/concepts#global-middleware for details.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +        Args:
    +            *args: A function that works as a global middleware.
    +        """
    +        if len(args) > 0:
    +            middleware_or_callable = args[0]
    +            if isinstance(middleware_or_callable, Middleware):
    +                middleware: Middleware = middleware_or_callable
    +                self._middleware_list.append(middleware)
    +            elif isinstance(middleware_or_callable, Callable):
    +                self._middleware_list.append(
    +                    CustomMiddleware(
    +                        app_name=self.name,
    +                        func=middleware_or_callable,
    +                        base_logger=self._base_logger,
    +                    )
    +                )
    +                return middleware_or_callable
    +            else:
    +                raise BoltError(
    +                    f"Unexpected type for a middleware ({type(middleware_or_callable)})"
    +                )
    +        return None
    +
    +    # -------------------------
    +    # Workflows: Steps from Apps
    +
    +    def step(
    +        self,
    +        callback_id: Union[str, Pattern, WorkflowStep, WorkflowStepBuilder],
    +        edit: Optional[
    +            Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]
    +        ] = None,
    +        save: Optional[
    +            Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]
    +        ] = None,
    +        execute: Optional[
    +            Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]
    +        ] = None,
    +    ):
    +        """Registers a new Workflow Step listener.
    +        Unlike others, this method doesn't behave as a decorator.
    +        If you want to register a workflow step by a decorator, use `WorkflowStepBuilder`'s methods.
    +
    +            # Create a new WorkflowStep instance
    +            from slack_bolt.workflows.step import WorkflowStep
    +            ws = WorkflowStep(
    +                callback_id="add_task",
    +                edit=edit,
    +                save=save,
    +                execute=execute,
    +            )
    +            # Pass Step to set up listeners
    +            app.step(ws)
    +
    +        Refer to https://api.slack.com/workflows/steps for details of Steps from Apps.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +        For further information about WorkflowStep specific function arguments
    +        such as `configure`, `update`, `complete`, and `fail`,
    +        refer to `slack_bolt.workflows.step.utilities` API documents.
    +
    +        Args:
    +            callback_id: The Callback ID for this workflow step
    +            edit: The function for displaying a modal in the Workflow Builder
    +            save: The function for handling configuration in the Workflow Builder
    +            execute: The function for handling the step execution
    +        """
    +        step = callback_id
    +        if isinstance(callback_id, (str, Pattern)):
    +            step = WorkflowStep(
    +                callback_id=callback_id,
    +                edit=edit,
    +                save=save,
    +                execute=execute,
    +                base_logger=self._base_logger,
    +            )
    +        elif isinstance(step, WorkflowStepBuilder):
    +            step = step.build(base_logger=self._base_logger)
    +        elif not isinstance(step, WorkflowStep):
    +            raise BoltError(f"Invalid step object ({type(step)})")
    +
    +        self.use(WorkflowStepMiddleware(step, self.listener_runner))
    +
    +    # -------------------------
    +    # global error handler
    +
    +    def error(
    +        self, func: Callable[..., Optional[BoltResponse]]
    +    ) -> Callable[..., Optional[BoltResponse]]:
    +        """Updates the global error handler. This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.error
    +            def custom_error_handler(error, body, logger):
    +                logger.exception(f"Error: {error}")
    +                logger.info(f"Request body: {body}")
    +
    +            # Pass a function to this method
    +            app.error(custom_error_handler)
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +        Args:
    +            func: The function that is supposed to be executed
    +                when getting an unhandled error in Bolt app.
    +        """
    +        self._listener_runner.listener_error_handler = CustomListenerErrorHandler(
    +            logger=self._framework_logger,
    +            func=func,
    +        )
    +        self._middleware_error_handler = CustomMiddlewareErrorHandler(
    +            logger=self._framework_logger,
    +            func=func,
    +        )
    +        return func
    +
    +    # -------------------------
    +    # events
    +
    +    def event(
    +        self,
    +        event: Union[
    +            str,
    +            Pattern,
    +            Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
    +        ],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new event listener. This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.event("team_join")
    +            def ask_for_introduction(event, say):
    +                welcome_channel_id = "C12345"
    +                user_id = event["user"]
    +                text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
    +                say(text=text, channel=welcome_channel_id)
    +
    +            # Pass a function to this method
    +            app.event("team_join")(ask_for_introduction)
    +
    +        Refer to https://api.slack.com/apis/connections/events-api for details of Events API.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +        Args:
    +            event: The conditions that match a request payload.
    +                If you pass a dict for this, you can have type, subtype in the constraint.
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.event(
    +                event, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware, True
    +            )
    +
    +        return __call__
    +
    +    def message(
    +        self,
    +        keyword: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new message event listener. This method can be used as either a decorator or a method.
    +        Check the `App#event` method's docstring for details.
    +
    +            # Use this method as a decorator
    +            @app.message(":wave:")
    +            def say_hello(message, say):
    +                user = message['user']
    +                say(f"Hi there, <@{user}>!")
    +
    +            # Pass a function to this method
    +            app.message(":wave:")(say_hello)
    +
    +        Refer to https://api.slack.com/events/message for details of `message` events.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +        Args:
    +            keyword: The keyword to match
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +        matchers = list(matchers) if matchers else []
    +        middleware = list(middleware) if middleware else []
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            constraints = {
    +                "type": "message",
    +                "subtype": (
    +                    # In most cases, new message events come with no subtype.
    +                    None,
    +                    # As of Jan 2021, most bot messages no longer have the subtype bot_message.
    +                    # By contrast, messages posted using classic app's bot token still have the subtype.
    +                    "bot_message",
    +                    # If an end-user posts a message with "Also send to #channel" checked,
    +                    # the message event comes with this subtype.
    +                    "thread_broadcast",
    +                    # If an end-user posts a message with attached files,
    +                    # the message event comes with this subtype.
    +                    "file_share",
    +                ),
    +            }
    +            primary_matcher = builtin_matchers.message_event(
    +                keyword=keyword, constraints=constraints, base_logger=self._base_logger
    +            )
    +            middleware.insert(0, MessageListenerMatches(keyword))
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware, True
    +            )
    +
    +        return __call__
    +
    +    # -------------------------
    +    # slash commands
    +
    +    def command(
    +        self,
    +        command: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new slash command listener.
    +        This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.command("/echo")
    +            def repeat_text(ack, say, command):
    +                # Acknowledge command request
    +                ack()
    +                say(f"{command['text']}")
    +
    +            # Pass a function to this method
    +            app.command("/echo")(repeat_text)
    +
    +        Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +        Args:
    +            command: The conditions that match a request payload
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.command(
    +                command, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    # -------------------------
    +    # shortcut
    +
    +    def shortcut(
    +        self,
    +        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new shortcut listener.
    +        This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.shortcut("open_modal")
    +            def open_modal(ack, body, client):
    +                # Acknowledge the command request
    +                ack()
    +                # Call views_open with the built-in client
    +                client.views_open(
    +                    # Pass a valid trigger_id within 3 seconds of receiving it
    +                    trigger_id=body["trigger_id"],
    +                    # View payload
    +                    view={ ... }
    +                )
    +
    +            # Pass a function to this method
    +            app.shortcut("open_modal")(open_modal)
    +
    +        Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +        Args:
    +            constraints: The conditions that match a request payload.
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.shortcut(
    +                constraints, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def global_shortcut(
    +        self,
    +        callback_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new global shortcut listener."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.global_shortcut(
    +                callback_id, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def message_shortcut(
    +        self,
    +        callback_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new message shortcut listener."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.message_shortcut(
    +                callback_id, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    # -------------------------
    +    # action
    +
    +    def action(
    +        self,
    +        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new action listener. This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.action("approve_button")
    +            def update_message(ack):
    +                ack()
    +
    +            # Pass a function to this method
    +            app.action("approve_button")(update_message)
    +
    +        * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`.
    +        * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`.
    +        * Refer to https://api.slack.com/dialogs for actions in dialogs.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +        Args:
    +            constraints: The conditions that match a request payload
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.action(
    +                constraints, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def block_action(
    +        self,
    +        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new `block_actions` action listener.
    +        Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.block_action(
    +                constraints, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def attachment_action(
    +        self,
    +        callback_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new `interactive_message` action listener.
    +        Refer to https://api.slack.com/legacy/message-buttons for details."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.attachment_action(
    +                callback_id, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def dialog_submission(
    +        self,
    +        callback_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new `dialog_submission` listener.
    +        Refer to https://api.slack.com/dialogs for details."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.dialog_submission(
    +                callback_id, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def dialog_cancellation(
    +        self,
    +        callback_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new `dialog_cancellation` listener.
    +        Refer to https://api.slack.com/dialogs for details."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.dialog_cancellation(
    +                callback_id, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    # -------------------------
    +    # view
    +
    +    def view(
    +        self,
    +        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new `view_submission`/`view_closed` event listener.
    +        This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.view("view_1")
    +            def handle_submission(ack, body, client, view):
    +                # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
    +                hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
    +                user = body["user"]["id"]
    +                # Validate the inputs
    +                errors = {}
    +                if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
    +                    errors["block_c"] = "The value must be longer than 5 characters"
    +                if len(errors) > 0:
    +                    ack(response_action="errors", errors=errors)
    +                    return
    +                # Acknowledge the view_submission event and close the modal
    +                ack()
    +                # Do whatever you want with the input data - here we're saving it to a DB
    +
    +            # Pass a function to this method
    +            app.view("view_1")(handle_submission)
    +
    +        Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +        Args:
    +            constraints: The conditions that match a request payload
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.view(
    +                constraints, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def view_submission(
    +        self,
    +        constraints: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new `view_submission` listener.
    +        Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.view_submission(
    +                constraints, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def view_closed(
    +        self,
    +        constraints: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new `view_closed` listener.
    +        Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.view_closed(
    +                constraints, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    # -------------------------
    +    # options
    +
    +    def options(
    +        self,
    +        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new options listener.
    +        This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.options("menu_selection")
    +            def show_menu_options(ack):
    +                options = [
    +                    {
    +                        "text": {"type": "plain_text", "text": "Option 1"},
    +                        "value": "1-1",
    +                    },
    +                    {
    +                        "text": {"type": "plain_text", "text": "Option 2"},
    +                        "value": "1-2",
    +                    },
    +                ]
    +                ack(options=options)
    +
    +            # Pass a function to this method
    +            app.options("menu_selection")(show_menu_options)
    +
    +        Refer to the following documents for details:
    +
    +        * https://api.slack.com/reference/block-kit/block-elements#external_select
    +        * https://api.slack.com/reference/block-kit/block-elements#external_multi_select
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +        Args:
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.options(
    +                constraints, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def block_suggestion(
    +        self,
    +        action_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new `block_suggestion` listener."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.block_suggestion(
    +                action_id, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def dialog_suggestion(
    +        self,
    +        callback_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +        """Registers a new `dialog_suggestion` listener.
    +        Refer to https://api.slack.com/dialogs for details."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.dialog_suggestion(
    +                callback_id, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    # -------------------------
    +    # built-in listener functions
    +
    +    def default_tokens_revoked_event_listener(
    +        self,
    +    ) -> Callable[..., Optional[BoltResponse]]:
    +        if self._tokens_revocation_listeners is None:
    +            raise BoltError(error_installation_store_required_for_builtin_listeners())
    +        return self._tokens_revocation_listeners.handle_tokens_revoked_events
    +
    +    def default_app_uninstalled_event_listener(
    +        self,
    +    ) -> Callable[..., Optional[BoltResponse]]:
    +        if self._tokens_revocation_listeners is None:
    +            raise BoltError(error_installation_store_required_for_builtin_listeners())
    +        return self._tokens_revocation_listeners.handle_app_uninstalled_events
    +
    +    def enable_token_revocation_listeners(self) -> None:
    +        self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
    +        self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
    +
    +    # -------------------------
    +
    +    def _init_context(self, req: BoltRequest):
    +        req.context["logger"] = get_bolt_app_logger(
    +            app_name=self.name, base_logger=self._base_logger
    +        )
    +        req.context["token"] = self._token
    +        if self._token is not None:
    +            # This WebClient instance can be safely singleton
    +            req.context["client"] = self._client
    +        else:
    +            # Set a new dedicated instance for this request
    +            client_per_request: WebClient = WebClient(
    +                token=None,  # the token will be set later
    +                base_url=self._client.base_url,
    +                timeout=self._client.timeout,
    +                ssl=self._client.ssl,
    +                proxy=self._client.proxy,
    +                headers=self._client.headers,
    +                team_id=req.context.team_id,
    +            )
    +            req.context["client"] = client_per_request
    +
    +    @staticmethod
    +    def _to_listener_functions(
    +        kwargs: dict,
    +    ) -> Optional[Sequence[Callable[..., Optional[BoltResponse]]]]:
    +        if kwargs:
    +            functions = [kwargs["ack"]]
    +            for sub in kwargs["lazy"]:
    +                functions.append(sub)
    +            return functions
    +        return None
    +
    +    def _register_listener(
    +        self,
    +        functions: Sequence[Callable[..., Optional[BoltResponse]]],
    +        primary_matcher: ListenerMatcher,
    +        matchers: Optional[Sequence[Callable[..., bool]]],
    +        middleware: Optional[Sequence[Union[Callable, Middleware]]],
    +        auto_acknowledgement: bool = False,
    +    ) -> Optional[Callable[..., Optional[BoltResponse]]]:
    +        value_to_return = None
    +        if not isinstance(functions, list):
    +            functions = list(functions)
    +        if len(functions) == 1:
    +            # In the case where the function is registered using decorator,
    +            # the registration should return the original function.
    +            value_to_return = functions[0]
    +
    +        listener_matchers = [
    +            CustomListenerMatcher(
    +                app_name=self.name, func=f, base_logger=self._base_logger
    +            )
    +            for f in (matchers or [])
    +        ]
    +        listener_matchers.insert(0, primary_matcher)
    +        listener_middleware = []
    +        for m in middleware or []:
    +            if isinstance(m, Middleware):
    +                listener_middleware.append(m)
    +            elif isinstance(m, Callable):
    +                listener_middleware.append(
    +                    CustomMiddleware(
    +                        app_name=self.name, func=m, base_logger=self._base_logger
    +                    )
    +                )
    +            else:
    +                raise ValueError(error_unexpected_listener_middleware(type(m)))
    +
    +        self._listeners.append(
    +            CustomListener(
    +                app_name=self.name,
    +                ack_function=functions.pop(0),
    +                lazy_functions=functions,
    +                matchers=listener_matchers,
    +                middleware=listener_middleware,
    +                auto_acknowledgement=auto_acknowledgement,
    +                base_logger=self._base_logger,
    +            )
    +        )
    +        return value_to_return
    +
    +

    Instance variables

    +
    +
    var client : slack_sdk.web.client.WebClient
    +
    +

    The singleton slack_sdk.WebClient instance in this app.

    +
    + +Expand source code + +
    @property
    +def client(self) -> WebClient:
    +    """The singleton `slack_sdk.WebClient` instance in this app."""
    +    return self._client
    +
    +
    +
    var installation_store : Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore]
    +
    +

    The slack_sdk.oauth.InstallationStore that can be used in the authorize middleware.

    +
    + +Expand source code + +
    @property
    +def installation_store(self) -> Optional[InstallationStore]:
    +    """The `slack_sdk.oauth.InstallationStore` that can be used in the `authorize` middleware."""
    +    return self._installation_store
    +
    +
    +
    var listener_runnerThreadListenerRunner
    +
    +

    The thread executor for asynchronously running listeners.

    +
    + +Expand source code + +
    @property
    +def listener_runner(self) -> ThreadListenerRunner:
    +    """The thread executor for asynchronously running listeners."""
    +    return self._listener_runner
    +
    +
    +
    var logger : logging.Logger
    +
    +

    The logger this app uses.

    +
    + +Expand source code + +
    @property
    +def logger(self) -> logging.Logger:
    +    """The logger this app uses."""
    +    return self._framework_logger
    +
    +
    +
    var name : str
    +
    +

    The name of this app (default: the filename)

    +
    + +Expand source code + +
    @property
    +def name(self) -> str:
    +    """The name of this app (default: the filename)"""
    +    return self._name
    +
    +
    +
    var oauth_flow : Optional[OAuthFlow]
    +
    +

    Configured OAuthFlow object if exists.

    +
    + +Expand source code + +
    @property
    +def oauth_flow(self) -> Optional[OAuthFlow]:
    +    """Configured `OAuthFlow` object if exists."""
    +    return self._oauth_flow
    +
    +
    +
    var process_before_response : bool
    +
    +
    +
    + +Expand source code + +
    @property
    +def process_before_response(self) -> bool:
    +    return self._process_before_response or False
    +
    +
    +
    +

    Methods

    +
    +
    +def action(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new action listener. This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.action("approve_button")
    +def update_message(ack):
    +    ack()
    +
    +# Pass a function to this method
    +app.action("approve_button")(update_message)
    +
    + +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    +

    Args

    +
    +
    constraints
    +
    The conditions that match a request payload
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def action(
    +    self,
    +    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new action listener. This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.action("approve_button")
    +        def update_message(ack):
    +            ack()
    +
    +        # Pass a function to this method
    +        app.action("approve_button")(update_message)
    +
    +    * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`.
    +    * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`.
    +    * Refer to https://api.slack.com/dialogs for actions in dialogs.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +    Args:
    +        constraints: The conditions that match a request payload
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.action(
    +            constraints, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def attachment_action(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new interactive_message action listener. +Refer to https://api.slack.com/legacy/message-buttons for details.

    +
    + +Expand source code + +
    def attachment_action(
    +    self,
    +    callback_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new `interactive_message` action listener.
    +    Refer to https://api.slack.com/legacy/message-buttons for details."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.attachment_action(
    +            callback_id, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def block_action(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new block_actions action listener. +Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.

    +
    + +Expand source code + +
    def block_action(
    +    self,
    +    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new `block_actions` action listener.
    +    Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.block_action(
    +            constraints, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def block_suggestion(self, action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new block_suggestion listener.

    +
    + +Expand source code + +
    def block_suggestion(
    +    self,
    +    action_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new `block_suggestion` listener."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.block_suggestion(
    +            action_id, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def command(self, command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new slash command listener. +This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.command("/echo")
    +def repeat_text(ack, say, command):
    +    # Acknowledge command request
    +    ack()
    +    say(f"{command['text']}")
    +
    +# Pass a function to this method
    +app.command("/echo")(repeat_text)
    +
    +

    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.

    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    +

    Args

    +
    +
    command
    +
    The conditions that match a request payload
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def command(
    +    self,
    +    command: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new slash command listener.
    +    This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.command("/echo")
    +        def repeat_text(ack, say, command):
    +            # Acknowledge command request
    +            ack()
    +            say(f"{command['text']}")
    +
    +        # Pass a function to this method
    +        app.command("/echo")(repeat_text)
    +
    +    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +    Args:
    +        command: The conditions that match a request payload
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.command(
    +            command, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def default_app_uninstalled_event_listener(self) ‑> Callable[..., Optional[BoltResponse]] +
    +
    +
    +
    + +Expand source code + +
    def default_app_uninstalled_event_listener(
    +    self,
    +) -> Callable[..., Optional[BoltResponse]]:
    +    if self._tokens_revocation_listeners is None:
    +        raise BoltError(error_installation_store_required_for_builtin_listeners())
    +    return self._tokens_revocation_listeners.handle_app_uninstalled_events
    +
    +
    +
    +def default_tokens_revoked_event_listener(self) ‑> Callable[..., Optional[BoltResponse]] +
    +
    + +Expand source code + +
    def default_tokens_revoked_event_listener(
    +    self,
    +) -> Callable[..., Optional[BoltResponse]]:
    +    if self._tokens_revocation_listeners is None:
    +        raise BoltError(error_installation_store_required_for_builtin_listeners())
    +    return self._tokens_revocation_listeners.handle_tokens_revoked_events
    +
    -
    slack_bolt.app.async_server
    +
    +def dialog_cancellation(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new dialog_cancellation listener. +Refer to https://api.slack.com/dialogs for details.

    +
    + +Expand source code + +
    def dialog_cancellation(
    +    self,
    +    callback_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new `dialog_cancellation` listener.
    +    Refer to https://api.slack.com/dialogs for details."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.dialog_cancellation(
    +            callback_id, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def dialog_submission(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new dialog_submission listener. +Refer to https://api.slack.com/dialogs for details.

    +
    + +Expand source code + +
    def dialog_submission(
    +    self,
    +    callback_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new `dialog_submission` listener.
    +    Refer to https://api.slack.com/dialogs for details."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.dialog_submission(
    +            callback_id, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def dialog_suggestion(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new dialog_suggestion listener. +Refer to https://api.slack.com/dialogs for details.

    +
    + +Expand source code + +
    def dialog_suggestion(
    +    self,
    +    callback_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new `dialog_suggestion` listener.
    +    Refer to https://api.slack.com/dialogs for details."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.dialog_suggestion(
    +            callback_id, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def dispatch(self, req: BoltRequest) ‑> BoltResponse +
    +
    +

    Applies all middleware and dispatches an incoming request from Slack to the right code path.

    +

    Args

    +
    +
    req
    +
    An incoming request from Slack
    +
    +

    Returns

    +

    The response generated by this Bolt app

    +
    + +Expand source code + +
    def dispatch(self, req: BoltRequest) -> BoltResponse:
    +    """Applies all middleware and dispatches an incoming request from Slack to the right code path.
    +
    +    Args:
    +        req: An incoming request from Slack
    +
    +    Returns:
    +        The response generated by this Bolt app
    +    """
    +    starting_time = time.time()
    +    self._init_context(req)
    +
    +    resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
    +    middleware_state = {"next_called": False}
    +
    +    def middleware_next():
    +        middleware_state["next_called"] = True
    +
    +    try:
    +        for middleware in self._middleware_list:
    +            middleware_state["next_called"] = False
    +            if self._framework_logger.level <= logging.DEBUG:
    +                self._framework_logger.debug(
    +                    debug_applying_middleware(middleware.name)
    +                )
    +            resp = middleware.process(req=req, resp=resp, next=middleware_next)
    +            if not middleware_state["next_called"]:
    +                if resp is None:
    +                    # next() method was not called without providing the response to return to Slack
    +                    # This should not be an intentional handling in usual use cases.
    +                    resp = BoltResponse(
    +                        status=404, body={"error": "no next() calls in middleware"}
    +                    )
    +                    if self._raise_error_for_unhandled_request is True:
    +                        self._listener_runner.listener_error_handler.handle(
    +                            error=BoltUnhandledRequestError(
    +                                request=req,
    +                                current_response=resp,
    +                                last_global_middleware_name=middleware.name,
    +                            ),
    +                            request=req,
    +                            response=resp,
    +                        )
    +                        return resp
    +                    self._framework_logger.warning(
    +                        warning_unhandled_by_global_middleware(middleware.name, req)
    +                    )
    +                    return resp
    +                return resp
    +
    +        for listener in self._listeners:
    +            listener_name = get_name_for_callable(listener.ack_function)
    +            self._framework_logger.debug(debug_checking_listener(listener_name))
    +            if listener.matches(req=req, resp=resp):
    +                # run all the middleware attached to this listener first
    +                middleware_resp, next_was_not_called = listener.run_middleware(
    +                    req=req, resp=resp
    +                )
    +                if next_was_not_called:
    +                    if middleware_resp is not None:
    +                        if self._framework_logger.level <= logging.DEBUG:
    +                            debug_message = (
    +                                debug_return_listener_middleware_response(
    +                                    listener_name,
    +                                    middleware_resp.status,
    +                                    middleware_resp.body,
    +                                    starting_time,
    +                                )
    +                            )
    +                            self._framework_logger.debug(debug_message)
    +                        return middleware_resp
    +                    # The last listener middleware didn't call next() method.
    +                    # This means the listener is not for this incoming request.
    +                    continue
    +
    +                if middleware_resp is not None:
    +                    resp = middleware_resp
    +
    +                self._framework_logger.debug(debug_running_listener(listener_name))
    +                listener_response: Optional[
    +                    BoltResponse
    +                ] = self._listener_runner.run(
    +                    request=req,
    +                    response=resp,
    +                    listener_name=listener_name,
    +                    listener=listener,
    +                )
    +                if listener_response is not None:
    +                    return listener_response
    +
    +        if resp is None:
    +            resp = BoltResponse(status=404, body={"error": "unhandled request"})
    +        if self._raise_error_for_unhandled_request is True:
    +            self._listener_runner.listener_error_handler.handle(
    +                error=BoltUnhandledRequestError(
    +                    request=req,
    +                    current_response=resp,
    +                ),
    +                request=req,
    +                response=resp,
    +            )
    +            return resp
    +        return self._handle_unmatched_requests(req, resp)
    +    except Exception as error:
    +        resp = BoltResponse(status=500, body="")
    +        self._middleware_error_handler.handle(
    +            error=error,
    +            request=req,
    +            response=resp,
    +        )
    +        return resp
    +
    +
    +
    +def enable_token_revocation_listeners(self) ‑> None +
    +
    + +Expand source code + +
    def enable_token_revocation_listeners(self) -> None:
    +    self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
    +    self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
    +
    +
    +
    +def error(self, func: Callable[..., Optional[BoltResponse]]) ‑> Callable[..., Optional[BoltResponse]] +
    +
    +

    Updates the global error handler. This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.error
    +def custom_error_handler(error, body, logger):
    +    logger.exception(f"Error: {error}")
    +    logger.info(f"Request body: {body}")
    +
    +# Pass a function to this method
    +app.error(custom_error_handler)
    +
    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    +

    Args

    +
    +
    func
    +
    The function that is supposed to be executed +when getting an unhandled error in Bolt app.
    +
    +
    + +Expand source code + +
    def error(
    +    self, func: Callable[..., Optional[BoltResponse]]
    +) -> Callable[..., Optional[BoltResponse]]:
    +    """Updates the global error handler. This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.error
    +        def custom_error_handler(error, body, logger):
    +            logger.exception(f"Error: {error}")
    +            logger.info(f"Request body: {body}")
    +
    +        # Pass a function to this method
    +        app.error(custom_error_handler)
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +    Args:
    +        func: The function that is supposed to be executed
    +            when getting an unhandled error in Bolt app.
    +    """
    +    self._listener_runner.listener_error_handler = CustomListenerErrorHandler(
    +        logger=self._framework_logger,
    +        func=func,
    +    )
    +    self._middleware_error_handler = CustomMiddlewareErrorHandler(
    +        logger=self._framework_logger,
    +        func=func,
    +    )
    +    return func
    +
    +
    +
    +def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, ForwardRef(None)]], ForwardRef(None)]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new event listener. This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.event("team_join")
    +def ask_for_introduction(event, say):
    +    welcome_channel_id = "C12345"
    +    user_id = event["user"]
    +    text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
    +    say(text=text, channel=welcome_channel_id)
    +
    +# Pass a function to this method
    +app.event("team_join")(ask_for_introduction)
    +
    +

    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.

    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    +

    Args

    +
    +
    event
    +
    The conditions that match a request payload. +If you pass a dict for this, you can have type, subtype in the constraint.
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def event(
    +    self,
    +    event: Union[
    +        str,
    +        Pattern,
    +        Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
    +    ],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new event listener. This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.event("team_join")
    +        def ask_for_introduction(event, say):
    +            welcome_channel_id = "C12345"
    +            user_id = event["user"]
    +            text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
    +            say(text=text, channel=welcome_channel_id)
    +
    +        # Pass a function to this method
    +        app.event("team_join")(ask_for_introduction)
    +
    +    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +    Args:
    +        event: The conditions that match a request payload.
    +            If you pass a dict for this, you can have type, subtype in the constraint.
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.event(
    +            event, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware, True
    +        )
    +
    +    return __call__
    +
    +
    +
    +def global_shortcut(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new global shortcut listener.

    +
    + +Expand source code + +
    def global_shortcut(
    +    self,
    +    callback_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new global shortcut listener."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.global_shortcut(
    +            callback_id, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def message(self, keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new message event listener. This method can be used as either a decorator or a method. +Check the App#event method's docstring for details.

    +
    # Use this method as a decorator
    +@app.message(":wave:")
    +def say_hello(message, say):
    +    user = message['user']
    +    say(f"Hi there, <@{user}>!")
    +
    +# Pass a function to this method
    +app.message(":wave:")(say_hello)
    +
    +

    Refer to https://api.slack.com/events/message for details of message events.

    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    +

    Args

    +
    +
    keyword
    +
    The keyword to match
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def message(
    +    self,
    +    keyword: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new message event listener. This method can be used as either a decorator or a method.
    +    Check the `App#event` method's docstring for details.
    +
    +        # Use this method as a decorator
    +        @app.message(":wave:")
    +        def say_hello(message, say):
    +            user = message['user']
    +            say(f"Hi there, <@{user}>!")
    +
    +        # Pass a function to this method
    +        app.message(":wave:")(say_hello)
    +
    +    Refer to https://api.slack.com/events/message for details of `message` events.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +    Args:
    +        keyword: The keyword to match
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +    matchers = list(matchers) if matchers else []
    +    middleware = list(middleware) if middleware else []
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        constraints = {
    +            "type": "message",
    +            "subtype": (
    +                # In most cases, new message events come with no subtype.
    +                None,
    +                # As of Jan 2021, most bot messages no longer have the subtype bot_message.
    +                # By contrast, messages posted using classic app's bot token still have the subtype.
    +                "bot_message",
    +                # If an end-user posts a message with "Also send to #channel" checked,
    +                # the message event comes with this subtype.
    +                "thread_broadcast",
    +                # If an end-user posts a message with attached files,
    +                # the message event comes with this subtype.
    +                "file_share",
    +            ),
    +        }
    +        primary_matcher = builtin_matchers.message_event(
    +            keyword=keyword, constraints=constraints, base_logger=self._base_logger
    +        )
    +        middleware.insert(0, MessageListenerMatches(keyword))
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware, True
    +        )
    +
    +    return __call__
    +
    +
    +
    +def message_shortcut(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new message shortcut listener.

    +
    + +Expand source code + +
    def message_shortcut(
    +    self,
    +    callback_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new message shortcut listener."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.message_shortcut(
    +            callback_id, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def middleware(self, *args) ‑> Optional[Callable] +
    +
    +

    Registers a new middleware to this app. +This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.middleware
    +def middleware_func(logger, body, next):
    +    logger.info(f"request body: {body}")
    +    next()
    +
    +# Pass a function to this method
    +app.middleware(middleware_func)
    +
    +

    Refer to https://slack.dev/bolt-python/concepts#global-middleware for details.

    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    +

    Args

    +
    +
    *args
    +
    A function that works as a global middleware.
    +
    +
    + +Expand source code + +
    def middleware(self, *args) -> Optional[Callable]:
    +    """Registers a new middleware to this app.
    +    This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.middleware
    +        def middleware_func(logger, body, next):
    +            logger.info(f"request body: {body}")
    +            next()
    +
    +        # Pass a function to this method
    +        app.middleware(middleware_func)
    +
    +    Refer to https://slack.dev/bolt-python/concepts#global-middleware for details.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +    Args:
    +        *args: A function that works as a global middleware.
    +    """
    +    if len(args) > 0:
    +        middleware_or_callable = args[0]
    +        if isinstance(middleware_or_callable, Middleware):
    +            middleware: Middleware = middleware_or_callable
    +            self._middleware_list.append(middleware)
    +        elif isinstance(middleware_or_callable, Callable):
    +            self._middleware_list.append(
    +                CustomMiddleware(
    +                    app_name=self.name,
    +                    func=middleware_or_callable,
    +                    base_logger=self._base_logger,
    +                )
    +            )
    +            return middleware_or_callable
    +        else:
    +            raise BoltError(
    +                f"Unexpected type for a middleware ({type(middleware_or_callable)})"
    +            )
    +    return None
    +
    +
    +
    +def options(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new options listener. +This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.options("menu_selection")
    +def show_menu_options(ack):
    +    options = [
    +        {
    +            "text": {"type": "plain_text", "text": "Option 1"},
    +            "value": "1-1",
    +        },
    +        {
    +            "text": {"type": "plain_text", "text": "Option 2"},
    +            "value": "1-2",
    +        },
    +    ]
    +    ack(options=options)
    +
    +# Pass a function to this method
    +app.options("menu_selection")(show_menu_options)
    +
    +

    Refer to the following documents for details:

    + +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    +

    Args

    +
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def options(
    +    self,
    +    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new options listener.
    +    This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.options("menu_selection")
    +        def show_menu_options(ack):
    +            options = [
    +                {
    +                    "text": {"type": "plain_text", "text": "Option 1"},
    +                    "value": "1-1",
    +                },
    +                {
    +                    "text": {"type": "plain_text", "text": "Option 2"},
    +                    "value": "1-2",
    +                },
    +            ]
    +            ack(options=options)
    +
    +        # Pass a function to this method
    +        app.options("menu_selection")(show_menu_options)
    +
    +    Refer to the following documents for details:
    +
    +    * https://api.slack.com/reference/block-kit/block-elements#external_select
    +    * https://api.slack.com/reference/block-kit/block-elements#external_multi_select
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +    Args:
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.options(
    +            constraints, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def shortcut(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new shortcut listener. +This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.shortcut("open_modal")
    +def open_modal(ack, body, client):
    +    # Acknowledge the command request
    +    ack()
    +    # Call views_open with the built-in client
    +    client.views_open(
    +        # Pass a valid trigger_id within 3 seconds of receiving it
    +        trigger_id=body["trigger_id"],
    +        # View payload
    +        view={ ... }
    +    )
    +
    +# Pass a function to this method
    +app.shortcut("open_modal")(open_modal)
    +
    +

    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.

    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    +

    Args

    +
    +
    constraints
    +
    The conditions that match a request payload.
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def shortcut(
    +    self,
    +    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new shortcut listener.
    +    This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.shortcut("open_modal")
    +        def open_modal(ack, body, client):
    +            # Acknowledge the command request
    +            ack()
    +            # Call views_open with the built-in client
    +            client.views_open(
    +                # Pass a valid trigger_id within 3 seconds of receiving it
    +                trigger_id=body["trigger_id"],
    +                # View payload
    +                view={ ... }
    +            )
    +
    +        # Pass a function to this method
    +        app.shortcut("open_modal")(open_modal)
    +
    +    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +    Args:
    +        constraints: The conditions that match a request payload.
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.shortcut(
    +            constraints, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def start(self, port: int = 3000, path: str = '/slack/events', http_server_logger_enabled: bool = True) ‑> None +
    +
    +

    Starts a web server for local development.

    +
    # With the default settings, `http://localhost:3000/slack/events`
    +# is available for handling incoming requests from Slack
    +app.start()
    +
    +

    This method internally starts a Web server process built with the http.server module. +For production, consider using a production-ready WSGI server such as Gunicorn.

    +

    Args

    +
    +
    port
    +
    The port to listen on (Default: 3000)
    +
    path
    +
    The path to handle request from Slack (Default: /slack/events)
    +
    http_server_logger_enabled
    +
    The flag to enable http.server logging if True (Default: True)
    +
    +
    + +Expand source code + +
    def start(
    +    self,
    +    port: int = 3000,
    +    path: str = "/slack/events",
    +    http_server_logger_enabled: bool = True,
    +) -> None:
    +    """Starts a web server for local development.
    +
    +        # With the default settings, `http://localhost:3000/slack/events`
    +        # is available for handling incoming requests from Slack
    +        app.start()
    +
    +    This method internally starts a Web server process built with the `http.server` module.
    +    For production, consider using a production-ready WSGI server such as Gunicorn.
    +
    +    Args:
    +        port: The port to listen on (Default: 3000)
    +        path: The path to handle request from Slack (Default: `/slack/events`)
    +        http_server_logger_enabled: The flag to enable http.server logging if True (Default: True)
    +    """
    +    self._development_server = SlackAppDevelopmentServer(
    +        port=port,
    +        path=path,
    +        app=self,
    +        oauth_flow=self.oauth_flow,
    +        http_server_logger_enabled=http_server_logger_enabled,
    +    )
    +    self._development_server.start()
    +
    +
    +
    +def step(self, callback_id: Union[str, Pattern, WorkflowStepWorkflowStepBuilder], edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None, save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None, execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None) +
    +
    +

    Registers a new Workflow Step listener. +Unlike others, this method doesn't behave as a decorator. +If you want to register a workflow step by a decorator, use WorkflowStepBuilder's methods.

    +
    # Create a new WorkflowStep instance
    +from slack_bolt.workflows.step import WorkflowStep
    +ws = WorkflowStep(
    +    callback_id="add_task",
    +    edit=edit,
    +    save=save,
    +    execute=execute,
    +)
    +# Pass Step to set up listeners
    +app.step(ws)
    +
    +

    Refer to https://api.slack.com/workflows/steps for details of Steps from Apps.

    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    +

    For further information about WorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to slack_bolt.workflows.step.utilities API documents.

    +

    Args

    +
    +
    callback_id
    +
    The Callback ID for this workflow step
    +
    edit
    +
    The function for displaying a modal in the Workflow Builder
    +
    save
    +
    The function for handling configuration in the Workflow Builder
    +
    execute
    +
    The function for handling the step execution
    +
    +
    + +Expand source code + +
    def step(
    +    self,
    +    callback_id: Union[str, Pattern, WorkflowStep, WorkflowStepBuilder],
    +    edit: Optional[
    +        Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]
    +    ] = None,
    +    save: Optional[
    +        Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]
    +    ] = None,
    +    execute: Optional[
    +        Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]
    +    ] = None,
    +):
    +    """Registers a new Workflow Step listener.
    +    Unlike others, this method doesn't behave as a decorator.
    +    If you want to register a workflow step by a decorator, use `WorkflowStepBuilder`'s methods.
    +
    +        # Create a new WorkflowStep instance
    +        from slack_bolt.workflows.step import WorkflowStep
    +        ws = WorkflowStep(
    +            callback_id="add_task",
    +            edit=edit,
    +            save=save,
    +            execute=execute,
    +        )
    +        # Pass Step to set up listeners
    +        app.step(ws)
    +
    +    Refer to https://api.slack.com/workflows/steps for details of Steps from Apps.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +    For further information about WorkflowStep specific function arguments
    +    such as `configure`, `update`, `complete`, and `fail`,
    +    refer to `slack_bolt.workflows.step.utilities` API documents.
    +
    +    Args:
    +        callback_id: The Callback ID for this workflow step
    +        edit: The function for displaying a modal in the Workflow Builder
    +        save: The function for handling configuration in the Workflow Builder
    +        execute: The function for handling the step execution
    +    """
    +    step = callback_id
    +    if isinstance(callback_id, (str, Pattern)):
    +        step = WorkflowStep(
    +            callback_id=callback_id,
    +            edit=edit,
    +            save=save,
    +            execute=execute,
    +            base_logger=self._base_logger,
    +        )
    +    elif isinstance(step, WorkflowStepBuilder):
    +        step = step.build(base_logger=self._base_logger)
    +    elif not isinstance(step, WorkflowStep):
    +        raise BoltError(f"Invalid step object ({type(step)})")
    +
    +    self.use(WorkflowStepMiddleware(step, self.listener_runner))
    +
    +
    +
    +def use(self, *args) ‑> Optional[Callable] +
    +
    +

    Registers a new global middleware to this app. This method can be used as either a decorator or a method.

    +

    Refer to App#middleware() method's docstring for details.

    +
    + +Expand source code + +
    def use(self, *args) -> Optional[Callable]:
    +    """Registers a new global middleware to this app. This method can be used as either a decorator or a method.
    +
    +    Refer to `App#middleware()` method's docstring for details."""
    +    return self.middleware(*args)
    +
    +
    +
    +def view(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new view_submission/view_closed event listener. +This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.view("view_1")
    +def handle_submission(ack, body, client, view):
    +    # Assume there's an input block with <code>block\_c</code> as the block_id and <code>dreamy\_input</code>
    +    hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
    +    user = body["user"]["id"]
    +    # Validate the inputs
    +    errors = {}
    +    if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
    +        errors["block_c"] = "The value must be longer than 5 characters"
    +    if len(errors) > 0:
    +        ack(response_action="errors", errors=errors)
    +        return
    +    # Acknowledge the view_submission event and close the modal
    +    ack()
    +    # Do whatever you want with the input data - here we're saving it to a DB
    +
    +# Pass a function to this method
    +app.view("view_1")(handle_submission)
    +
    +

    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.

    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    +

    Args

    +
    +
    constraints
    +
    The conditions that match a request payload
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def view(
    +    self,
    +    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new `view_submission`/`view_closed` event listener.
    +    This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.view("view_1")
    +        def handle_submission(ack, body, client, view):
    +            # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
    +            hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
    +            user = body["user"]["id"]
    +            # Validate the inputs
    +            errors = {}
    +            if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
    +                errors["block_c"] = "The value must be longer than 5 characters"
    +            if len(errors) > 0:
    +                ack(response_action="errors", errors=errors)
    +                return
    +            # Acknowledge the view_submission event and close the modal
    +            ack()
    +            # Do whatever you want with the input data - here we're saving it to a DB
    +
    +        # Pass a function to this method
    +        app.view("view_1")(handle_submission)
    +
    +    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
    +
    +    Args:
    +        constraints: The conditions that match a request payload
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.view(
    +            constraints, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def view_closed(self, constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new view_closed listener. +Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.

    +
    + +Expand source code + +
    def view_closed(
    +    self,
    +    constraints: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new `view_closed` listener.
    +    Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.view_closed(
    +            constraints, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def view_submission(self, constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +
    +
    +

    Registers a new view_submission listener. +Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.

    +
    + +Expand source code + +
    def view_submission(
    +    self,
    +    constraints: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., bool]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    +    """Registers a new `view_submission` listener.
    +    Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.view_submission(
    +            constraints, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    -
    -
    -
    -
    -
    -
    diff --git a/docs/api-docs/slack_bolt/async_app.html b/docs/api-docs/slack_bolt/async_app.html index b31814f92..22ac929f6 100644 --- a/docs/api-docs/slack_bolt/async_app.html +++ b/docs/api-docs/slack_bolt/async_app.html @@ -24,7 +24,7 @@

    Module slack_bolt.async_app

    Module for creating asyncio based apps

    Creating an async app

    -

    If you'd prefer to build your app with asyncio, you can import the AIOHTTP library and call the AsyncApp constructor. Within async apps, you can use the async/await pattern.

    +

    If you'd prefer to build your app with asyncio, you can import the AIOHTTP library and call the AsyncApp constructor. Within async apps, you can use the async/await pattern.

    # Python 3.6+ required
     python -m venv .venv
     source .venv/bin/activate
    @@ -109,23 +109,4072 @@ 

    Creating an async app

    Refer to `slack_bolt.app.async_app` for more details. """ # noqa: E501 -from .app.async_app import AsyncApp # noqa -from .context.ack.async_ack import AsyncAck # noqa -from .context.async_context import AsyncBoltContext # noqa -from .context.respond.async_respond import AsyncRespond # noqa -from .context.say.async_say import AsyncSay # noqa -from .listener.async_listener import AsyncListener # noqa -from .listener_matcher.async_listener_matcher import AsyncCustomListenerMatcher # noqa -from .request.async_request import AsyncBoltRequest # noqa
    +from .app.async_app import AsyncApp +from .context.ack.async_ack import AsyncAck +from .context.async_context import AsyncBoltContext +from .context.respond.async_respond import AsyncRespond +from .context.say.async_say import AsyncSay +from .listener.async_listener import AsyncListener +from .listener_matcher.async_listener_matcher import AsyncCustomListenerMatcher +from .request.async_request import AsyncBoltRequest + +__all__ = [ + "AsyncApp", + "AsyncAck", + "AsyncBoltContext", + "AsyncRespond", + "AsyncSay", + "AsyncListener", + "AsyncCustomListenerMatcher", + "AsyncBoltRequest", +] + +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class AsyncAck +
    +
    +
    +
    + +Expand source code + +
    class AsyncAck:
    +    response: Optional[BoltResponse]
    +
    +    def __init__(self):
    +        self.response: Optional[BoltResponse] = None
    +
    +    async def __call__(
    +        self,
    +        text: Union[str, dict] = "",  # text: str or whole_response: dict
    +        blocks: Optional[Sequence[Union[dict, Block]]] = None,
    +        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
    +        unfurl_links: Optional[bool] = None,
    +        unfurl_media: Optional[bool] = None,
    +        response_type: Optional[str] = None,  # in_channel / ephemeral
    +        # block_suggestion / dialog_suggestion
    +        options: Optional[Sequence[Union[dict, Option]]] = None,
    +        option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None,
    +        # view_submission
    +        response_action: Optional[str] = None,  # errors / update / push / clear
    +        errors: Optional[Dict[str, str]] = None,
    +        view: Optional[Union[dict, View]] = None,
    +    ) -> BoltResponse:
    +        return _set_response(
    +            self,
    +            text_or_whole_response=text,
    +            blocks=blocks,
    +            attachments=attachments,
    +            unfurl_links=unfurl_links,
    +            unfurl_media=unfurl_media,
    +            response_type=response_type,
    +            options=options,
    +            option_groups=option_groups,
    +            response_action=response_action,
    +            errors=errors,
    +            view=view,
    +        )
    +
    +

    Class variables

    +
    +
    var response : Optional[BoltResponse]
    +
    +
    +
    +
    +
    +
    +class AsyncApp +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, verification_token: Optional[str] = None) +
    +
    +

    Bolt App that provides functionalities to register middleware/listeners.

    +
    import os
    +from slack_bolt.async_app import AsyncApp
    +
    +# Initializes your app with your bot token and signing secret
    +app = AsyncApp(
    +    token=os.environ.get("SLACK_BOT_TOKEN"),
    +    signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
    +)
    +
    +# Listens to incoming messages that contain "hello"
    +@app.message("hello")
    +async def message_hello(message, say):  # async function
    +    # say() sends a message to the channel where the event was triggered
    +    await say(f"Hey there <@{message['user']}>!")
    +
    +# Start your app
    +if __name__ == "__main__":
    +    app.start(port=int(os.environ.get("PORT", 3000)))
    +
    +

    Refer to https://slack.dev/bolt-python/concepts#async for details.

    +

    If you would like to build an OAuth app for enabling the app to run with multiple workspaces, +refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app.

    +

    Args

    +
    +
    logger
    +
    The custom logger that can be used in this app.
    +
    name
    +
    The application name that will be used in logging. If absent, the source file name will be used.
    +
    process_before_response
    +
    True if this app runs on Function as a Service. (Default: False)
    +
    raise_error_for_unhandled_request
    +
    True if you want to raise exceptions for unhandled requests +and use @app.error listeners instead of +the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
    +
    signing_secret
    +
    The Signing Secret value used for verifying requests from Slack.
    +
    token
    +
    The bot/user access token required only for single-workspace app.
    +
    client
    +
    The singleton slack_sdk.web.async_client.AsyncWebClient instance for this app.
    +
    authorize
    +
    The function to authorize an incoming request from Slack +by checking if there is a team/user in the installation data.
    +
    installation_store
    +
    The module offering save/find operations of installation data
    +
    installation_store_bot_only
    +
    Use AsyncInstallationStore#async_find_bot() if True (Default: False)
    +
    request_verification_enabled
    +
    False if you would like to disable the built-in middleware (Default: True). +AsyncRequestVerification is a built-in middleware that verifies the signature in HTTP Mode requests. +Make sure if it's safe enough when you turn a built-in middleware off. +We strongly recommend using RequestVerification for better security. +If you have a proxy that verifies request signature in front of the Bolt app, +it's totally fine to disable RequestVerification to avoid duplication of work. +Don't turn it off just for easiness of development.
    +
    ignoring_self_events_enabled
    +
    False if you would like to disable the built-in middleware (Default: True). +AsyncIgnoringSelfEvents is a built-in middleware that enables Bolt apps to easily skip the events +generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
    +
    url_verification_enabled
    +
    False if you would like to disable the built-in middleware (Default: True). +AsyncUrlVerification is a built-in middleware that handles url_verification requests +that verify the endpoint for Events API in HTTP Mode requests.
    +
    ssl_check_enabled
    +
    bool = False if you would like to disable the built-in middleware (Default: True). +AsyncSslCheck is a built-in middleware that handles ssl_check requests from Slack.
    +
    oauth_settings
    +
    The settings related to Slack app installation flow (OAuth flow)
    +
    oauth_flow
    +
    Instantiated slack_bolt.oauth.AsyncOAuthFlow. This is always prioritized over oauth_settings.
    +
    verification_token
    +
    Deprecated verification mechanism. This can used only for ssl_check requests.
    +
    +
    + +Expand source code + +
    class AsyncApp:
    +    def __init__(
    +        self,
    +        *,
    +        logger: Optional[logging.Logger] = None,
    +        # Used in logger
    +        name: Optional[str] = None,
    +        # Set True when you run this app on a FaaS platform
    +        process_before_response: bool = False,
    +        # Set True if you want to handle an unhandled request as an exception
    +        raise_error_for_unhandled_request: bool = False,
    +        # Basic Information > Credentials > Signing Secret
    +        signing_secret: Optional[str] = None,
    +        # for single-workspace apps
    +        token: Optional[str] = None,
    +        client: Optional[AsyncWebClient] = None,
    +        # for multi-workspace apps
    +        authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None,
    +        installation_store: Optional[AsyncInstallationStore] = None,
    +        # for either only bot scope usage or v1.0.x compatibility
    +        installation_store_bot_only: Optional[bool] = None,
    +        # for customizing the built-in middleware
    +        request_verification_enabled: bool = True,
    +        ignoring_self_events_enabled: bool = True,
    +        ssl_check_enabled: bool = True,
    +        url_verification_enabled: bool = True,
    +        # for the OAuth flow
    +        oauth_settings: Optional[AsyncOAuthSettings] = None,
    +        oauth_flow: Optional[AsyncOAuthFlow] = None,
    +        # No need to set (the value is used only in response to ssl_check requests)
    +        verification_token: Optional[str] = None,
    +    ):
    +        """Bolt App that provides functionalities to register middleware/listeners.
    +
    +            import os
    +            from slack_bolt.async_app import AsyncApp
    +
    +            # Initializes your app with your bot token and signing secret
    +            app = AsyncApp(
    +                token=os.environ.get("SLACK_BOT_TOKEN"),
    +                signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
    +            )
    +
    +            # Listens to incoming messages that contain "hello"
    +            @app.message("hello")
    +            async def message_hello(message, say):  # async function
    +                # say() sends a message to the channel where the event was triggered
    +                await say(f"Hey there <@{message['user']}>!")
    +
    +            # Start your app
    +            if __name__ == "__main__":
    +                app.start(port=int(os.environ.get("PORT", 3000)))
    +
    +        Refer to https://slack.dev/bolt-python/concepts#async for details.
    +
    +        If you would like to build an OAuth app for enabling the app to run with multiple workspaces,
    +        refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app.
    +
    +        Args:
    +            logger: The custom logger that can be used in this app.
    +            name: The application name that will be used in logging. If absent, the source file name will be used.
    +            process_before_response: True if this app runs on Function as a Service. (Default: False)
    +            raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests
    +                and use @app.error listeners instead of
    +                the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
    +            signing_secret: The Signing Secret value used for verifying requests from Slack.
    +            token: The bot/user access token required only for single-workspace app.
    +            client: The singleton `slack_sdk.web.async_client.AsyncWebClient` instance for this app.
    +            authorize: The function to authorize an incoming request from Slack
    +                by checking if there is a team/user in the installation data.
    +            installation_store: The module offering save/find operations of installation data
    +            installation_store_bot_only: Use `AsyncInstallationStore#async_find_bot()` if True (Default: False)
    +            request_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
    +                `AsyncRequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests.
    +                Make sure if it's safe enough when you turn a built-in middleware off.
    +                We strongly recommend using RequestVerification for better security.
    +                If you have a proxy that verifies request signature in front of the Bolt app,
    +                it's totally fine to disable RequestVerification to avoid duplication of work.
    +                Don't turn it off just for easiness of development.
    +            ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True).
    +                `AsyncIgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events
    +                generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
    +            url_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
    +                `AsyncUrlVerification` is a built-in middleware that handles url_verification requests
    +                that verify the endpoint for Events API in HTTP Mode requests.
    +            ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True).
    +                `AsyncSslCheck` is a built-in middleware that handles ssl_check requests from Slack.
    +            oauth_settings: The settings related to Slack app installation flow (OAuth flow)
    +            oauth_flow: Instantiated `slack_bolt.oauth.AsyncOAuthFlow`. This is always prioritized over oauth_settings.
    +            verification_token: Deprecated verification mechanism. This can used only for ssl_check requests.
    +        """
    +        signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", "")
    +        token = token or os.environ.get("SLACK_BOT_TOKEN")
    +
    +        self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1]
    +        self._signing_secret: str = signing_secret
    +        self._verification_token: Optional[str] = verification_token or os.environ.get(
    +            "SLACK_VERIFICATION_TOKEN", None
    +        )
    +        # If a logger is explicitly passed when initializing, the logger works as the base logger.
    +        # The base logger's logging settings will be propagated to all the loggers created by bolt-python.
    +        self._base_logger = logger
    +        # The framework logger is supposed to be used for the internal logging.
    +        # Also, it's accessible via `app.logger` as the app's singleton logger.
    +        self._framework_logger = logger or get_bolt_logger(AsyncApp)
    +        self._raise_error_for_unhandled_request = raise_error_for_unhandled_request
    +
    +        self._token: Optional[str] = token
    +
    +        if client is not None:
    +            if not isinstance(client, AsyncWebClient):
    +                raise BoltError(error_client_invalid_type_async())
    +            self._async_client = client
    +            self._token = client.token
    +            if token is not None:
    +                self._framework_logger.warning(
    +                    warning_client_prioritized_and_token_skipped()
    +                )
    +        else:
    +            self._async_client = create_async_web_client(
    +                # NOTE: the token here can be None
    +                token=token,
    +                logger=self._framework_logger,
    +            )
    +
    +        # --------------------------------------
    +        # Authorize & OAuthFlow initialization
    +        # --------------------------------------
    +
    +        self._async_authorize: Optional[AsyncAuthorize] = None
    +        if authorize is not None:
    +            if oauth_settings is not None or oauth_flow is not None:
    +                raise BoltError(error_authorize_conflicts())
    +
    +            self._async_authorize = AsyncCallableAuthorize(
    +                logger=self._framework_logger, func=authorize
    +            )
    +
    +        self._async_installation_store: Optional[
    +            AsyncInstallationStore
    +        ] = installation_store
    +        if self._async_installation_store is not None and self._async_authorize is None:
    +            settings = oauth_flow.settings if oauth_flow is not None else oauth_settings
    +            self._async_authorize = AsyncInstallationStoreAuthorize(
    +                installation_store=self._async_installation_store,
    +                client_id=settings.client_id if settings is not None else None,
    +                client_secret=settings.client_secret if settings is not None else None,
    +                logger=self._framework_logger,
    +                bot_only=installation_store_bot_only,
    +                client=self._async_client,  # for proxy use cases etc.
    +            )
    +
    +        self._async_oauth_flow: Optional[AsyncOAuthFlow] = None
    +
    +        if (
    +            oauth_settings is None
    +            and os.environ.get("SLACK_CLIENT_ID") is not None
    +            and os.environ.get("SLACK_CLIENT_SECRET") is not None
    +        ):
    +            # initialize with the default settings
    +            oauth_settings = AsyncOAuthSettings()
    +
    +            if oauth_flow is None and installation_store is None:
    +                # show info-level log for avoiding confusions
    +                self._framework_logger.info(info_default_oauth_settings_loaded())
    +
    +        if oauth_flow:
    +            if not isinstance(oauth_flow, AsyncOAuthFlow):
    +                raise BoltError(error_oauth_flow_invalid_type_async())
    +
    +            self._async_oauth_flow = oauth_flow
    +            installation_store = select_consistent_installation_store(
    +                client_id=self._async_oauth_flow.client_id,
    +                app_store=self._async_installation_store,
    +                oauth_flow_store=self._async_oauth_flow.settings.installation_store,
    +                logger=self._framework_logger,
    +            )
    +            self._async_installation_store = installation_store
    +            self._async_oauth_flow.settings.installation_store = installation_store
    +
    +            if self._async_oauth_flow._async_client is None:
    +                self._async_oauth_flow._async_client = self._async_client
    +            if self._async_authorize is None:
    +                self._async_authorize = self._async_oauth_flow.settings.authorize
    +        elif oauth_settings is not None:
    +            if not isinstance(oauth_settings, AsyncOAuthSettings):
    +                raise BoltError(error_oauth_settings_invalid_type_async())
    +
    +            installation_store = select_consistent_installation_store(
    +                client_id=oauth_settings.client_id,
    +                app_store=self._async_installation_store,
    +                oauth_flow_store=oauth_settings.installation_store,
    +                logger=self._framework_logger,
    +            )
    +            self._async_installation_store = installation_store
    +            oauth_settings.installation_store = installation_store
    +
    +            self._async_oauth_flow = AsyncOAuthFlow(
    +                client=self._async_client, logger=self.logger, settings=oauth_settings
    +            )
    +            if self._async_authorize is None:
    +                self._async_authorize = self._async_oauth_flow.settings.authorize
    +            self._async_authorize.token_rotation_expiration_minutes = (
    +                oauth_settings.token_rotation_expiration_minutes
    +            )
    +
    +        if (
    +            self._async_installation_store is not None
    +            or self._async_authorize is not None
    +        ) and self._token is not None:
    +            self._token = None
    +            self._framework_logger.warning(warning_token_skipped())
    +
    +        # after setting bot_only here, __init__ cannot replace authorize function
    +        if (
    +            installation_store_bot_only is not None
    +            and self._async_oauth_flow is not None
    +        ):
    +            app_bot_only = installation_store_bot_only or False
    +            oauth_flow_bot_only = (
    +                self._async_oauth_flow.settings.installation_store_bot_only
    +            )
    +            if app_bot_only != oauth_flow_bot_only:
    +                self.logger.warning(warning_bot_only_conflicts())
    +                self._async_oauth_flow.settings.installation_store_bot_only = (
    +                    app_bot_only
    +                )
    +                self._async_authorize.bot_only = app_bot_only
    +
    +        self._async_tokens_revocation_listeners: Optional[
    +            AsyncTokenRevocationListeners
    +        ] = None
    +        if self._async_installation_store is not None:
    +            self._async_tokens_revocation_listeners = AsyncTokenRevocationListeners(
    +                self._async_installation_store
    +            )
    +
    +        # --------------------------------------
    +        # Middleware Initialization
    +        # --------------------------------------
    +
    +        self._async_middleware_list: List[AsyncMiddleware] = []
    +        self._async_listeners: List[AsyncListener] = []
    +
    +        self._process_before_response = process_before_response
    +        self._async_listener_runner = AsyncioListenerRunner(
    +            logger=self._framework_logger,
    +            process_before_response=process_before_response,
    +            listener_error_handler=AsyncDefaultListenerErrorHandler(
    +                logger=self._framework_logger
    +            ),
    +            listener_start_handler=AsyncDefaultListenerStartHandler(
    +                logger=self._framework_logger
    +            ),
    +            listener_completion_handler=AsyncDefaultListenerCompletionHandler(
    +                logger=self._framework_logger
    +            ),
    +            lazy_listener_runner=AsyncioLazyListenerRunner(
    +                logger=self._framework_logger,
    +            ),
    +        )
    +        self._async_middleware_error_handler = AsyncDefaultMiddlewareErrorHandler(
    +            logger=self._framework_logger,
    +        )
    +
    +        self._init_middleware_list_done = False
    +        self._init_async_middleware_list(
    +            request_verification_enabled=request_verification_enabled,
    +            ignoring_self_events_enabled=ignoring_self_events_enabled,
    +            ssl_check_enabled=ssl_check_enabled,
    +            url_verification_enabled=url_verification_enabled,
    +        )
    +
    +        self._server: Optional[AsyncSlackAppServer] = None
    +
    +    def _init_async_middleware_list(
    +        self,
    +        request_verification_enabled: bool = True,
    +        ignoring_self_events_enabled: bool = True,
    +        ssl_check_enabled: bool = True,
    +        url_verification_enabled: bool = True,
    +    ):
    +        if self._init_middleware_list_done:
    +            return
    +        if ssl_check_enabled is True:
    +            self._async_middleware_list.append(
    +                AsyncSslCheck(
    +                    verification_token=self._verification_token,
    +                    base_logger=self._base_logger,
    +                )
    +            )
    +        if request_verification_enabled is True:
    +            self._async_middleware_list.append(
    +                AsyncRequestVerification(
    +                    self._signing_secret, base_logger=self._base_logger
    +                )
    +            )
    +        # As authorize is required for making a Bolt app function, we don't offer the flag to disable this
    +        if self._async_oauth_flow is None:
    +            if self._token:
    +                self._async_middleware_list.append(
    +                    AsyncSingleTeamAuthorization(base_logger=self._base_logger)
    +                )
    +            elif self._async_authorize is not None:
    +                self._async_middleware_list.append(
    +                    AsyncMultiTeamsAuthorization(
    +                        authorize=self._async_authorize, base_logger=self._base_logger
    +                    )
    +                )
    +            else:
    +                raise BoltError(error_token_required())
    +        else:
    +            self._async_middleware_list.append(
    +                AsyncMultiTeamsAuthorization(
    +                    authorize=self._async_authorize, base_logger=self._base_logger
    +                )
    +            )
    +
    +        if ignoring_self_events_enabled is True:
    +            self._async_middleware_list.append(
    +                AsyncIgnoringSelfEvents(base_logger=self._base_logger)
    +            )
    +        if url_verification_enabled is True:
    +            self._async_middleware_list.append(
    +                AsyncUrlVerification(base_logger=self._base_logger)
    +            )
    +        self._init_middleware_list_done = True
    +
    +    # -------------------------
    +    # accessors
    +
    +    @property
    +    def name(self) -> str:
    +        """The name of this app (default: the filename)"""
    +        return self._name
    +
    +    @property
    +    def oauth_flow(self) -> Optional[AsyncOAuthFlow]:
    +        """Configured `OAuthFlow` object if exists."""
    +        return self._async_oauth_flow
    +
    +    @property
    +    def client(self) -> AsyncWebClient:
    +        """The singleton `slack_sdk.web.async_client.AsyncWebClient` instance in this app."""
    +        return self._async_client
    +
    +    @property
    +    def logger(self) -> logging.Logger:
    +        """The logger this app uses."""
    +        return self._framework_logger
    +
    +    @property
    +    def installation_store(self) -> Optional[AsyncInstallationStore]:
    +        """The `slack_sdk.oauth.AsyncInstallationStore` that can be used in the `authorize` middleware."""
    +        return self._async_installation_store
    +
    +    @property
    +    def listener_runner(self) -> AsyncioListenerRunner:
    +        """The asyncio-based executor for asynchronously running listeners."""
    +        return self._async_listener_runner
    +
    +    @property
    +    def process_before_response(self) -> bool:
    +        return self._process_before_response or False
    +
    +    # -------------------------
    +    # standalone server
    +
    +    from .async_server import AsyncSlackAppServer
    +
    +    def server(
    +        self,
    +        port: int = 3000,
    +        path: str = "/slack/events",
    +        host: Optional[str] = None,
    +    ) -> AsyncSlackAppServer:
    +        """Configure a web server using AIOHTTP.
    +        Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
    +
    +        Args:
    +            port: The port to listen on (Default: 3000)
    +            path: The path to handle request from Slack (Default: `/slack/events`)
    +            host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
    +        """
    +        if (
    +            self._server is None
    +            or self._server.port != port
    +            or self._server.path != path
    +        ):
    +            self._server = AsyncSlackAppServer(
    +                port=port,
    +                path=path,
    +                app=self,
    +                host=host,
    +            )
    +        return self._server
    +
    +    def web_app(self, path: str = "/slack/events") -> web.Application:
    +        """Returns a `web.Application` instance for aiohttp-devtools users.
    +
    +            from slack_bolt.async_app import AsyncApp
    +            app = AsyncApp()
    +
    +            @app.event("app_mention")
    +            async def event_test(body, say, logger):
    +                logger.info(body)
    +                await say("What's up?")
    +
    +            def app_factory():
    +                return app.web_app()
    +
    +            # adev runserver --port 3000 --app-factory app_factory async_app.py
    +
    +        Args:
    +            path: The path to receive incoming requests from Slack
    +        """
    +        return self.server(path=path).web_app
    +
    +    def start(
    +        self, port: int = 3000, path: str = "/slack/events", host: Optional[str] = None
    +    ) -> None:
    +        """Start a web server using AIOHTTP.
    +        Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
    +
    +        Args:
    +            port: The port to listen on (Default: 3000)
    +            path: The path to handle request from Slack (Default: `/slack/events`)
    +            host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
    +        """
    +        self.server(port=port, path=path, host=host).start()
    +
    +    # -------------------------
    +    # main dispatcher
    +
    +    async def async_dispatch(self, req: AsyncBoltRequest) -> BoltResponse:
    +        """Applies all middleware and dispatches an incoming request from Slack to the right code path.
    +
    +        Args:
    +            req: An incoming request from Slack.
    +
    +        Returns:
    +            The response generated by this Bolt app.
    +        """
    +        starting_time = time.time()
    +        self._init_context(req)
    +
    +        resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
    +        middleware_state = {"next_called": False}
    +
    +        async def async_middleware_next():
    +            middleware_state["next_called"] = True
    +
    +        try:
    +            for middleware in self._async_middleware_list:
    +                middleware_state["next_called"] = False
    +                if self._framework_logger.level <= logging.DEBUG:
    +                    self._framework_logger.debug(f"Applying {middleware.name}")
    +                resp = await middleware.async_process(
    +                    req=req, resp=resp, next=async_middleware_next
    +                )
    +                if not middleware_state["next_called"]:
    +                    if resp is None:
    +                        # next() method was not called without providing the response to return to Slack
    +                        # This should not be an intentional handling in usual use cases.
    +                        resp = BoltResponse(
    +                            status=404, body={"error": "no next() calls in middleware"}
    +                        )
    +                        if self._raise_error_for_unhandled_request is True:
    +                            await self._async_listener_runner.listener_error_handler.handle(
    +                                error=BoltUnhandledRequestError(
    +                                    request=req,
    +                                    current_response=resp,
    +                                    last_global_middleware_name=middleware.name,
    +                                ),
    +                                request=req,
    +                                response=resp,
    +                            )
    +                            return resp
    +                        self._framework_logger.warning(
    +                            warning_unhandled_by_global_middleware(middleware.name, req)
    +                        )
    +                        return resp
    +                    return resp
    +
    +            for listener in self._async_listeners:
    +                listener_name = get_name_for_callable(listener.ack_function)
    +                self._framework_logger.debug(debug_checking_listener(listener_name))
    +                if await listener.async_matches(req=req, resp=resp):
    +                    # run all the middleware attached to this listener first
    +                    (
    +                        middleware_resp,
    +                        next_was_not_called,
    +                    ) = await listener.run_async_middleware(req=req, resp=resp)
    +                    if next_was_not_called:
    +                        if middleware_resp is not None:
    +                            if self._framework_logger.level <= logging.DEBUG:
    +                                debug_message = (
    +                                    debug_return_listener_middleware_response(
    +                                        listener_name,
    +                                        middleware_resp.status,
    +                                        middleware_resp.body,
    +                                        starting_time,
    +                                    )
    +                                )
    +                                self._framework_logger.debug(debug_message)
    +                            return middleware_resp
    +                        # The last listener middleware didn't call next() method.
    +                        # This means the listener is not for this incoming request.
    +                        continue
    +
    +                    if middleware_resp is not None:
    +                        resp = middleware_resp
    +
    +                    self._framework_logger.debug(debug_running_listener(listener_name))
    +                    listener_response: Optional[
    +                        BoltResponse
    +                    ] = await self._async_listener_runner.run(
    +                        request=req,
    +                        response=resp,
    +                        listener_name=listener_name,
    +                        listener=listener,
    +                    )
    +                    if listener_response is not None:
    +                        return listener_response
    +
    +            if resp is None:
    +                resp = BoltResponse(status=404, body={"error": "unhandled request"})
    +            if self._raise_error_for_unhandled_request is True:
    +                await self._async_listener_runner.listener_error_handler.handle(
    +                    error=BoltUnhandledRequestError(
    +                        request=req,
    +                        current_response=resp,
    +                    ),
    +                    request=req,
    +                    response=resp,
    +                )
    +                return resp
    +            return self._handle_unmatched_requests(req, resp)
    +
    +        except Exception as error:
    +            resp = BoltResponse(status=500, body="")
    +            await self._async_middleware_error_handler.handle(
    +                error=error,
    +                request=req,
    +                response=resp,
    +            )
    +            return resp
    +
    +    def _handle_unmatched_requests(
    +        self, req: AsyncBoltRequest, resp: BoltResponse
    +    ) -> BoltResponse:
    +        self._framework_logger.warning(warning_unhandled_request(req))
    +        return resp
    +
    +    # -------------------------
    +    # middleware
    +
    +    def use(self, *args) -> Optional[Callable]:
    +        """Refer to `AsyncApp#middleware()` method's docstring for details."""
    +        return self.middleware(*args)
    +
    +    def middleware(self, *args) -> Optional[Callable]:
    +        """Registers a new middleware to this app.
    +        This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.middleware
    +            async def middleware_func(logger, body, next):
    +                logger.info(f"request body: {body}")
    +                await next()
    +
    +            # Pass a function to this method
    +            app.middleware(middleware_func)
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +        Args:
    +            *args: A function that works as a global middleware.
    +        """
    +        if len(args) > 0:
    +            middleware_or_callable = args[0]
    +            if isinstance(middleware_or_callable, AsyncMiddleware):
    +                middleware: AsyncMiddleware = middleware_or_callable
    +                self._async_middleware_list.append(middleware)
    +            elif isinstance(middleware_or_callable, Callable):
    +                self._async_middleware_list.append(
    +                    AsyncCustomMiddleware(
    +                        app_name=self.name,
    +                        func=middleware_or_callable,
    +                        base_logger=self._base_logger,
    +                    )
    +                )
    +                return middleware_or_callable
    +            else:
    +                raise BoltError(
    +                    f"Unexpected type for a middleware ({type(middleware_or_callable)})"
    +                )
    +        return None
    +
    +    # -------------------------
    +    # Workflows: Steps from Apps
    +
    +    def step(
    +        self,
    +        callback_id: Union[str, Pattern, AsyncWorkflowStep, AsyncWorkflowStepBuilder],
    +        edit: Optional[
    +            Union[
    +                Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]
    +            ]
    +        ] = None,
    +        save: Optional[
    +            Union[
    +                Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]
    +            ]
    +        ] = None,
    +        execute: Optional[
    +            Union[
    +                Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]
    +            ]
    +        ] = None,
    +    ):
    +        """
    +        Registers a new Workflow Step listener.
    +        Unlike others, this method doesn't behave as a decorator.
    +        If you want to register a workflow step by a decorator, use `AsyncWorkflowStepBuilder`'s methods.
    +
    +            # Create a new WorkflowStep instance
    +            from slack_bolt.workflows.async_step import AsyncWorkflowStep
    +            ws = AsyncWorkflowStep(
    +                callback_id="add_task",
    +                edit=edit,
    +                save=save,
    +                execute=execute,
    +            )
    +            # Pass Step to set up listeners
    +            app.step(ws)
    +
    +        Refer to https://api.slack.com/workflows/steps for details of Steps from Apps.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +        For further information about AsyncWorkflowStep specific function arguments
    +        such as `configure`, `update`, `complete`, and `fail`,
    +        refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents.
    +
    +        Args:
    +            callback_id: The Callback ID for this workflow step
    +            edit: The function for displaying a modal in the Workflow Builder
    +            save: The function for handling configuration in the Workflow Builder
    +            execute: The function for handling the step execution
    +        """
    +        step = callback_id
    +        if isinstance(callback_id, (str, Pattern)):
    +            step = AsyncWorkflowStep(
    +                callback_id=callback_id,
    +                edit=edit,
    +                save=save,
    +                execute=execute,
    +                base_logger=self._base_logger,
    +            )
    +        elif isinstance(step, AsyncWorkflowStepBuilder):
    +            step = step.build(base_logger=self._base_logger)
    +        elif not isinstance(step, AsyncWorkflowStep):
    +            raise BoltError(f"Invalid step object ({type(step)})")
    +
    +        self.use(AsyncWorkflowStepMiddleware(step, self._async_listener_runner))
    +
    +    # -------------------------
    +    # global error handler
    +
    +    def error(
    +        self, func: Callable[..., Awaitable[Optional[BoltResponse]]]
    +    ) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
    +        """Updates the global error handler. This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.error
    +            async def custom_error_handler(error, body, logger):
    +                logger.exception(f"Error: {error}")
    +                logger.info(f"Request body: {body}")
    +
    +            # Pass a function to this method
    +            app.error(custom_error_handler)
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +        Args:
    +            func: The function that is supposed to be executed
    +                when getting an unhandled error in Bolt app.
    +        """
    +        if not inspect.iscoroutinefunction(func):
    +            name = get_name_for_callable(func)
    +            raise BoltError(error_listener_function_must_be_coro_func(name))
    +        self._async_listener_runner.listener_error_handler = (
    +            AsyncCustomListenerErrorHandler(
    +                logger=self._framework_logger,
    +                func=func,
    +            )
    +        )
    +        self._async_middleware_error_handler = AsyncCustomMiddlewareErrorHandler(
    +            logger=self._framework_logger,
    +            func=func,
    +        )
    +        return func
    +
    +    # -------------------------
    +    # events
    +
    +    def event(
    +        self,
    +        event: Union[
    +            str,
    +            Pattern,
    +            Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
    +        ],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new event listener. This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.event("team_join")
    +            async def ask_for_introduction(event, say):
    +                welcome_channel_id = "C12345"
    +                user_id = event["user"]
    +                text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
    +                await say(text=text, channel=welcome_channel_id)
    +
    +            # Pass a function to this method
    +            app.event("team_join")(ask_for_introduction)
    +
    +        Refer to https://api.slack.com/apis/connections/events-api for details of Events API.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +        Args:
    +            event: The conditions that match a request payload.
    +                If you pass a dict for this, you can have type, subtype in the constraint.
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.event(
    +                event, True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware, True
    +            )
    +
    +        return __call__
    +
    +    def message(
    +        self,
    +        keyword: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new message event listener. This method can be used as either a decorator or a method.
    +        Check the `App#event` method's docstring for details.
    +
    +            # Use this method as a decorator
    +            @app.message(":wave:")
    +            async def say_hello(message, say):
    +                user = message['user']
    +                await say(f"Hi there, <@{user}>!")
    +
    +            # Pass a function to this method
    +            app.message(":wave:")(say_hello)
    +
    +        Refer to https://api.slack.com/events/message for details of `message` events.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +        Args:
    +            keyword: The keyword to match
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +        matchers = list(matchers) if matchers else []
    +        middleware = list(middleware) if middleware else []
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            constraints = {
    +                "type": "message",
    +                "subtype": (
    +                    # In most cases, new message events come with no subtype.
    +                    None,
    +                    # As of Jan 2021, most bot messages no longer have the subtype bot_message.
    +                    # By contrast, messages posted using classic app's bot token still have the subtype.
    +                    "bot_message",
    +                    # If an end-user posts a message with "Also send to #channel" checked,
    +                    # the message event comes with this subtype.
    +                    "thread_broadcast",
    +                    # If an end-user posts a message with attached files,
    +                    # the message event comes with this subtype.
    +                    "file_share",
    +                ),
    +            }
    +            primary_matcher = builtin_matchers.message_event(
    +                constraints=constraints,
    +                keyword=keyword,
    +                asyncio=True,
    +                base_logger=self._base_logger,
    +            )
    +            middleware.insert(0, AsyncMessageListenerMatches(keyword))
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware, True
    +            )
    +
    +        return __call__
    +
    +    # -------------------------
    +    # slash commands
    +
    +    def command(
    +        self,
    +        command: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new slash command listener.
    +        This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.command("/echo")
    +            async def repeat_text(ack, say, command):
    +                # Acknowledge command request
    +                await ack()
    +                await say(f"{command['text']}")
    +
    +            # Pass a function to this method
    +            app.command("/echo")(repeat_text)
    +
    +        Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +        Args:
    +            command: The conditions that match a request payload
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.command(
    +                command, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    # -------------------------
    +    # shortcut
    +
    +    def shortcut(
    +        self,
    +        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new shortcut listener.
    +        This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.shortcut("open_modal")
    +            async def open_modal(ack, body, client):
    +                # Acknowledge the command request
    +                await ack()
    +                # Call views_open with the built-in client
    +                await client.views_open(
    +                    # Pass a valid trigger_id within 3 seconds of receiving it
    +                    trigger_id=body["trigger_id"],
    +                    # View payload
    +                    view={ ... }
    +                )
    +
    +            # Pass a function to this method
    +            app.shortcut("open_modal")(open_modal)
    +
    +        Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +        Args:
    +            constraints: The conditions that match a request payload.
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.shortcut(
    +                constraints, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def global_shortcut(
    +        self,
    +        callback_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new global shortcut listener."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.global_shortcut(
    +                callback_id, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def message_shortcut(
    +        self,
    +        callback_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new message shortcut listener."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.message_shortcut(
    +                callback_id, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    # -------------------------
    +    # action
    +
    +    def action(
    +        self,
    +        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new action listener. This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.action("approve_button")
    +            async def update_message(ack):
    +                await ack()
    +
    +            # Pass a function to this method
    +            app.action("approve_button")(update_message)
    +
    +        * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`.
    +        * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`.
    +        * Refer to https://api.slack.com/dialogs for actions in dialogs.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +        Args:
    +            constraints: The conditions that match a request payload
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.action(
    +                constraints, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def block_action(
    +        self,
    +        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new `block_actions` action listener.
    +        Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.block_action(
    +                constraints, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def attachment_action(
    +        self,
    +        callback_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new `interactive_message` action listener.
    +        Refer to https://api.slack.com/legacy/message-buttons for details."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.attachment_action(
    +                callback_id, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def dialog_submission(
    +        self,
    +        callback_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new `dialog_submission` listener.
    +        Refer to https://api.slack.com/dialogs for details."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.dialog_submission(
    +                callback_id, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def dialog_cancellation(
    +        self,
    +        callback_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new `dialog_submission` listener.
    +        Refer to https://api.slack.com/dialogs for details."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.dialog_cancellation(
    +                callback_id, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    # -------------------------
    +    # view
    +
    +    def view(
    +        self,
    +        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new `view_submission`/`view_closed` event listener.
    +        This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.view("view_1")
    +            async def handle_submission(ack, body, client, view):
    +                # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
    +                hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
    +                user = body["user"]["id"]
    +                # Validate the inputs
    +                errors = {}
    +                if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
    +                    errors["block_c"] = "The value must be longer than 5 characters"
    +                if len(errors) > 0:
    +                    await ack(response_action="errors", errors=errors)
    +                    return
    +                # Acknowledge the view_submission event and close the modal
    +                await ack()
    +                # Do whatever you want with the input data - here we're saving it to a DB
    +
    +            # Pass a function to this method
    +            app.view("view_1")(handle_submission)
    +
    +        Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +        Args:
    +            constraints: The conditions that match a request payload
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.view(
    +                constraints, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def view_submission(
    +        self,
    +        constraints: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new `view_submission` listener.
    +        Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.view_submission(
    +                constraints, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def view_closed(
    +        self,
    +        constraints: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new `view_closed` listener.
    +        Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.view_closed(
    +                constraints, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    # -------------------------
    +    # options
    +
    +    def options(
    +        self,
    +        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new options listener.
    +        This method can be used as either a decorator or a method.
    +
    +            # Use this method as a decorator
    +            @app.options("menu_selection")
    +            async def show_menu_options(ack):
    +                options = [
    +                    {
    +                        "text": {"type": "plain_text", "text": "Option 1"},
    +                        "value": "1-1",
    +                    },
    +                    {
    +                        "text": {"type": "plain_text", "text": "Option 2"},
    +                        "value": "1-2",
    +                    },
    +                ]
    +                await ack(options=options)
    +
    +            # Pass a function to this method
    +            app.options("menu_selection")(show_menu_options)
    +
    +        Refer to the following documents for details:
    +
    +        * https://api.slack.com/reference/block-kit/block-elements#external_select
    +        * https://api.slack.com/reference/block-kit/block-elements#external_multi_select
    +
    +        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +        Args:
    +            matchers: A list of listener matcher functions.
    +                Only when all the matchers return True, the listener function can be invoked.
    +            middleware: A list of lister middleware functions.
    +                Only when all the middleware call `next()` method, the listener function can be invoked.
    +        """
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.options(
    +                constraints, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def block_suggestion(
    +        self,
    +        action_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new `block_suggestion` listener."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.block_suggestion(
    +                action_id, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    def dialog_suggestion(
    +        self,
    +        callback_id: Union[str, Pattern],
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        """Registers a new `dialog_suggestion` listener.
    +        Refer to https://api.slack.com/dialogs for details."""
    +
    +        def __call__(*args, **kwargs):
    +            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +            primary_matcher = builtin_matchers.dialog_suggestion(
    +                callback_id, asyncio=True, base_logger=self._base_logger
    +            )
    +            return self._register_listener(
    +                list(functions), primary_matcher, matchers, middleware
    +            )
    +
    +        return __call__
    +
    +    # -------------------------
    +    # built-in listener functions
    +
    +    def default_tokens_revoked_event_listener(
    +        self,
    +    ) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
    +        if self._async_tokens_revocation_listeners is None:
    +            raise BoltError(error_installation_store_required_for_builtin_listeners())
    +        return self._async_tokens_revocation_listeners.handle_tokens_revoked_events
    +
    +    def default_app_uninstalled_event_listener(
    +        self,
    +    ) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
    +        if self._async_tokens_revocation_listeners is None:
    +            raise BoltError(error_installation_store_required_for_builtin_listeners())
    +        return self._async_tokens_revocation_listeners.handle_app_uninstalled_events
    +
    +    def enable_token_revocation_listeners(self) -> None:
    +        self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
    +        self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
    +
    +    # -------------------------
    +
    +    def _init_context(self, req: AsyncBoltRequest):
    +        req.context["logger"] = get_bolt_app_logger(
    +            app_name=self.name, base_logger=self._base_logger
    +        )
    +        req.context["token"] = self._token
    +        if self._token is not None:
    +            # This AsyncWebClient instance can be safely singleton
    +            req.context["client"] = self._async_client
    +        else:
    +            # Set a new dedicated instance for this request
    +            client_per_request: AsyncWebClient = AsyncWebClient(
    +                token=None,  # the token will be set later
    +                base_url=self._async_client.base_url,
    +                timeout=self._async_client.timeout,
    +                ssl=self._async_client.ssl,
    +                proxy=self._async_client.proxy,
    +                session=self._async_client.session,
    +                trust_env_in_session=self._async_client.trust_env_in_session,
    +                headers=self._async_client.headers,
    +                team_id=req.context.team_id,
    +            )
    +            req.context["client"] = client_per_request
    +
    +    @staticmethod
    +    def _to_listener_functions(
    +        kwargs: dict,
    +    ) -> Optional[Sequence[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +        if kwargs:
    +            functions = [kwargs["ack"]]
    +            for sub in kwargs["lazy"]:
    +                functions.append(sub)
    +            return functions
    +        return None
    +
    +    def _register_listener(
    +        self,
    +        functions: Sequence[Callable[..., Awaitable[Optional[BoltResponse]]]],
    +        primary_matcher: AsyncListenerMatcher,
    +        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]],
    +        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]],
    +        auto_acknowledgement: bool = False,
    +    ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]:
    +        value_to_return = None
    +        if not isinstance(functions, list):
    +            functions = list(functions)
    +        if len(functions) == 1:
    +            # In the case where the function is registered using decorator,
    +            # the registration should return the original function.
    +            value_to_return = functions[0]
    +
    +        for func in functions:
    +            if not inspect.iscoroutinefunction(func):
    +                name = get_name_for_callable(func)
    +                raise BoltError(error_listener_function_must_be_coro_func(name))
    +
    +        listener_matchers = [
    +            AsyncCustomListenerMatcher(
    +                app_name=self.name, func=f, base_logger=self._base_logger
    +            )
    +            for f in (matchers or [])
    +        ]
    +        listener_matchers.insert(0, primary_matcher)
    +        listener_middleware = []
    +        for m in middleware or []:
    +            if isinstance(m, AsyncMiddleware):
    +                listener_middleware.append(m)
    +            elif isinstance(m, Callable) and inspect.iscoroutinefunction(m):
    +                listener_middleware.append(
    +                    AsyncCustomMiddleware(
    +                        app_name=self.name, func=m, base_logger=self._base_logger
    +                    )
    +                )
    +            else:
    +                raise ValueError(error_unexpected_listener_middleware(type(m)))
    +
    +        self._async_listeners.append(
    +            AsyncCustomListener(
    +                app_name=self.name,
    +                ack_function=functions.pop(0),
    +                lazy_functions=functions,
    +                matchers=listener_matchers,
    +                middleware=listener_middleware,
    +                auto_acknowledgement=auto_acknowledgement,
    +                base_logger=self._base_logger,
    +            )
    +        )
    +
    +        return value_to_return
    +
    +

    Class variables

    +
    +
    var AsyncSlackAppServer
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var client : slack_sdk.web.async_client.AsyncWebClient
    +
    +

    The singleton slack_sdk.web.async_client.AsyncWebClient instance in this app.

    +
    + +Expand source code + +
    @property
    +def client(self) -> AsyncWebClient:
    +    """The singleton `slack_sdk.web.async_client.AsyncWebClient` instance in this app."""
    +    return self._async_client
    +
    +
    +
    var installation_store : Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore]
    +
    +

    The slack_sdk.oauth.AsyncInstallationStore that can be used in the authorize middleware.

    +
    + +Expand source code + +
    @property
    +def installation_store(self) -> Optional[AsyncInstallationStore]:
    +    """The `slack_sdk.oauth.AsyncInstallationStore` that can be used in the `authorize` middleware."""
    +    return self._async_installation_store
    +
    +
    +
    var listener_runnerAsyncioListenerRunner
    +
    +

    The asyncio-based executor for asynchronously running listeners.

    +
    + +Expand source code + +
    @property
    +def listener_runner(self) -> AsyncioListenerRunner:
    +    """The asyncio-based executor for asynchronously running listeners."""
    +    return self._async_listener_runner
    +
    +
    +
    var logger : logging.Logger
    +
    +

    The logger this app uses.

    +
    + +Expand source code + +
    @property
    +def logger(self) -> logging.Logger:
    +    """The logger this app uses."""
    +    return self._framework_logger
    +
    +
    +
    var name : str
    +
    +

    The name of this app (default: the filename)

    +
    + +Expand source code + +
    @property
    +def name(self) -> str:
    +    """The name of this app (default: the filename)"""
    +    return self._name
    +
    +
    +
    var oauth_flow : Optional[AsyncOAuthFlow]
    +
    +

    Configured OAuthFlow object if exists.

    +
    + +Expand source code + +
    @property
    +def oauth_flow(self) -> Optional[AsyncOAuthFlow]:
    +    """Configured `OAuthFlow` object if exists."""
    +    return self._async_oauth_flow
    +
    +
    +
    var process_before_response : bool
    +
    +
    +
    + +Expand source code + +
    @property
    +def process_before_response(self) -> bool:
    +    return self._process_before_response or False
    +
    +
    +
    +

    Methods

    +
    +
    +def action(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new action listener. This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.action("approve_button")
    +async def update_message(ack):
    +    await ack()
    +
    +# Pass a function to this method
    +app.action("approve_button")(update_message)
    +
    + +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    +

    Args

    +
    +
    constraints
    +
    The conditions that match a request payload
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def action(
    +    self,
    +    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new action listener. This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.action("approve_button")
    +        async def update_message(ack):
    +            await ack()
    +
    +        # Pass a function to this method
    +        app.action("approve_button")(update_message)
    +
    +    * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`.
    +    * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`.
    +    * Refer to https://api.slack.com/dialogs for actions in dialogs.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +    Args:
    +        constraints: The conditions that match a request payload
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.action(
    +            constraints, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +async def async_dispatch(self, req: AsyncBoltRequest) ‑> BoltResponse +
    +
    +

    Applies all middleware and dispatches an incoming request from Slack to the right code path.

    +

    Args

    +
    +
    req
    +
    An incoming request from Slack.
    +
    +

    Returns

    +

    The response generated by this Bolt app.

    +
    + +Expand source code + +
    async def async_dispatch(self, req: AsyncBoltRequest) -> BoltResponse:
    +    """Applies all middleware and dispatches an incoming request from Slack to the right code path.
    +
    +    Args:
    +        req: An incoming request from Slack.
    +
    +    Returns:
    +        The response generated by this Bolt app.
    +    """
    +    starting_time = time.time()
    +    self._init_context(req)
    +
    +    resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
    +    middleware_state = {"next_called": False}
    +
    +    async def async_middleware_next():
    +        middleware_state["next_called"] = True
    +
    +    try:
    +        for middleware in self._async_middleware_list:
    +            middleware_state["next_called"] = False
    +            if self._framework_logger.level <= logging.DEBUG:
    +                self._framework_logger.debug(f"Applying {middleware.name}")
    +            resp = await middleware.async_process(
    +                req=req, resp=resp, next=async_middleware_next
    +            )
    +            if not middleware_state["next_called"]:
    +                if resp is None:
    +                    # next() method was not called without providing the response to return to Slack
    +                    # This should not be an intentional handling in usual use cases.
    +                    resp = BoltResponse(
    +                        status=404, body={"error": "no next() calls in middleware"}
    +                    )
    +                    if self._raise_error_for_unhandled_request is True:
    +                        await self._async_listener_runner.listener_error_handler.handle(
    +                            error=BoltUnhandledRequestError(
    +                                request=req,
    +                                current_response=resp,
    +                                last_global_middleware_name=middleware.name,
    +                            ),
    +                            request=req,
    +                            response=resp,
    +                        )
    +                        return resp
    +                    self._framework_logger.warning(
    +                        warning_unhandled_by_global_middleware(middleware.name, req)
    +                    )
    +                    return resp
    +                return resp
    +
    +        for listener in self._async_listeners:
    +            listener_name = get_name_for_callable(listener.ack_function)
    +            self._framework_logger.debug(debug_checking_listener(listener_name))
    +            if await listener.async_matches(req=req, resp=resp):
    +                # run all the middleware attached to this listener first
    +                (
    +                    middleware_resp,
    +                    next_was_not_called,
    +                ) = await listener.run_async_middleware(req=req, resp=resp)
    +                if next_was_not_called:
    +                    if middleware_resp is not None:
    +                        if self._framework_logger.level <= logging.DEBUG:
    +                            debug_message = (
    +                                debug_return_listener_middleware_response(
    +                                    listener_name,
    +                                    middleware_resp.status,
    +                                    middleware_resp.body,
    +                                    starting_time,
    +                                )
    +                            )
    +                            self._framework_logger.debug(debug_message)
    +                        return middleware_resp
    +                    # The last listener middleware didn't call next() method.
    +                    # This means the listener is not for this incoming request.
    +                    continue
    +
    +                if middleware_resp is not None:
    +                    resp = middleware_resp
    +
    +                self._framework_logger.debug(debug_running_listener(listener_name))
    +                listener_response: Optional[
    +                    BoltResponse
    +                ] = await self._async_listener_runner.run(
    +                    request=req,
    +                    response=resp,
    +                    listener_name=listener_name,
    +                    listener=listener,
    +                )
    +                if listener_response is not None:
    +                    return listener_response
    +
    +        if resp is None:
    +            resp = BoltResponse(status=404, body={"error": "unhandled request"})
    +        if self._raise_error_for_unhandled_request is True:
    +            await self._async_listener_runner.listener_error_handler.handle(
    +                error=BoltUnhandledRequestError(
    +                    request=req,
    +                    current_response=resp,
    +                ),
    +                request=req,
    +                response=resp,
    +            )
    +            return resp
    +        return self._handle_unmatched_requests(req, resp)
    +
    +    except Exception as error:
    +        resp = BoltResponse(status=500, body="")
    +        await self._async_middleware_error_handler.handle(
    +            error=error,
    +            request=req,
    +            response=resp,
    +        )
    +        return resp
    +
    +
    +
    +def attachment_action(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new interactive_message action listener. +Refer to https://api.slack.com/legacy/message-buttons for details.

    +
    + +Expand source code + +
    def attachment_action(
    +    self,
    +    callback_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new `interactive_message` action listener.
    +    Refer to https://api.slack.com/legacy/message-buttons for details."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.attachment_action(
    +            callback_id, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def block_action(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new block_actions action listener. +Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.

    +
    + +Expand source code + +
    def block_action(
    +    self,
    +    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new `block_actions` action listener.
    +    Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.block_action(
    +            constraints, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def block_suggestion(self, action_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new block_suggestion listener.

    +
    + +Expand source code + +
    def block_suggestion(
    +    self,
    +    action_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new `block_suggestion` listener."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.block_suggestion(
    +            action_id, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def command(self, command: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new slash command listener. +This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.command("/echo")
    +async def repeat_text(ack, say, command):
    +    # Acknowledge command request
    +    await ack()
    +    await say(f"{command['text']}")
    +
    +# Pass a function to this method
    +app.command("/echo")(repeat_text)
    +
    +

    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.

    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    +

    Args

    +
    +
    command
    +
    The conditions that match a request payload
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def command(
    +    self,
    +    command: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new slash command listener.
    +    This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.command("/echo")
    +        async def repeat_text(ack, say, command):
    +            # Acknowledge command request
    +            await ack()
    +            await say(f"{command['text']}")
    +
    +        # Pass a function to this method
    +        app.command("/echo")(repeat_text)
    +
    +    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +    Args:
    +        command: The conditions that match a request payload
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.command(
    +            command, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def default_app_uninstalled_event_listener(self) ‑> Callable[..., Awaitable[Optional[BoltResponse]]] +
    +
    +
    +
    + +Expand source code + +
    def default_app_uninstalled_event_listener(
    +    self,
    +) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
    +    if self._async_tokens_revocation_listeners is None:
    +        raise BoltError(error_installation_store_required_for_builtin_listeners())
    +    return self._async_tokens_revocation_listeners.handle_app_uninstalled_events
    +
    +
    +
    +def default_tokens_revoked_event_listener(self) ‑> Callable[..., Awaitable[Optional[BoltResponse]]] +
    +
    +
    +
    + +Expand source code + +
    def default_tokens_revoked_event_listener(
    +    self,
    +) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
    +    if self._async_tokens_revocation_listeners is None:
    +        raise BoltError(error_installation_store_required_for_builtin_listeners())
    +    return self._async_tokens_revocation_listeners.handle_tokens_revoked_events
    +
    +
    +
    +def dialog_cancellation(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new dialog_submission listener. +Refer to https://api.slack.com/dialogs for details.

    +
    + +Expand source code + +
    def dialog_cancellation(
    +    self,
    +    callback_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new `dialog_submission` listener.
    +    Refer to https://api.slack.com/dialogs for details."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.dialog_cancellation(
    +            callback_id, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def dialog_submission(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new dialog_submission listener. +Refer to https://api.slack.com/dialogs for details.

    +
    + +Expand source code + +
    def dialog_submission(
    +    self,
    +    callback_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new `dialog_submission` listener.
    +    Refer to https://api.slack.com/dialogs for details."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.dialog_submission(
    +            callback_id, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def dialog_suggestion(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new dialog_suggestion listener. +Refer to https://api.slack.com/dialogs for details.

    +
    + +Expand source code + +
    def dialog_suggestion(
    +    self,
    +    callback_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new `dialog_suggestion` listener.
    +    Refer to https://api.slack.com/dialogs for details."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.dialog_suggestion(
    +            callback_id, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def enable_token_revocation_listeners(self) ‑> None +
    +
    +
    +
    + +Expand source code + +
    def enable_token_revocation_listeners(self) -> None:
    +    self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
    +    self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
    +
    +
    +
    +def error(self, func: Callable[..., Awaitable[Optional[BoltResponse]]]) ‑> Callable[..., Awaitable[Optional[BoltResponse]]] +
    +
    +

    Updates the global error handler. This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.error
    +async def custom_error_handler(error, body, logger):
    +    logger.exception(f"Error: {error}")
    +    logger.info(f"Request body: {body}")
    +
    +# Pass a function to this method
    +app.error(custom_error_handler)
    +
    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    +

    Args

    +
    +
    func
    +
    The function that is supposed to be executed +when getting an unhandled error in Bolt app.
    +
    +
    + +Expand source code + +
    def error(
    +    self, func: Callable[..., Awaitable[Optional[BoltResponse]]]
    +) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
    +    """Updates the global error handler. This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.error
    +        async def custom_error_handler(error, body, logger):
    +            logger.exception(f"Error: {error}")
    +            logger.info(f"Request body: {body}")
    +
    +        # Pass a function to this method
    +        app.error(custom_error_handler)
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +    Args:
    +        func: The function that is supposed to be executed
    +            when getting an unhandled error in Bolt app.
    +    """
    +    if not inspect.iscoroutinefunction(func):
    +        name = get_name_for_callable(func)
    +        raise BoltError(error_listener_function_must_be_coro_func(name))
    +    self._async_listener_runner.listener_error_handler = (
    +        AsyncCustomListenerErrorHandler(
    +            logger=self._framework_logger,
    +            func=func,
    +        )
    +    )
    +    self._async_middleware_error_handler = AsyncCustomMiddlewareErrorHandler(
    +        logger=self._framework_logger,
    +        func=func,
    +    )
    +    return func
    +
    +
    +
    +def event(self, event: Union[str, Pattern, Dict[str, Union[str, Sequence[Union[str, Pattern, ForwardRef(None)]], ForwardRef(None)]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new event listener. This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.event("team_join")
    +async def ask_for_introduction(event, say):
    +    welcome_channel_id = "C12345"
    +    user_id = event["user"]
    +    text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
    +    await say(text=text, channel=welcome_channel_id)
    +
    +# Pass a function to this method
    +app.event("team_join")(ask_for_introduction)
    +
    +

    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.

    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    +

    Args

    +
    +
    event
    +
    The conditions that match a request payload. +If you pass a dict for this, you can have type, subtype in the constraint.
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def event(
    +    self,
    +    event: Union[
    +        str,
    +        Pattern,
    +        Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
    +    ],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new event listener. This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.event("team_join")
    +        async def ask_for_introduction(event, say):
    +            welcome_channel_id = "C12345"
    +            user_id = event["user"]
    +            text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
    +            await say(text=text, channel=welcome_channel_id)
    +
    +        # Pass a function to this method
    +        app.event("team_join")(ask_for_introduction)
    +
    +    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +    Args:
    +        event: The conditions that match a request payload.
    +            If you pass a dict for this, you can have type, subtype in the constraint.
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.event(
    +            event, True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware, True
    +        )
    +
    +    return __call__
    +
    +
    +
    +def global_shortcut(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new global shortcut listener.

    +
    + +Expand source code + +
    def global_shortcut(
    +    self,
    +    callback_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new global shortcut listener."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.global_shortcut(
    +            callback_id, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def message(self, keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new message event listener. This method can be used as either a decorator or a method. +Check the App#event method's docstring for details.

    +
    # Use this method as a decorator
    +@app.message(":wave:")
    +async def say_hello(message, say):
    +    user = message['user']
    +    await say(f"Hi there, <@{user}>!")
    +
    +# Pass a function to this method
    +app.message(":wave:")(say_hello)
    +
    +

    Refer to https://api.slack.com/events/message for details of message events.

    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    +

    Args

    +
    +
    keyword
    +
    The keyword to match
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def message(
    +    self,
    +    keyword: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new message event listener. This method can be used as either a decorator or a method.
    +    Check the `App#event` method's docstring for details.
    +
    +        # Use this method as a decorator
    +        @app.message(":wave:")
    +        async def say_hello(message, say):
    +            user = message['user']
    +            await say(f"Hi there, <@{user}>!")
    +
    +        # Pass a function to this method
    +        app.message(":wave:")(say_hello)
    +
    +    Refer to https://api.slack.com/events/message for details of `message` events.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +    Args:
    +        keyword: The keyword to match
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +    matchers = list(matchers) if matchers else []
    +    middleware = list(middleware) if middleware else []
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        constraints = {
    +            "type": "message",
    +            "subtype": (
    +                # In most cases, new message events come with no subtype.
    +                None,
    +                # As of Jan 2021, most bot messages no longer have the subtype bot_message.
    +                # By contrast, messages posted using classic app's bot token still have the subtype.
    +                "bot_message",
    +                # If an end-user posts a message with "Also send to #channel" checked,
    +                # the message event comes with this subtype.
    +                "thread_broadcast",
    +                # If an end-user posts a message with attached files,
    +                # the message event comes with this subtype.
    +                "file_share",
    +            ),
    +        }
    +        primary_matcher = builtin_matchers.message_event(
    +            constraints=constraints,
    +            keyword=keyword,
    +            asyncio=True,
    +            base_logger=self._base_logger,
    +        )
    +        middleware.insert(0, AsyncMessageListenerMatches(keyword))
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware, True
    +        )
    +
    +    return __call__
    +
    +
    +
    +def message_shortcut(self, callback_id: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new message shortcut listener.

    +
    + +Expand source code + +
    def message_shortcut(
    +    self,
    +    callback_id: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new message shortcut listener."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.message_shortcut(
    +            callback_id, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def middleware(self, *args) ‑> Optional[Callable] +
    +
    +

    Registers a new middleware to this app. +This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.middleware
    +async def middleware_func(logger, body, next):
    +    logger.info(f"request body: {body}")
    +    await next()
    +
    +# Pass a function to this method
    +app.middleware(middleware_func)
    +
    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    +

    Args

    +
    +
    *args
    +
    A function that works as a global middleware.
    +
    +
    + +Expand source code + +
    def middleware(self, *args) -> Optional[Callable]:
    +    """Registers a new middleware to this app.
    +    This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.middleware
    +        async def middleware_func(logger, body, next):
    +            logger.info(f"request body: {body}")
    +            await next()
    +
    +        # Pass a function to this method
    +        app.middleware(middleware_func)
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +    Args:
    +        *args: A function that works as a global middleware.
    +    """
    +    if len(args) > 0:
    +        middleware_or_callable = args[0]
    +        if isinstance(middleware_or_callable, AsyncMiddleware):
    +            middleware: AsyncMiddleware = middleware_or_callable
    +            self._async_middleware_list.append(middleware)
    +        elif isinstance(middleware_or_callable, Callable):
    +            self._async_middleware_list.append(
    +                AsyncCustomMiddleware(
    +                    app_name=self.name,
    +                    func=middleware_or_callable,
    +                    base_logger=self._base_logger,
    +                )
    +            )
    +            return middleware_or_callable
    +        else:
    +            raise BoltError(
    +                f"Unexpected type for a middleware ({type(middleware_or_callable)})"
    +            )
    +    return None
    +
    +
    +
    +def options(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new options listener. +This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.options("menu_selection")
    +async def show_menu_options(ack):
    +    options = [
    +        {
    +            "text": {"type": "plain_text", "text": "Option 1"},
    +            "value": "1-1",
    +        },
    +        {
    +            "text": {"type": "plain_text", "text": "Option 2"},
    +            "value": "1-2",
    +        },
    +    ]
    +    await ack(options=options)
    +
    +# Pass a function to this method
    +app.options("menu_selection")(show_menu_options)
    +
    +

    Refer to the following documents for details:

    + +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    +

    Args

    +
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def options(
    +    self,
    +    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new options listener.
    +    This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.options("menu_selection")
    +        async def show_menu_options(ack):
    +            options = [
    +                {
    +                    "text": {"type": "plain_text", "text": "Option 1"},
    +                    "value": "1-1",
    +                },
    +                {
    +                    "text": {"type": "plain_text", "text": "Option 2"},
    +                    "value": "1-2",
    +                },
    +            ]
    +            await ack(options=options)
    +
    +        # Pass a function to this method
    +        app.options("menu_selection")(show_menu_options)
    +
    +    Refer to the following documents for details:
    +
    +    * https://api.slack.com/reference/block-kit/block-elements#external_select
    +    * https://api.slack.com/reference/block-kit/block-elements#external_multi_select
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +    Args:
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.options(
    +            constraints, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def server(self, port: int = 3000, path: str = '/slack/events', host: Optional[str] = None) ‑> AsyncSlackAppServer +
    +
    +

    Configure a web server using AIOHTTP. +Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.

    +

    Args

    +
    +
    port
    +
    The port to listen on (Default: 3000)
    +
    path
    +
    The path to handle request from Slack (Default: /slack/events)
    +
    host
    +
    The hostname to serve the web endpoints. (Default: 0.0.0.0)
    +
    +
    + +Expand source code + +
    def server(
    +    self,
    +    port: int = 3000,
    +    path: str = "/slack/events",
    +    host: Optional[str] = None,
    +) -> AsyncSlackAppServer:
    +    """Configure a web server using AIOHTTP.
    +    Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
    +
    +    Args:
    +        port: The port to listen on (Default: 3000)
    +        path: The path to handle request from Slack (Default: `/slack/events`)
    +        host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
    +    """
    +    if (
    +        self._server is None
    +        or self._server.port != port
    +        or self._server.path != path
    +    ):
    +        self._server = AsyncSlackAppServer(
    +            port=port,
    +            path=path,
    +            app=self,
    +            host=host,
    +        )
    +    return self._server
    +
    +
    +
    +def shortcut(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new shortcut listener. +This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.shortcut("open_modal")
    +async def open_modal(ack, body, client):
    +    # Acknowledge the command request
    +    await ack()
    +    # Call views_open with the built-in client
    +    await client.views_open(
    +        # Pass a valid trigger_id within 3 seconds of receiving it
    +        trigger_id=body["trigger_id"],
    +        # View payload
    +        view={ ... }
    +    )
    +
    +# Pass a function to this method
    +app.shortcut("open_modal")(open_modal)
    +
    +

    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.

    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    +

    Args

    +
    +
    constraints
    +
    The conditions that match a request payload.
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def shortcut(
    +    self,
    +    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new shortcut listener.
    +    This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.shortcut("open_modal")
    +        async def open_modal(ack, body, client):
    +            # Acknowledge the command request
    +            await ack()
    +            # Call views_open with the built-in client
    +            await client.views_open(
    +                # Pass a valid trigger_id within 3 seconds of receiving it
    +                trigger_id=body["trigger_id"],
    +                # View payload
    +                view={ ... }
    +            )
    +
    +        # Pass a function to this method
    +        app.shortcut("open_modal")(open_modal)
    +
    +    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +    Args:
    +        constraints: The conditions that match a request payload.
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.shortcut(
    +            constraints, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    -
    -
    -
    -
    -
    -
    -
    -
    + +
    +def start(self, port: int = 3000, path: str = '/slack/events', host: Optional[str] = None) ‑> None +
    +
    +

    Start a web server using AIOHTTP. +Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.

    +

    Args

    +
    +
    port
    +
    The port to listen on (Default: 3000)
    +
    path
    +
    The path to handle request from Slack (Default: /slack/events)
    +
    host
    +
    The hostname to serve the web endpoints. (Default: 0.0.0.0)
    +
    +
    + +Expand source code + +
    def start(
    +    self, port: int = 3000, path: str = "/slack/events", host: Optional[str] = None
    +) -> None:
    +    """Start a web server using AIOHTTP.
    +    Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
    +
    +    Args:
    +        port: The port to listen on (Default: 3000)
    +        path: The path to handle request from Slack (Default: `/slack/events`)
    +        host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
    +    """
    +    self.server(port=port, path=path, host=host).start()
    +
    +
    +
    +def step(self, callback_id: Union[str, Pattern, AsyncWorkflowStepAsyncWorkflowStepBuilder], edit: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], ForwardRef(None)] = None, save: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], ForwardRef(None)] = None, execute: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], ForwardRef(None)] = None) +
    +
    +

    Registers a new Workflow Step listener. +Unlike others, this method doesn't behave as a decorator. +If you want to register a workflow step by a decorator, use AsyncWorkflowStepBuilder's methods.

    +
    # Create a new WorkflowStep instance
    +from slack_bolt.workflows.async_step import AsyncWorkflowStep
    +ws = AsyncWorkflowStep(
    +    callback_id="add_task",
    +    edit=edit,
    +    save=save,
    +    execute=execute,
    +)
    +# Pass Step to set up listeners
    +app.step(ws)
    +
    +

    Refer to https://api.slack.com/workflows/steps for details of Steps from Apps.

    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document. +For further information about AsyncWorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to the async prefixed ones in slack_bolt.workflows.step.utilities API documents.

    +

    Args

    +
    +
    callback_id
    +
    The Callback ID for this workflow step
    +
    edit
    +
    The function for displaying a modal in the Workflow Builder
    +
    save
    +
    The function for handling configuration in the Workflow Builder
    +
    execute
    +
    The function for handling the step execution
    +
    +
    + +Expand source code + +
    def step(
    +    self,
    +    callback_id: Union[str, Pattern, AsyncWorkflowStep, AsyncWorkflowStepBuilder],
    +    edit: Optional[
    +        Union[
    +            Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]
    +        ]
    +    ] = None,
    +    save: Optional[
    +        Union[
    +            Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]
    +        ]
    +    ] = None,
    +    execute: Optional[
    +        Union[
    +            Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]
    +        ]
    +    ] = None,
    +):
    +    """
    +    Registers a new Workflow Step listener.
    +    Unlike others, this method doesn't behave as a decorator.
    +    If you want to register a workflow step by a decorator, use `AsyncWorkflowStepBuilder`'s methods.
    +
    +        # Create a new WorkflowStep instance
    +        from slack_bolt.workflows.async_step import AsyncWorkflowStep
    +        ws = AsyncWorkflowStep(
    +            callback_id="add_task",
    +            edit=edit,
    +            save=save,
    +            execute=execute,
    +        )
    +        # Pass Step to set up listeners
    +        app.step(ws)
    +
    +    Refer to https://api.slack.com/workflows/steps for details of Steps from Apps.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +    For further information about AsyncWorkflowStep specific function arguments
    +    such as `configure`, `update`, `complete`, and `fail`,
    +    refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents.
    +
    +    Args:
    +        callback_id: The Callback ID for this workflow step
    +        edit: The function for displaying a modal in the Workflow Builder
    +        save: The function for handling configuration in the Workflow Builder
    +        execute: The function for handling the step execution
    +    """
    +    step = callback_id
    +    if isinstance(callback_id, (str, Pattern)):
    +        step = AsyncWorkflowStep(
    +            callback_id=callback_id,
    +            edit=edit,
    +            save=save,
    +            execute=execute,
    +            base_logger=self._base_logger,
    +        )
    +    elif isinstance(step, AsyncWorkflowStepBuilder):
    +        step = step.build(base_logger=self._base_logger)
    +    elif not isinstance(step, AsyncWorkflowStep):
    +        raise BoltError(f"Invalid step object ({type(step)})")
    +
    +    self.use(AsyncWorkflowStepMiddleware(step, self._async_listener_runner))
    +
    +
    +
    +def use(self, *args) ‑> Optional[Callable] +
    +
    +

    Refer to AsyncApp#middleware() method's docstring for details.

    +
    + +Expand source code + +
    def use(self, *args) -> Optional[Callable]:
    +    """Refer to `AsyncApp#middleware()` method's docstring for details."""
    +    return self.middleware(*args)
    +
    +
    +
    +def view(self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new view_submission/view_closed event listener. +This method can be used as either a decorator or a method.

    +
    # Use this method as a decorator
    +@app.view("view_1")
    +async def handle_submission(ack, body, client, view):
    +    # Assume there's an input block with <code>block\_c</code> as the block_id and <code>dreamy\_input</code>
    +    hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
    +    user = body["user"]["id"]
    +    # Validate the inputs
    +    errors = {}
    +    if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
    +        errors["block_c"] = "The value must be longer than 5 characters"
    +    if len(errors) > 0:
    +        await ack(response_action="errors", errors=errors)
    +        return
    +    # Acknowledge the view_submission event and close the modal
    +    await ack()
    +    # Do whatever you want with the input data - here we're saving it to a DB
    +
    +# Pass a function to this method
    +app.view("view_1")(handle_submission)
    +
    +

    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.

    +

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    +

    Args

    +
    +
    constraints
    +
    The conditions that match a request payload
    +
    matchers
    +
    A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
    +
    middleware
    +
    A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
    +
    +
    + +Expand source code + +
    def view(
    +    self,
    +    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new `view_submission`/`view_closed` event listener.
    +    This method can be used as either a decorator or a method.
    +
    +        # Use this method as a decorator
    +        @app.view("view_1")
    +        async def handle_submission(ack, body, client, view):
    +            # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
    +            hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
    +            user = body["user"]["id"]
    +            # Validate the inputs
    +            errors = {}
    +            if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
    +                errors["block_c"] = "The value must be longer than 5 characters"
    +            if len(errors) > 0:
    +                await ack(response_action="errors", errors=errors)
    +                return
    +            # Acknowledge the view_submission event and close the modal
    +            await ack()
    +            # Do whatever you want with the input data - here we're saving it to a DB
    +
    +        # Pass a function to this method
    +        app.view("view_1")(handle_submission)
    +
    +    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.
    +
    +    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
    +
    +    Args:
    +        constraints: The conditions that match a request payload
    +        matchers: A list of listener matcher functions.
    +            Only when all the matchers return True, the listener function can be invoked.
    +        middleware: A list of lister middleware functions.
    +            Only when all the middleware call `next()` method, the listener function can be invoked.
    +    """
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.view(
    +            constraints, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def view_closed(self, constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new view_closed listener. +Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.

    +
    + +Expand source code + +
    def view_closed(
    +    self,
    +    constraints: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new `view_closed` listener.
    +    Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.view_closed(
    +            constraints, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def view_submission(self, constraints: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +
    +
    +

    Registers a new view_submission listener. +Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.

    +
    + +Expand source code + +
    def view_submission(
    +    self,
    +    constraints: Union[str, Pattern],
    +    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
    +    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
    +) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    +    """Registers a new `view_submission` listener.
    +    Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details."""
    +
    +    def __call__(*args, **kwargs):
    +        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
    +        primary_matcher = builtin_matchers.view_submission(
    +            constraints, asyncio=True, base_logger=self._base_logger
    +        )
    +        return self._register_listener(
    +            list(functions), primary_matcher, matchers, middleware
    +        )
    +
    +    return __call__
    +
    +
    +
    +def web_app(self, path: str = '/slack/events') ‑> aiohttp.web_app.Application +
    +
    +

    Returns a web.Application instance for aiohttp-devtools users.

    +
    from slack_bolt.async_app import AsyncApp
    +app = AsyncApp()
    +
    +@app.event("app_mention")
    +async def event_test(body, say, logger):
    +    logger.info(body)
    +    await say("What's up?")
    +
    +def app_factory():
    +    return app.web_app()
    +
    +# adev runserver --port 3000 --app-factory app_factory async_app.py
    +
    +

    Args

    +
    +
    path
    +
    The path to receive incoming requests from Slack
    +
    +
    + +Expand source code + +
    def web_app(self, path: str = "/slack/events") -> web.Application:
    +    """Returns a `web.Application` instance for aiohttp-devtools users.
    +
    +        from slack_bolt.async_app import AsyncApp
    +        app = AsyncApp()
    +
    +        @app.event("app_mention")
    +        async def event_test(body, say, logger):
    +            logger.info(body)
    +            await say("What's up?")
    +
    +        def app_factory():
    +            return app.web_app()
    +
    +        # adev runserver --port 3000 --app-factory app_factory async_app.py
    +
    +    Args:
    +        path: The path to receive incoming requests from Slack
    +    """
    +    return self.server(path=path).web_app
    +
    +
    + + +
    +class AsyncBoltContext +(*args, **kwargs) +
    +
    +

    Context object associated with a request from Slack.

    +
    + +Expand source code + +
    class AsyncBoltContext(BaseContext):
    +    """Context object associated with a request from Slack."""
    +
    +    def to_copyable(self) -> "AsyncBoltContext":
    +        new_dict = {}
    +        for prop_name, prop_value in self.items():
    +            if prop_name in self.standard_property_names:
    +                # all the standard properties are copiable
    +                new_dict[prop_name] = prop_value
    +            else:
    +                try:
    +                    copied_value = create_copy(prop_value)
    +                    new_dict[prop_name] = copied_value
    +                except TypeError as te:
    +                    self.logger.debug(
    +                        f"Skipped settings '{prop_name}' to a copied request for lazy listeners "
    +                        f"as it's not possible to make a deep copy (error: {te})"
    +                    )
    +        return AsyncBoltContext(new_dict)
    +
    +    @property
    +    def client(self) -> Optional[AsyncWebClient]:
    +        """The `AsyncWebClient` instance available for this request.
    +
    +            @app.event("app_mention")
    +            async def handle_events(context):
    +                await context.client.chat_postMessage(
    +                    channel=context.channel_id,
    +                    text="Thanks!",
    +                )
    +
    +            # You can access "client" this way too.
    +            @app.event("app_mention")
    +            async def handle_events(client, context):
    +                await client.chat_postMessage(
    +                    channel=context.channel_id,
    +                    text="Thanks!",
    +                )
    +
    +        Returns:
    +            `AsyncWebClient` instance
    +        """
    +        if "client" not in self:
    +            self["client"] = AsyncWebClient(token=None)
    +        return self["client"]
    +
    +    @property
    +    def ack(self) -> AsyncAck:
    +        """`ack()` function for this request.
    +
    +            @app.action("button")
    +            async def handle_button_clicks(context):
    +                await context.ack()
    +
    +            # You can access "ack" this way too.
    +            @app.action("button")
    +            async def handle_button_clicks(ack):
    +                await ack()
    +
    +        Returns:
    +            Callable `ack()` function
    +        """
    +        if "ack" not in self:
    +            self["ack"] = AsyncAck()
    +        return self["ack"]
    +
    +    @property
    +    def say(self) -> AsyncSay:
    +        """`say()` function for this request.
    +
    +            @app.action("button")
    +            async def handle_button_clicks(context):
    +                await context.ack()
    +                await context.say("Hi!")
    +
    +            # You can access "ack" this way too.
    +            @app.action("button")
    +            async def handle_button_clicks(ack, say):
    +                await ack()
    +                await say("Hi!")
    +
    +        Returns:
    +            Callable `say()` function
    +        """
    +        if "say" not in self:
    +            self["say"] = AsyncSay(client=self.client, channel=self.channel_id)
    +        return self["say"]
    +
    +    @property
    +    def respond(self) -> Optional[AsyncRespond]:
    +        """`respond()` function for this request.
    +
    +            @app.action("button")
    +            async def handle_button_clicks(context):
    +                await context.ack()
    +                await context.respond("Hi!")
    +
    +            # You can access "ack" this way too.
    +            @app.action("button")
    +            async def handle_button_clicks(ack, respond):
    +                await ack()
    +                await respond("Hi!")
    +
    +        Returns:
    +            Callable `respond()` function
    +        """
    +        if "respond" not in self:
    +            self["respond"] = AsyncRespond(
    +                response_url=self.response_url,
    +                proxy=self.client.proxy,
    +                ssl=self.client.ssl,
    +            )
    +        return self["respond"]
    +
    +

    Ancestors

    + +

    Instance variables

    +
    +
    var ackAsyncAck
    +
    +

    ack() function for this request.

    +
    @app.action("button")
    +async def handle_button_clicks(context):
    +    await context.ack()
    +
    +# You can access "ack" this way too.
    +@app.action("button")
    +async def handle_button_clicks(ack):
    +    await ack()
    +
    +

    Returns

    +

    Callable ack() function

    +
    + +Expand source code + +
    @property
    +def ack(self) -> AsyncAck:
    +    """`ack()` function for this request.
    +
    +        @app.action("button")
    +        async def handle_button_clicks(context):
    +            await context.ack()
    +
    +        # You can access "ack" this way too.
    +        @app.action("button")
    +        async def handle_button_clicks(ack):
    +            await ack()
    +
    +    Returns:
    +        Callable `ack()` function
    +    """
    +    if "ack" not in self:
    +        self["ack"] = AsyncAck()
    +    return self["ack"]
    +
    +
    +
    var client : Optional[slack_sdk.web.async_client.AsyncWebClient]
    +
    +

    The AsyncWebClient instance available for this request.

    +
    @app.event("app_mention")
    +async def handle_events(context):
    +    await context.client.chat_postMessage(
    +        channel=context.channel_id,
    +        text="Thanks!",
    +    )
    +
    +# You can access "client" this way too.
    +@app.event("app_mention")
    +async def handle_events(client, context):
    +    await client.chat_postMessage(
    +        channel=context.channel_id,
    +        text="Thanks!",
    +    )
    +
    +

    Returns

    +

    AsyncWebClient instance

    +
    + +Expand source code + +
    @property
    +def client(self) -> Optional[AsyncWebClient]:
    +    """The `AsyncWebClient` instance available for this request.
    +
    +        @app.event("app_mention")
    +        async def handle_events(context):
    +            await context.client.chat_postMessage(
    +                channel=context.channel_id,
    +                text="Thanks!",
    +            )
    +
    +        # You can access "client" this way too.
    +        @app.event("app_mention")
    +        async def handle_events(client, context):
    +            await client.chat_postMessage(
    +                channel=context.channel_id,
    +                text="Thanks!",
    +            )
    +
    +    Returns:
    +        `AsyncWebClient` instance
    +    """
    +    if "client" not in self:
    +        self["client"] = AsyncWebClient(token=None)
    +    return self["client"]
    +
    +
    +
    var respond : Optional[AsyncRespond]
    +
    +

    respond() function for this request.

    +
    @app.action("button")
    +async def handle_button_clicks(context):
    +    await context.ack()
    +    await context.respond("Hi!")
    +
    +# You can access "ack" this way too.
    +@app.action("button")
    +async def handle_button_clicks(ack, respond):
    +    await ack()
    +    await respond("Hi!")
    +
    +

    Returns

    +

    Callable respond() function

    +
    + +Expand source code + +
    @property
    +def respond(self) -> Optional[AsyncRespond]:
    +    """`respond()` function for this request.
    +
    +        @app.action("button")
    +        async def handle_button_clicks(context):
    +            await context.ack()
    +            await context.respond("Hi!")
    +
    +        # You can access "ack" this way too.
    +        @app.action("button")
    +        async def handle_button_clicks(ack, respond):
    +            await ack()
    +            await respond("Hi!")
    +
    +    Returns:
    +        Callable `respond()` function
    +    """
    +    if "respond" not in self:
    +        self["respond"] = AsyncRespond(
    +            response_url=self.response_url,
    +            proxy=self.client.proxy,
    +            ssl=self.client.ssl,
    +        )
    +    return self["respond"]
    +
    +
    +
    var sayAsyncSay
    +
    +

    say() function for this request.

    +
    @app.action("button")
    +async def handle_button_clicks(context):
    +    await context.ack()
    +    await context.say("Hi!")
    +
    +# You can access "ack" this way too.
    +@app.action("button")
    +async def handle_button_clicks(ack, say):
    +    await ack()
    +    await say("Hi!")
    +
    +

    Returns

    +

    Callable say() function

    +
    + +Expand source code + +
    @property
    +def say(self) -> AsyncSay:
    +    """`say()` function for this request.
    +
    +        @app.action("button")
    +        async def handle_button_clicks(context):
    +            await context.ack()
    +            await context.say("Hi!")
    +
    +        # You can access "ack" this way too.
    +        @app.action("button")
    +        async def handle_button_clicks(ack, say):
    +            await ack()
    +            await say("Hi!")
    +
    +    Returns:
    +        Callable `say()` function
    +    """
    +    if "say" not in self:
    +        self["say"] = AsyncSay(client=self.client, channel=self.channel_id)
    +    return self["say"]
    +
    +
    +
    +

    Methods

    +
    +
    +def to_copyable(self) ‑> AsyncBoltContext +
    +
    +
    +
    + +Expand source code + +
    def to_copyable(self) -> "AsyncBoltContext":
    +    new_dict = {}
    +    for prop_name, prop_value in self.items():
    +        if prop_name in self.standard_property_names:
    +            # all the standard properties are copiable
    +            new_dict[prop_name] = prop_value
    +        else:
    +            try:
    +                copied_value = create_copy(prop_value)
    +                new_dict[prop_name] = copied_value
    +            except TypeError as te:
    +                self.logger.debug(
    +                    f"Skipped settings '{prop_name}' to a copied request for lazy listeners "
    +                    f"as it's not possible to make a deep copy (error: {te})"
    +                )
    +    return AsyncBoltContext(new_dict)
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class AsyncBoltRequest +(*, body: Union[str, dict], query: Union[str, Dict[str, str], Dict[str, Sequence[str]], ForwardRef(None)] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, str]] = None, mode: str = 'http') +
    +
    +

    Request to a Bolt app.

    +

    Args

    +
    +
    body
    +
    The raw request body (only plain text is supported for "http" mode)
    +
    query
    +
    The query string data in any data format.
    +
    headers
    +
    The request headers.
    +
    context
    +
    The context in this request.
    +
    mode
    +
    The mode used for this request. (either "http" or "socket_mode")
    +
    +
    + +Expand source code + +
    class AsyncBoltRequest:
    +    raw_body: str
    +    body: Dict[str, Any]
    +    query: Dict[str, Sequence[str]]
    +    headers: Dict[str, Sequence[str]]
    +    content_type: Optional[str]
    +    context: AsyncBoltContext
    +    lazy_only: bool
    +    lazy_function_name: Optional[str]
    +    mode: str  # either "http" or "socket_mode"
    +
    +    def __init__(
    +        self,
    +        *,
    +        body: Union[str, dict],
    +        query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None,
    +        headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None,
    +        context: Optional[Dict[str, str]] = None,
    +        mode: str = "http",  # either "http" or "socket_mode"
    +    ):
    +        """Request to a Bolt app.
    +
    +        Args:
    +            body: The raw request body (only plain text is supported for "http" mode)
    +            query: The query string data in any data format.
    +            headers: The request headers.
    +            context: The context in this request.
    +            mode: The mode used for this request. (either "http" or "socket_mode")
    +        """
    +
    +        if mode == "http":
    +            # HTTP Mode
    +            if body is not None and not isinstance(body, str):
    +                raise BoltError(error_message_raw_body_required_in_http_mode())
    +            self.raw_body = body if body is not None else ""
    +        else:
    +            # Socket Mode
    +            if body is not None and isinstance(body, str):
    +                self.raw_body = body
    +            else:
    +                # We don't convert the dict value to str
    +                # as doing so does not guarantee to keep the original structure/format.
    +                self.raw_body = ""
    +
    +        self.query = parse_query(query)
    +        self.headers = build_normalized_headers(headers)
    +        self.content_type = extract_content_type(self.headers)
    +
    +        if isinstance(body, str):
    +            self.body = parse_body(self.raw_body, self.content_type)
    +        elif isinstance(body, dict):
    +            self.body = body
    +        else:
    +            self.body = {}
    +
    +        self.context = build_async_context(
    +            AsyncBoltContext(context if context else {}), self.body
    +        )
    +        self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0])
    +        self.lazy_function_name = self.headers.get(
    +            "x-slack-bolt-lazy-function-name", [None]
    +        )[0]
    +        self.mode = mode
    +
    +    def to_copyable(self) -> "AsyncBoltRequest":
    +        body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
    +        return AsyncBoltRequest(
    +            body=body,
    +            query=self.query,
    +            headers=self.headers,
    +            context=self.context.to_copyable(),
    +            mode=self.mode,
    +        )
    +
    +

    Class variables

    +
    +
    var body : Dict[str, Any]
    +
    +
    +
    +
    var content_type : Optional[str]
    +
    +
    +
    +
    var contextAsyncBoltContext
    +
    +
    +
    +
    var headers : Dict[str, Sequence[str]]
    +
    +
    +
    +
    var lazy_function_name : Optional[str]
    +
    +
    +
    +
    var lazy_only : bool
    +
    +
    +
    +
    var mode : str
    +
    +
    +
    +
    var query : Dict[str, Sequence[str]]
    +
    +
    +
    +
    var raw_body : str
    +
    +
    +
    +
    +

    Methods

    +
    +
    +def to_copyable(self) ‑> AsyncBoltRequest +
    +
    +
    +
    + +Expand source code + +
    def to_copyable(self) -> "AsyncBoltRequest":
    +    body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
    +    return AsyncBoltRequest(
    +        body=body,
    +        query=self.query,
    +        headers=self.headers,
    +        context=self.context.to_copyable(),
    +        mode=self.mode,
    +    )
    +
    +
    +
    +
    +
    +class AsyncCustomListenerMatcher +(*, app_name: str, func: Callable[..., Awaitable[bool]], base_logger: Optional[logging.Logger] = None) +
    +
    +
    +
    + +Expand source code + +
    class AsyncCustomListenerMatcher(AsyncListenerMatcher):
    +    app_name: str
    +    func: Callable[..., Awaitable[bool]]
    +    arg_names: Sequence[str]
    +    logger: Logger
    +
    +    def __init__(
    +        self,
    +        *,
    +        app_name: str,
    +        func: Callable[..., Awaitable[bool]],
    +        base_logger: Optional[Logger] = None
    +    ):
    +        self.app_name = app_name
    +        self.func = func
    +        self.arg_names = inspect.getfullargspec(func).args
    +        self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger)
    +
    +    async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool:
    +        return await self.func(
    +            **build_async_required_kwargs(
    +                logger=self.logger,
    +                required_arg_names=self.arg_names,
    +                request=req,
    +                response=resp,
    +                this_func=self.func,
    +            )
    +        )
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var app_name : str
    +
    +
    +
    +
    var arg_names : Sequence[str]
    +
    +
    +
    +
    var func : Callable[..., Awaitable[bool]]
    +
    +
    +
    +
    var logger : logging.Logger
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class AsyncListener +
    +
    +
    +
    + +Expand source code + +
    class AsyncListener(metaclass=ABCMeta):
    +    matchers: Sequence[AsyncListenerMatcher]
    +    middleware: Sequence[AsyncMiddleware]
    +    ack_function: Callable[..., Awaitable[BoltResponse]]
    +    lazy_functions: Sequence[Callable[..., Awaitable[None]]]
    +    auto_acknowledgement: bool
    +
    +    async def async_matches(
    +        self,
    +        *,
    +        req: AsyncBoltRequest,
    +        resp: BoltResponse,
    +    ) -> bool:
    +        is_matched: bool = False
    +        for matcher in self.matchers:
    +            is_matched = await matcher.async_matches(req, resp)
    +            if not is_matched:
    +                return is_matched
    +        return is_matched
    +
    +    async def run_async_middleware(
    +        self,
    +        *,
    +        req: AsyncBoltRequest,
    +        resp: BoltResponse,
    +    ) -> Tuple[Optional[BoltResponse], bool]:
    +        """Runs an async middleware.
    +
    +        Args:
    +            req: The incoming request
    +            resp: The current response
    +
    +        Returns:
    +            A tuple of the processed response and a flag indicating termination
    +        """
    +        for m in self.middleware:
    +            middleware_state = {"next_called": False}
    +
    +            async def _next():
    +                middleware_state["next_called"] = True
    +
    +            resp = await m.async_process(req=req, resp=resp, next=_next)
    +            if not middleware_state["next_called"]:
    +                # next() was not called in this middleware
    +                return (resp, True)
    +        return (resp, False)
    +
    +    @abstractmethod
    +    async def run_ack_function(
    +        self, *, request: AsyncBoltRequest, response: BoltResponse
    +    ) -> BoltResponse:
    +        """Runs all the registered middleware and then run the listener function.
    +
    +        Args:
    +            request: The incoming request
    +            response: The current response
    +
    +        Returns:
    +            The processed response
    +        """
    +        raise NotImplementedError()
    +
    +

    Subclasses

    + +

    Class variables

    +
    +
    var ack_function : Callable[..., Awaitable[BoltResponse]]
    +
    +
    +
    +
    var auto_acknowledgement : bool
    +
    +
    +
    +
    var lazy_functions : Sequence[Callable[..., Awaitable[None]]]
    +
    +
    +
    +
    var matchers : Sequence[AsyncListenerMatcher]
    +
    +
    +
    +
    var middleware : Sequence[AsyncMiddleware]
    +
    +
    +
    +
    +

    Methods

    +
    +
    +async def async_matches(self, *, req: AsyncBoltRequest, resp: BoltResponse) ‑> bool +
    +
    +
    +
    + +Expand source code + +
    async def async_matches(
    +    self,
    +    *,
    +    req: AsyncBoltRequest,
    +    resp: BoltResponse,
    +) -> bool:
    +    is_matched: bool = False
    +    for matcher in self.matchers:
    +        is_matched = await matcher.async_matches(req, resp)
    +        if not is_matched:
    +            return is_matched
    +    return is_matched
    +
    +
    +
    +async def run_ack_function(self, *, request: AsyncBoltRequest, response: BoltResponse) ‑> BoltResponse +
    +
    +

    Runs all the registered middleware and then run the listener function.

    +

    Args

    +
    +
    request
    +
    The incoming request
    +
    response
    +
    The current response
    +
    +

    Returns

    +

    The processed response

    +
    + +Expand source code + +
    @abstractmethod
    +async def run_ack_function(
    +    self, *, request: AsyncBoltRequest, response: BoltResponse
    +) -> BoltResponse:
    +    """Runs all the registered middleware and then run the listener function.
    +
    +    Args:
    +        request: The incoming request
    +        response: The current response
    +
    +    Returns:
    +        The processed response
    +    """
    +    raise NotImplementedError()
    +
    +
    +
    +async def run_async_middleware(self, *, req: AsyncBoltRequest, resp: BoltResponse) ‑> Tuple[Optional[BoltResponse], bool] +
    +
    +

    Runs an async middleware.

    +

    Args

    +
    +
    req
    +
    The incoming request
    +
    resp
    +
    The current response
    +
    +

    Returns

    +

    A tuple of the processed response and a flag indicating termination

    +
    + +Expand source code + +
    async def run_async_middleware(
    +    self,
    +    *,
    +    req: AsyncBoltRequest,
    +    resp: BoltResponse,
    +) -> Tuple[Optional[BoltResponse], bool]:
    +    """Runs an async middleware.
    +
    +    Args:
    +        req: The incoming request
    +        resp: The current response
    +
    +    Returns:
    +        A tuple of the processed response and a flag indicating termination
    +    """
    +    for m in self.middleware:
    +        middleware_state = {"next_called": False}
    +
    +        async def _next():
    +            middleware_state["next_called"] = True
    +
    +        resp = await m.async_process(req=req, resp=resp, next=_next)
    +        if not middleware_state["next_called"]:
    +            # next() was not called in this middleware
    +            return (resp, True)
    +    return (resp, False)
    +
    +
    +
    +
    +
    +class AsyncRespond +(*, response_url: Optional[str], proxy: Optional[str] = None, ssl: Optional[ssl.SSLContext] = None) +
    +
    +
    +
    + +Expand source code + +
    class AsyncRespond:
    +    response_url: Optional[str]
    +    proxy: Optional[str]
    +    ssl: Optional[SSLContext]
    +
    +    def __init__(
    +        self,
    +        *,
    +        response_url: Optional[str],
    +        proxy: Optional[str] = None,
    +        ssl: Optional[SSLContext] = None,
    +    ):
    +        self.response_url = response_url
    +        self.proxy = proxy
    +        self.ssl = ssl
    +
    +    async def __call__(
    +        self,
    +        text: Union[str, dict] = "",
    +        blocks: Optional[Sequence[Union[dict, Block]]] = None,
    +        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
    +        response_type: Optional[str] = None,
    +        replace_original: Optional[bool] = None,
    +        delete_original: Optional[bool] = None,
    +        unfurl_links: Optional[bool] = None,
    +        unfurl_media: Optional[bool] = None,
    +    ) -> WebhookResponse:
    +        if self.response_url is not None:
    +            client = AsyncWebhookClient(
    +                url=self.response_url,
    +                proxy=self.proxy,
    +                ssl=self.ssl,
    +            )
    +            text_or_whole_response: Union[str, dict] = text
    +            if isinstance(text_or_whole_response, str):
    +                message = _build_message(
    +                    text=text,
    +                    blocks=blocks,
    +                    attachments=attachments,
    +                    response_type=response_type,
    +                    replace_original=replace_original,
    +                    delete_original=delete_original,
    +                    unfurl_links=unfurl_links,
    +                    unfurl_media=unfurl_media,
    +                )
    +                return await client.send_dict(message)
    +            elif isinstance(text_or_whole_response, dict):
    +                whole_response: dict = text_or_whole_response
    +                message = _build_message(**whole_response)
    +                return await client.send_dict(message)
    +            else:
    +                raise ValueError(f"The arg is unexpected type ({type(text)})")
    +        else:
    +            raise ValueError("respond is unsupported here as there is no response_url")
    +
    +

    Class variables

    +
    +
    var proxy : Optional[str]
    +
    +
    +
    +
    var response_url : Optional[str]
    +
    +
    +
    +
    var ssl : Optional[ssl.SSLContext]
    +
    +
    +
    +
    +
    +
    +class AsyncSay +(client: Optional[slack_sdk.web.async_client.AsyncWebClient], channel: Optional[str]) +
    +
    +
    +
    + +Expand source code + +
    class AsyncSay:
    +    client: Optional[AsyncWebClient]
    +    channel: Optional[str]
    +
    +    def __init__(
    +        self,
    +        client: Optional[AsyncWebClient],
    +        channel: Optional[str],
    +    ):
    +        self.client = client
    +        self.channel = channel
    +
    +    async def __call__(
    +        self,
    +        text: Union[str, dict] = "",
    +        blocks: Optional[Sequence[Union[Dict, Block]]] = None,
    +        attachments: Optional[Sequence[Union[Dict, Attachment]]] = None,
    +        channel: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +        unfurl_links: Optional[bool] = None,
    +        unfurl_media: Optional[bool] = None,
    +        **kwargs,
    +    ) -> AsyncSlackResponse:
    +        if _can_say(self, channel):
    +            text_or_whole_response: Union[str, dict] = text
    +            if isinstance(text_or_whole_response, str):
    +                text = text_or_whole_response
    +                return await self.client.chat_postMessage(
    +                    channel=channel or self.channel,
    +                    text=text,
    +                    blocks=blocks,
    +                    attachments=attachments,
    +                    thread_ts=thread_ts,
    +                    unfurl_links=unfurl_links,
    +                    unfurl_media=unfurl_media,
    +                    **kwargs,
    +                )
    +            elif isinstance(text_or_whole_response, dict):
    +                message: dict = text_or_whole_response
    +                if "channel" not in message:
    +                    message["channel"] = channel or self.channel
    +                return await self.client.chat_postMessage(**message)
    +            else:
    +                raise ValueError(
    +                    f"The arg is unexpected type ({type(text_or_whole_response)})"
    +                )
    +        else:
    +            raise ValueError("say without channel_id here is unsupported")
    +
    +

    Class variables

    +
    +
    var channel : Optional[str]
    +
    +
    +
    +
    var client : Optional[slack_sdk.web.async_client.AsyncWebClient]
    +
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/authorization/index.html b/docs/api-docs/slack_bolt/authorization/index.html index 6352b61c5..df47f7e82 100644 --- a/docs/api-docs/slack_bolt/authorization/index.html +++ b/docs/api-docs/slack_bolt/authorization/index.html @@ -35,7 +35,11 @@

    Module slack_bolt.authorization

    Refer to https://slack.dev/bolt-python/concepts#authorization for details. """ -from .authorize_result import AuthorizeResult # noqa
    +from .authorize_result import AuthorizeResult + +__all__ = [ + "AuthorizeResult", +]
    @@ -68,6 +72,185 @@

    Sub-modules

    +

    Classes

    +
    +
    +class AuthorizeResult +(*, enterprise_id: Optional[str], team_id: Optional[str], bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, user_id: Optional[str] = None, user_token: Optional[str] = None) +
    +
    +

    Authorize function call result

    +

    Args

    +
    +
    enterprise_id
    +
    Organization ID (Enterprise Grid) starting with E
    +
    team_id
    +
    Workspace ID starting with T
    +
    bot_user_id
    +
    Bot user's User ID starting with either U or W
    +
    bot_id
    +
    Bot ID starting with B
    +
    bot_token
    +
    Bot user access token starting with xoxb-
    +
    user_id
    +
    The request user ID
    +
    user_token
    +
    User access token starting with xoxp-
    +
    +
    + +Expand source code + +
    class AuthorizeResult(dict):
    +    """Authorize function call result"""
    +
    +    enterprise_id: Optional[str]
    +    team_id: Optional[str]
    +    bot_id: Optional[str]
    +    bot_user_id: Optional[str]
    +    bot_token: Optional[str]
    +    user_id: Optional[str]
    +    user_token: Optional[str]
    +
    +    def __init__(
    +        self,
    +        *,
    +        enterprise_id: Optional[str],
    +        team_id: Optional[str],
    +        # bot
    +        bot_user_id: Optional[str] = None,
    +        bot_id: Optional[str] = None,
    +        bot_token: Optional[str] = None,
    +        # user
    +        user_id: Optional[str] = None,
    +        user_token: Optional[str] = None,
    +    ):
    +        """
    +        Args:
    +            enterprise_id: Organization ID (Enterprise Grid) starting with `E`
    +            team_id: Workspace ID starting with `T`
    +            bot_user_id: Bot user's User ID starting with either `U` or `W`
    +            bot_id: Bot ID starting with `B`
    +            bot_token: Bot user access token starting with `xoxb-`
    +            user_id: The request user ID
    +            user_token: User access token starting with `xoxp-`
    +        """
    +        self["enterprise_id"] = self.enterprise_id = enterprise_id
    +        self["team_id"] = self.team_id = team_id
    +        # bot
    +        self["bot_user_id"] = self.bot_user_id = bot_user_id
    +        self["bot_id"] = self.bot_id = bot_id
    +        self["bot_token"] = self.bot_token = bot_token
    +        # user
    +        self["user_id"] = self.user_id = user_id
    +        self["user_token"] = self.user_token = user_token
    +
    +    @classmethod
    +    def from_auth_test_response(
    +        cls,
    +        *,
    +        bot_token: Optional[str] = None,
    +        user_token: Optional[str] = None,
    +        auth_test_response: SlackResponse,
    +    ) -> "AuthorizeResult":
    +        bot_user_id: Optional[str] = (  # type:ignore
    +            auth_test_response.get("user_id")
    +            if auth_test_response.get("bot_id") is not None
    +            else None
    +        )
    +        user_id: Optional[str] = (  # type:ignore
    +            auth_test_response.get("user_id")
    +            if auth_test_response.get("bot_id") is None
    +            else None
    +        )
    +        return AuthorizeResult(
    +            enterprise_id=auth_test_response.get("enterprise_id"),
    +            team_id=auth_test_response.get("team_id"),
    +            bot_id=auth_test_response.get("bot_id"),
    +            bot_user_id=bot_user_id,
    +            user_id=user_id,
    +            bot_token=bot_token,
    +            user_token=user_token,
    +        )
    +
    +

    Ancestors

    +
      +
    • builtins.dict
    • +
    +

    Class variables

    +
    +
    var bot_id : Optional[str]
    +
    +
    +
    +
    var bot_token : Optional[str]
    +
    +
    +
    +
    var bot_user_id : Optional[str]
    +
    +
    +
    +
    var enterprise_id : Optional[str]
    +
    +
    +
    +
    var team_id : Optional[str]
    +
    +
    +
    +
    var user_id : Optional[str]
    +
    +
    +
    +
    var user_token : Optional[str]
    +
    +
    +
    +
    +

    Static methods

    +
    +
    +def from_auth_test_response(*, bot_token: Optional[str] = None, user_token: Optional[str] = None, auth_test_response: slack_sdk.web.slack_response.SlackResponse) ‑> AuthorizeResult +
    +
    +
    +
    + +Expand source code + +
    @classmethod
    +def from_auth_test_response(
    +    cls,
    +    *,
    +    bot_token: Optional[str] = None,
    +    user_token: Optional[str] = None,
    +    auth_test_response: SlackResponse,
    +) -> "AuthorizeResult":
    +    bot_user_id: Optional[str] = (  # type:ignore
    +        auth_test_response.get("user_id")
    +        if auth_test_response.get("bot_id") is not None
    +        else None
    +    )
    +    user_id: Optional[str] = (  # type:ignore
    +        auth_test_response.get("user_id")
    +        if auth_test_response.get("bot_id") is None
    +        else None
    +    )
    +    return AuthorizeResult(
    +        enterprise_id=auth_test_response.get("enterprise_id"),
    +        team_id=auth_test_response.get("team_id"),
    +        bot_id=auth_test_response.get("bot_id"),
    +        bot_user_id=bot_user_id,
    +        user_id=user_id,
    +        bot_token=bot_token,
    +        user_token=user_token,
    +    )
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/context/ack/index.html b/docs/api-docs/slack_bolt/context/ack/index.html index 2d49a6cb2..a75ce98f3 100644 --- a/docs/api-docs/slack_bolt/context/ack/index.html +++ b/docs/api-docs/slack_bolt/context/ack/index.html @@ -27,7 +27,11 @@

    Module slack_bolt.context.ack

    Expand source code
    # Don't add async module imports here
    -from .ack import Ack  # noqa: F401
    +from .ack import Ack + +__all__ = [ + "Ack", +]
    @@ -52,6 +56,63 @@

    Sub-modules

    +

    Classes

    +
    +
    +class Ack +
    +
    +
    +
    + +Expand source code + +
    class Ack:
    +    response: Optional[BoltResponse]
    +
    +    def __init__(self):
    +        self.response: Optional[BoltResponse] = None
    +
    +    def __call__(
    +        self,
    +        text: Union[str, dict] = "",  # text: str or whole_response: dict
    +        blocks: Optional[Sequence[Union[dict, Block]]] = None,
    +        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
    +        unfurl_links: Optional[bool] = None,
    +        unfurl_media: Optional[bool] = None,
    +        response_type: Optional[str] = None,  # in_channel / ephemeral
    +        # block_suggestion / dialog_suggestion
    +        options: Optional[Sequence[Union[dict, Option]]] = None,
    +        option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None,
    +        # view_submission
    +        response_action: Optional[str] = None,  # errors / update / push / clear
    +        errors: Optional[Dict[str, str]] = None,
    +        view: Optional[Union[dict, View]] = None,
    +    ) -> BoltResponse:
    +        return _set_response(
    +            self,
    +            text_or_whole_response=text,
    +            blocks=blocks,
    +            attachments=attachments,
    +            unfurl_links=unfurl_links,
    +            unfurl_media=unfurl_media,
    +            response_type=response_type,
    +            options=options,
    +            option_groups=option_groups,
    +            response_action=response_action,
    +            errors=errors,
    +            view=view,
    +        )
    +
    +

    Class variables

    +
    +
    var response : Optional[BoltResponse]
    +
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/context/index.html b/docs/api-docs/slack_bolt/context/index.html index 1e2bea874..0ef2c7268 100644 --- a/docs/api-docs/slack_bolt/context/index.html +++ b/docs/api-docs/slack_bolt/context/index.html @@ -39,7 +39,11 @@

    Module slack_bolt.context

    """ # Don't add async module imports here -from .context import BoltContext # noqa: F401 +from .context import BoltContext + +__all__ = [ + "BoltContext", +]
    @@ -76,6 +80,375 @@

    Sub-modules

    +

    Classes

    +
    +
    +class BoltContext +(*args, **kwargs) +
    +
    +

    Context object associated with a request from Slack.

    +
    + +Expand source code + +
    class BoltContext(BaseContext):
    +    """Context object associated with a request from Slack."""
    +
    +    def to_copyable(self) -> "BoltContext":
    +        new_dict = {}
    +        for prop_name, prop_value in self.items():
    +            if prop_name in self.standard_property_names:
    +                # all the standard properties are copiable
    +                new_dict[prop_name] = prop_value
    +            else:
    +                try:
    +                    copied_value = create_copy(prop_value)
    +                    new_dict[prop_name] = copied_value
    +                except TypeError as te:
    +                    self.logger.warning(
    +                        f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
    +                        "due to a deep-copy creation error. Consider passing the value not as part of context object "
    +                        f"(error: {te})"
    +                    )
    +        return BoltContext(new_dict)
    +
    +    @property
    +    def client(self) -> Optional[WebClient]:
    +        """The `WebClient` instance available for this request.
    +
    +            @app.event("app_mention")
    +            def handle_events(context):
    +                context.client.chat_postMessage(
    +                    channel=context.channel_id,
    +                    text="Thanks!",
    +                )
    +
    +            # You can access "client" this way too.
    +            @app.event("app_mention")
    +            def handle_events(client, context):
    +                client.chat_postMessage(
    +                    channel=context.channel_id,
    +                    text="Thanks!",
    +                )
    +
    +        Returns:
    +            `WebClient` instance
    +        """
    +        if "client" not in self:
    +            self["client"] = WebClient(token=None)
    +        return self["client"]
    +
    +    @property
    +    def ack(self) -> Ack:
    +        """`ack()` function for this request.
    +
    +            @app.action("button")
    +            def handle_button_clicks(context):
    +                context.ack()
    +
    +            # You can access "ack" this way too.
    +            @app.action("button")
    +            def handle_button_clicks(ack):
    +                ack()
    +
    +        Returns:
    +            Callable `ack()` function
    +        """
    +        if "ack" not in self:
    +            self["ack"] = Ack()
    +        return self["ack"]
    +
    +    @property
    +    def say(self) -> Say:
    +        """`say()` function for this request.
    +
    +            @app.action("button")
    +            def handle_button_clicks(context):
    +                context.ack()
    +                context.say("Hi!")
    +
    +            # You can access "ack" this way too.
    +            @app.action("button")
    +            def handle_button_clicks(ack, say):
    +                ack()
    +                say("Hi!")
    +
    +        Returns:
    +            Callable `say()` function
    +        """
    +        if "say" not in self:
    +            self["say"] = Say(client=self.client, channel=self.channel_id)
    +        return self["say"]
    +
    +    @property
    +    def respond(self) -> Optional[Respond]:
    +        """`respond()` function for this request.
    +
    +            @app.action("button")
    +            def handle_button_clicks(context):
    +                context.ack()
    +                context.respond("Hi!")
    +
    +            # You can access "ack" this way too.
    +            @app.action("button")
    +            def handle_button_clicks(ack, respond):
    +                ack()
    +                respond("Hi!")
    +
    +        Returns:
    +            Callable `respond()` function
    +        """
    +        if "respond" not in self:
    +            self["respond"] = Respond(
    +                response_url=self.response_url,
    +                proxy=self.client.proxy,
    +                ssl=self.client.ssl,
    +            )
    +        return self["respond"]
    +
    +

    Ancestors

    + +

    Instance variables

    +
    +
    var ackAck
    +
    +

    slack_bolt.context.ack function for this request.

    +
    @app.action("button")
    +def handle_button_clicks(context):
    +    context.ack()
    +
    +# You can access "ack" this way too.
    +@app.action("button")
    +def handle_button_clicks(ack):
    +    ack()
    +
    +

    Returns

    +

    Callable slack_bolt.context.ack function

    +
    + +Expand source code + +
    @property
    +def ack(self) -> Ack:
    +    """`ack()` function for this request.
    +
    +        @app.action("button")
    +        def handle_button_clicks(context):
    +            context.ack()
    +
    +        # You can access "ack" this way too.
    +        @app.action("button")
    +        def handle_button_clicks(ack):
    +            ack()
    +
    +    Returns:
    +        Callable `ack()` function
    +    """
    +    if "ack" not in self:
    +        self["ack"] = Ack()
    +    return self["ack"]
    +
    +
    +
    var client : Optional[slack_sdk.web.client.WebClient]
    +
    +

    The WebClient instance available for this request.

    +
    @app.event("app_mention")
    +def handle_events(context):
    +    context.client.chat_postMessage(
    +        channel=context.channel_id,
    +        text="Thanks!",
    +    )
    +
    +# You can access "client" this way too.
    +@app.event("app_mention")
    +def handle_events(client, context):
    +    client.chat_postMessage(
    +        channel=context.channel_id,
    +        text="Thanks!",
    +    )
    +
    +

    Returns

    +

    WebClient instance

    +
    + +Expand source code + +
    @property
    +def client(self) -> Optional[WebClient]:
    +    """The `WebClient` instance available for this request.
    +
    +        @app.event("app_mention")
    +        def handle_events(context):
    +            context.client.chat_postMessage(
    +                channel=context.channel_id,
    +                text="Thanks!",
    +            )
    +
    +        # You can access "client" this way too.
    +        @app.event("app_mention")
    +        def handle_events(client, context):
    +            client.chat_postMessage(
    +                channel=context.channel_id,
    +                text="Thanks!",
    +            )
    +
    +    Returns:
    +        `WebClient` instance
    +    """
    +    if "client" not in self:
    +        self["client"] = WebClient(token=None)
    +    return self["client"]
    +
    +
    +
    var respond : Optional[Respond]
    +
    +

    slack_bolt.context.respond function for this request.

    +
    @app.action("button")
    +def handle_button_clicks(context):
    +    context.ack()
    +    context.respond("Hi!")
    +
    +# You can access "ack" this way too.
    +@app.action("button")
    +def handle_button_clicks(ack, respond):
    +    ack()
    +    respond("Hi!")
    +
    +

    Returns

    +

    Callable slack_bolt.context.respond function

    +
    + +Expand source code + +
    @property
    +def respond(self) -> Optional[Respond]:
    +    """`respond()` function for this request.
    +
    +        @app.action("button")
    +        def handle_button_clicks(context):
    +            context.ack()
    +            context.respond("Hi!")
    +
    +        # You can access "ack" this way too.
    +        @app.action("button")
    +        def handle_button_clicks(ack, respond):
    +            ack()
    +            respond("Hi!")
    +
    +    Returns:
    +        Callable `respond()` function
    +    """
    +    if "respond" not in self:
    +        self["respond"] = Respond(
    +            response_url=self.response_url,
    +            proxy=self.client.proxy,
    +            ssl=self.client.ssl,
    +        )
    +    return self["respond"]
    +
    +
    +
    var saySay
    +
    +

    slack_bolt.context.say function for this request.

    +
    @app.action("button")
    +def handle_button_clicks(context):
    +    context.ack()
    +    context.say("Hi!")
    +
    +# You can access "ack" this way too.
    +@app.action("button")
    +def handle_button_clicks(ack, say):
    +    ack()
    +    say("Hi!")
    +
    +

    Returns

    +

    Callable slack_bolt.context.say function

    +
    + +Expand source code + +
    @property
    +def say(self) -> Say:
    +    """`say()` function for this request.
    +
    +        @app.action("button")
    +        def handle_button_clicks(context):
    +            context.ack()
    +            context.say("Hi!")
    +
    +        # You can access "ack" this way too.
    +        @app.action("button")
    +        def handle_button_clicks(ack, say):
    +            ack()
    +            say("Hi!")
    +
    +    Returns:
    +        Callable `say()` function
    +    """
    +    if "say" not in self:
    +        self["say"] = Say(client=self.client, channel=self.channel_id)
    +    return self["say"]
    +
    +
    +
    +

    Methods

    +
    +
    +def to_copyable(self) ‑> BoltContext +
    +
    +
    +
    + +Expand source code + +
    def to_copyable(self) -> "BoltContext":
    +    new_dict = {}
    +    for prop_name, prop_value in self.items():
    +        if prop_name in self.standard_property_names:
    +            # all the standard properties are copiable
    +            new_dict[prop_name] = prop_value
    +        else:
    +            try:
    +                copied_value = create_copy(prop_value)
    +                new_dict[prop_name] = copied_value
    +            except TypeError as te:
    +                self.logger.warning(
    +                    f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
    +                    "due to a deep-copy creation error. Consider passing the value not as part of context object "
    +                    f"(error: {te})"
    +                )
    +    return BoltContext(new_dict)
    +
    +
    +
    +

    Inherited members

    + +
    +
    diff --git a/docs/api-docs/slack_bolt/context/respond/index.html b/docs/api-docs/slack_bolt/context/respond/index.html index 135e7242e..b38402ceb 100644 --- a/docs/api-docs/slack_bolt/context/respond/index.html +++ b/docs/api-docs/slack_bolt/context/respond/index.html @@ -27,7 +27,11 @@

    Module slack_bolt.context.respond

    Expand source code
    # Don't add async module imports here
    -from .respond import Respond  # noqa: F401
    +from .respond import Respond + +__all__ = [ + "Respond", +]
    @@ -52,6 +56,92 @@

    Sub-modules

    +

    Classes

    +
    +
    +class Respond +(*, response_url: Optional[str], proxy: Optional[str] = None, ssl: Optional[ssl.SSLContext] = None) +
    +
    +
    +
    + +Expand source code + +
    class Respond:
    +    response_url: Optional[str]
    +    proxy: Optional[str]
    +    ssl: Optional[SSLContext]
    +
    +    def __init__(
    +        self,
    +        *,
    +        response_url: Optional[str],
    +        proxy: Optional[str] = None,
    +        ssl: Optional[SSLContext] = None,
    +    ):
    +        self.response_url = response_url
    +        self.proxy = proxy
    +        self.ssl = ssl
    +
    +    def __call__(
    +        self,
    +        text: Union[str, dict] = "",
    +        blocks: Optional[Sequence[Union[dict, Block]]] = None,
    +        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
    +        response_type: Optional[str] = None,
    +        replace_original: Optional[bool] = None,
    +        delete_original: Optional[bool] = None,
    +        unfurl_links: Optional[bool] = None,
    +        unfurl_media: Optional[bool] = None,
    +    ) -> WebhookResponse:
    +        if self.response_url is not None:
    +            client = WebhookClient(
    +                url=self.response_url,
    +                proxy=self.proxy,
    +                ssl=self.ssl,
    +            )
    +            text_or_whole_response: Union[str, dict] = text
    +            if isinstance(text_or_whole_response, str):
    +                text = text_or_whole_response
    +                message = _build_message(
    +                    text=text,
    +                    blocks=blocks,
    +                    attachments=attachments,
    +                    response_type=response_type,
    +                    replace_original=replace_original,
    +                    delete_original=delete_original,
    +                    unfurl_links=unfurl_links,
    +                    unfurl_media=unfurl_media,
    +                )
    +                return client.send_dict(message)
    +            elif isinstance(text_or_whole_response, dict):
    +                message = _build_message(**text_or_whole_response)
    +                return client.send_dict(message)
    +            else:
    +                raise ValueError(
    +                    f"The arg is unexpected type ({type(text_or_whole_response)})"
    +                )
    +        else:
    +            raise ValueError("respond is unsupported here as there is no response_url")
    +
    +

    Class variables

    +
    +
    var proxy : Optional[str]
    +
    +
    +
    +
    var response_url : Optional[str]
    +
    +
    +
    +
    var ssl : Optional[ssl.SSLContext]
    +
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/context/say/index.html b/docs/api-docs/slack_bolt/context/say/index.html index 9b3ef82c5..7dee26747 100644 --- a/docs/api-docs/slack_bolt/context/say/index.html +++ b/docs/api-docs/slack_bolt/context/say/index.html @@ -27,7 +27,11 @@

    Module slack_bolt.context.say

    Expand source code
    # Don't add async module imports here
    -from .say import Say  # noqa: F401
    +from .say import Say + +__all__ = [ + "Say", +]
    @@ -52,6 +56,80 @@

    Sub-modules

    +

    Classes

    +
    +
    +class Say +(client: Optional[slack_sdk.web.client.WebClient], channel: Optional[str]) +
    +
    +
    +
    + +Expand source code + +
    class Say:
    +    client: Optional[WebClient]
    +    channel: Optional[str]
    +
    +    def __init__(
    +        self,
    +        client: Optional[WebClient],
    +        channel: Optional[str],
    +    ):
    +        self.client = client
    +        self.channel = channel
    +
    +    def __call__(
    +        self,
    +        text: Union[str, dict] = "",
    +        blocks: Optional[Sequence[Union[Dict, Block]]] = None,
    +        attachments: Optional[Sequence[Union[Dict, Attachment]]] = None,
    +        channel: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +        unfurl_links: Optional[bool] = None,
    +        unfurl_media: Optional[bool] = None,
    +        **kwargs,
    +    ) -> SlackResponse:
    +        if _can_say(self, channel):
    +            text_or_whole_response: Union[str, dict] = text
    +            if isinstance(text_or_whole_response, str):
    +                text = text_or_whole_response
    +                return self.client.chat_postMessage(
    +                    channel=channel or self.channel,
    +                    text=text,
    +                    blocks=blocks,
    +                    attachments=attachments,
    +                    thread_ts=thread_ts,
    +                    unfurl_links=unfurl_links,
    +                    unfurl_media=unfurl_media,
    +                    **kwargs,
    +                )
    +            elif isinstance(text_or_whole_response, dict):
    +                message: dict = text_or_whole_response
    +                if "channel" not in message:
    +                    message["channel"] = channel or self.channel
    +                return self.client.chat_postMessage(**message)
    +            else:
    +                raise ValueError(
    +                    f"The arg is unexpected type ({type(text_or_whole_response)})"
    +                )
    +        else:
    +            raise ValueError("say without channel_id here is unsupported")
    +
    +

    Class variables

    +
    +
    var channel : Optional[str]
    +
    +
    +
    +
    var client : Optional[slack_sdk.web.client.WebClient]
    +
    +
    +
    +
    +
    +
    diff --git a/docs/api-docs/slack_bolt/index.html b/docs/api-docs/slack_bolt/index.html index ebaeecaae..2605518a6 100644 --- a/docs/api-docs/slack_bolt/index.html +++ b/docs/api-docs/slack_bolt/index.html @@ -40,16 +40,29 @@

    Package slack_bolt

    * The class representing a Bolt app: `slack_bolt.app.app` """ # noqa: E501 # Don't add async module imports here -from .app import App # noqa -from .context import BoltContext # noqa -from .context.ack import Ack # noqa -from .context.respond import Respond # noqa -from .context.say import Say # noqa -from .kwargs_injection import Args # noqa -from .listener import Listener # noqa -from .listener_matcher import CustomListenerMatcher # noqa -from .request import BoltRequest # noqa -from .response import BoltResponse # noqa +from .app import App +from .context import BoltContext +from .context.ack import Ack +from .context.respond import Respond +from .context.say import Say +from .kwargs_injection import Args +from .listener import Listener +from .listener_matcher import CustomListenerMatcher +from .request import BoltRequest +from .response import BoltResponse + +__all__ = [ + "App", + "BoltContext", + "Ack", + "Respond", + "Say", + "Args", + "Listener", + "CustomListenerMatcher", + "BoltRequest", + "BoltResponse", +]
    @@ -139,34 +152,4431 @@

    Sub-modules

    -
    - -
    + +

    trigger_id=body["trigger_id"], view={ ... } ) + +

    Alternatively, you can include a parameter named args and it will be injected with an instance of this class.

    +
    @app.action("link_button")
    +async def handle_buttons(args):
    +    args.logger.info(f"request body: {args.body}")
    +    await args.ack()
    +    if args.context.channel_id is not None:
    +        await args.respond("Hi!")
    +    await args.client.views_open(
    +        trigger_id=args.body["trigger_id"],
    +        view={ ... }
    +    )
     
    @@ -194,6 +219,19 @@

    Classes

    view={ ... } ) + Alternatively, you can include a parameter named `args` and it will be injected with an instance of this class. + + @app.action("link_button") + async def handle_buttons(args): + args.logger.info(f"request body: {args.body}") + await args.ack() + if args.context.channel_id is not None: + await args.respond("Hi!") + await args.client.views_open( + trigger_id=args.body["trigger_id"], + view={ ... } + ) + """ logger: Logger diff --git a/docs/api-docs/slack_bolt/kwargs_injection/async_utils.html b/docs/api-docs/slack_bolt/kwargs_injection/async_utils.html index 6a2c530ff..defdd3f5a 100644 --- a/docs/api-docs/slack_bolt/kwargs_injection/async_utils.html +++ b/docs/api-docs/slack_bolt/kwargs_injection/async_utils.html @@ -111,7 +111,7 @@

    Module slack_bolt.kwargs_injection.async_utilsModule slack_bolt.kwargs_injection.async_utils @@ -214,7 +214,7 @@

    Functions

    first_arg_name = required_arg_names[0] if first_arg_name in {"self", "cls"}: required_arg_names.pop(0) - elif first_arg_name not in all_available_args.keys(): + elif first_arg_name not in all_available_args.keys() and first_arg_name != "args": if this_func is None: logger.warning(warning_skip_uncommon_arg_name(first_arg_name)) required_arg_names.pop(0) @@ -231,7 +231,7 @@

    Functions

    else: logger.warning(f"Unknown Request object type detected ({type(request)})") - if name not in found_arg_names: + elif name not in found_arg_names: logger.warning(f"{name} is not a valid argument") kwargs[name] = None return kwargs diff --git a/docs/api-docs/slack_bolt/kwargs_injection/index.html b/docs/api-docs/slack_bolt/kwargs_injection/index.html index cb9c81689..223d1f153 100644 --- a/docs/api-docs/slack_bolt/kwargs_injection/index.html +++ b/docs/api-docs/slack_bolt/kwargs_injection/index.html @@ -144,7 +144,7 @@

    Functions

    first_arg_name = required_arg_names[0] if first_arg_name in {"self", "cls"}: required_arg_names.pop(0) - elif first_arg_name not in all_available_args.keys(): + elif first_arg_name not in all_available_args.keys() and first_arg_name != "args": if this_func is None: logger.warning(warning_skip_uncommon_arg_name(first_arg_name)) required_arg_names.pop(0) @@ -161,7 +161,7 @@

    Functions

    else: logger.warning(f"Unknown Request object type detected ({type(request)})") - if name not in found_arg_names: + elif name not in found_arg_names: logger.warning(f"{name} is not a valid argument") kwargs[name] = None return kwargs @@ -189,6 +189,18 @@

    Classes

    trigger_id=body["trigger_id"], view={ ... } ) + +

    Alternatively, you can include a parameter named slack_bolt.kwargs_injection.args and it will be injected with an instance of this class.

    +
    @app.action("link_button")
    +def handle_buttons(args):
    +    args.logger.info(f"request body: {args.body}")
    +    args.ack()
    +    if args.context.channel_id is not None:
    +        args.respond("Hi!")
    +    args.client.views_open(
    +        trigger_id=args.body["trigger_id"],
    +        view={ ... }
    +    )
     
    @@ -209,6 +221,19 @@

    Classes

    view={ ... } ) + Alternatively, you can include a parameter named `args` and it will be injected with an instance of this class. + + @app.action("link_button") + def handle_buttons(args): + args.logger.info(f"request body: {args.body}") + args.ack() + if args.context.channel_id is not None: + args.respond("Hi!") + args.client.views_open( + trigger_id=args.body["trigger_id"], + view={ ... } + ) + """ client: WebClient diff --git a/docs/api-docs/slack_bolt/kwargs_injection/utils.html b/docs/api-docs/slack_bolt/kwargs_injection/utils.html index b1f87d01e..6fee6e4fc 100644 --- a/docs/api-docs/slack_bolt/kwargs_injection/utils.html +++ b/docs/api-docs/slack_bolt/kwargs_injection/utils.html @@ -111,7 +111,7 @@

    Module slack_bolt.kwargs_injection.utils

    first_arg_name = required_arg_names[0] if first_arg_name in {"self", "cls"}: required_arg_names.pop(0) - elif first_arg_name not in all_available_args.keys(): + elif first_arg_name not in all_available_args.keys() and first_arg_name != "args": if this_func is None: logger.warning(warning_skip_uncommon_arg_name(first_arg_name)) required_arg_names.pop(0) @@ -128,7 +128,7 @@

    Module slack_bolt.kwargs_injection.utils

    else: logger.warning(f"Unknown Request object type detected ({type(request)})") - if name not in found_arg_names: + elif name not in found_arg_names: logger.warning(f"{name} is not a valid argument") kwargs[name] = None return kwargs @@ -214,7 +214,7 @@

    Functions

    first_arg_name = required_arg_names[0] if first_arg_name in {"self", "cls"}: required_arg_names.pop(0) - elif first_arg_name not in all_available_args.keys(): + elif first_arg_name not in all_available_args.keys() and first_arg_name != "args": if this_func is None: logger.warning(warning_skip_uncommon_arg_name(first_arg_name)) required_arg_names.pop(0) @@ -231,7 +231,7 @@

    Functions

    else: logger.warning(f"Unknown Request object type detected ({type(request)})") - if name not in found_arg_names: + elif name not in found_arg_names: logger.warning(f"{name} is not a valid argument") kwargs[name] = None return kwargs diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index 3c7de3a13..61fa013d0 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.16.2"
    +__version__ = "1.16.3"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index e37cf15a5..7d855cdd8 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.16.2" +__version__ = "1.16.3" From ade7bc4951b54a28ed5e4569c6d60c86a1c8ce50 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 10 Mar 2023 20:40:38 +0900 Subject: [PATCH 560/865] Enable developers to pass fully implemented authorize along with installation_store (#851) * Enable developers to pass fully implemented authorize along with installation_store * Fix the CI build error with Python 3.6 due to Chalice 1.28 --- setup.py | 3 +- slack_bolt/app/app.py | 13 +- slack_bolt/app/async_app.py | 14 +- .../test_app_custom_authorize.py | 254 +++++++++++++++++ .../test_app_custom_authorize.py | 266 ++++++++++++++++++ 5 files changed, 542 insertions(+), 8 deletions(-) create mode 100644 tests/scenario_tests/test_app_custom_authorize.py create mode 100644 tests/scenario_tests_async/test_app_custom_authorize.py diff --git a/setup.py b/setup.py index ef7556d1e..e91bd8e2b 100755 --- a/setup.py +++ b/setup.py @@ -77,7 +77,8 @@ # used only under src/slack_bolt/adapter "boto3<=2", "bottle>=0.12,<1", - "chalice>=1.27.3,<2", + # TODO: chalice 1.28 dropped Python 3.6 support + "chalice<=1.27.3", "CherryPy>=18,<19", "Django>=3,<5", "falcon>=3.1.1,<4" if sys.version_info.minor >= 11 else "falcon>=2,<4", diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 6e5293244..1a923e35e 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -217,9 +217,16 @@ def message_hello(message, say): self._authorize: Optional[Authorize] = None if authorize is not None: - if oauth_settings is not None or oauth_flow is not None: - raise BoltError(error_authorize_conflicts()) - self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize) + if isinstance(authorize, Authorize): + # As long as an advanced developer understands what they're doing, + # bolt-python should not prevent customizing authorize middleware + self._authorize = authorize + else: + if oauth_settings is not None or oauth_flow is not None: + # If the given authorize is a simple function, + # it does not work along with installation_store. + raise BoltError(error_authorize_conflicts()) + self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize) self._installation_store: Optional[InstallationStore] = installation_store if self._installation_store is not None and self._authorize is None: diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 39e8a659c..5b5c52338 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -222,10 +222,16 @@ async def message_hello(message, say): # async function self._async_authorize: Optional[AsyncAuthorize] = None if authorize is not None: - if oauth_settings is not None or oauth_flow is not None: - raise BoltError(error_authorize_conflicts()) - - self._async_authorize = AsyncCallableAuthorize(logger=self._framework_logger, func=authorize) + if isinstance(authorize, AsyncAuthorize): + # As long as an advanced developer understands what they're doing, + # bolt-python should not prevent customizing authorize middleware + self._async_authorize = authorize + else: + if oauth_settings is not None or oauth_flow is not None: + # If the given authorize is a simple function, + # it does not work along with installation_store. + raise BoltError(error_authorize_conflicts()) + self._async_authorize = AsyncCallableAuthorize(logger=self._framework_logger, func=authorize) self._async_installation_store: Optional[AsyncInstallationStore] = installation_store if self._async_installation_store is not None and self._async_authorize is None: diff --git a/tests/scenario_tests/test_app_custom_authorize.py b/tests/scenario_tests/test_app_custom_authorize.py new file mode 100644 index 000000000..e6293d695 --- /dev/null +++ b/tests/scenario_tests/test_app_custom_authorize.py @@ -0,0 +1,254 @@ +import datetime +import json +import logging +from time import time, sleep +from typing import Optional + +import pytest +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import App, BoltRequest, Say, BoltContext +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.authorization.authorize import Authorize +from slack_bolt.error import BoltError +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class MemoryInstallationStore(InstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class CustomAuthorize(Authorize): + def __init__(self, installation_store: InstallationStore): + self.installation_store = installation_store + + def __call__( + self, + *, + context: BoltContext, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str], + ) -> Optional[AuthorizeResult]: + bot_token: Optional[str] = None + user_token: Optional[str] = None + latest_installation: Optional[Installation] = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + this_user_installation: Optional[Installation] = None + if latest_installation is not None: + bot_token = latest_installation.bot_token # this still can be None + user_token = latest_installation.user_token # this still can be None + if latest_installation.user_id != user_id: + # First off, remove the user token as the installer is a different user + user_token = None + latest_installation.user_token = None + latest_installation.user_refresh_token = None + latest_installation.user_token_expires_at = None + latest_installation.user_scopes = [] + + # try to fetch the request user's installation + # to reflect the user's access token if exists + this_user_installation = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) + if this_user_installation is not None: + user_token = this_user_installation.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = this_user_installation.bot_token + token: Optional[str] = bot_token or user_token + if token is None: + return None + try: + auth_test_api_response = context.client.auth_test(token=token) + authorize_result = AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test_api_response, + bot_token=bot_token, + user_token=user_token, + ) + return authorize_result + except SlackApiError: + return None + + +class TestApp: + 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_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + app_mention_request_body = { + "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"], + } + + @staticmethod + def handle_app_mention(body, say: Say, payload, event): + assert body["event"] == payload + assert payload == event + say("What's up?") + + def build_app_mention_request(self): + timestamp, body = str(int(time())), json.dumps(self.app_mention_request_body) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_installation_store_only(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="secret", + scopes=["commands"], + installation_store=MemoryInstallationStore(), + ), + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_installation_store_and_authorize(self): + installation_store = MemoryInstallationStore() + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="secret", + scopes=["commands"], + installation_store=installation_store, + ), + authorize=CustomAuthorize(installation_store), + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_installation_store_and_func_authorize(self): + installation_store = MemoryInstallationStore() + + def authorize(): + pass + + with pytest.raises(BoltError): + App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="secret", + scopes=["commands"], + installation_store=installation_store, + ), + authorize=authorize, + ) diff --git a/tests/scenario_tests_async/test_app_custom_authorize.py b/tests/scenario_tests_async/test_app_custom_authorize.py new file mode 100644 index 000000000..43fab3f6c --- /dev/null +++ b/tests/scenario_tests_async/test_app_custom_authorize.py @@ -0,0 +1,266 @@ +import asyncio +import datetime +import json +import logging +from time import time +from typing import Optional + +import pytest +from slack_sdk.errors import SlackApiError +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.authorization.async_authorize import AsyncAuthorize +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.error import BoltError +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +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, + assert_auth_test_count_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAppCustomAuthorize: + 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, + ) + + @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) + + 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_app_mention_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(app_mention_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_installation_store_only(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=MemoryInstallationStore(), + ), + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_installation_store_and_authorize(self): + installation_store = MemoryInstallationStore() + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=CustomAuthorize(installation_store), + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=installation_store, + ), + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_installation_store_and_func_authorize(self): + installation_store = MemoryInstallationStore() + + async def authorize(): + pass + + with pytest.raises(BoltError): + AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=installation_store, + ), + ) + + +app_mention_body = { + "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"], +} + + +async def whats_up(body, say, payload, event): + assert body["event"] == payload + assert payload == event + await say("What's up?") + + +class MemoryInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class CustomAuthorize(AsyncAuthorize): + def __init__(self, installation_store: AsyncInstallationStore): + self.installation_store = installation_store + + async def __call__( + self, + *, + context: AsyncBoltContext, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str], + ) -> Optional[AuthorizeResult]: + bot_token: Optional[str] = None + user_token: Optional[str] = None + latest_installation: Optional[Installation] = await self.installation_store.async_find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + this_user_installation: Optional[Installation] = None + if latest_installation is not None: + bot_token = latest_installation.bot_token # this still can be None + user_token = latest_installation.user_token # this still can be None + if latest_installation.user_id != user_id: + # First off, remove the user token as the installer is a different user + user_token = None + latest_installation.user_token = None + latest_installation.user_refresh_token = None + latest_installation.user_token_expires_at = None + latest_installation.user_scopes = [] + + # try to fetch the request user's installation + # to reflect the user's access token if exists + this_user_installation = await self.installation_store.async_find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) + if this_user_installation is not None: + user_token = this_user_installation.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = this_user_installation.bot_token + token: Optional[str] = bot_token or user_token + if token is None: + return None + try: + auth_test_api_response = await context.client.auth_test(token=token) + authorize_result = AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test_api_response, + bot_token=bot_token, + user_token=user_token, + ) + return authorize_result + except SlackApiError: + return None From 36ab652183fe5bab38a5ede918f6fd2b2fd43bbc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 10 Mar 2023 20:43:37 +0900 Subject: [PATCH 561/865] Enable developers to define app.message listener without args to capture all messages (#848) --- slack_bolt/app/app.py | 2 +- slack_bolt/app/async_app.py | 2 +- tests/scenario_tests/test_message.py | 34 ++++++++++++++++++++ tests/scenario_tests_async/test_message.py | 36 ++++++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 1a923e35e..4d27ddfec 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -743,7 +743,7 @@ def __call__(*args, **kwargs): def message( self, - keyword: Union[str, Pattern], + keyword: Union[str, Pattern] = "", matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 5b5c52338..ae224e341 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -773,7 +773,7 @@ def __call__(*args, **kwargs): def message( self, - keyword: Union[str, Pattern], + keyword: Union[str, Pattern] = "", matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: diff --git a/tests/scenario_tests/test_message.py b/tests/scenario_tests/test_message.py index 33ae0d945..8aa073809 100644 --- a/tests/scenario_tests/test_message.py +++ b/tests/scenario_tests/test_message.py @@ -74,6 +74,40 @@ def test_string_keyword(self): time.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 + def test_all_message_matching_1(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.message("") + def handle_all_new_messages(say): + say("Thanks!") + + request = self.build_request2() + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + time.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_all_message_matching_2(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.message() + def handle_all_new_messages(say): + say("Thanks!") + + request = self.build_request2() + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + time.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + def test_string_keyword_capturing(self): app = App( client=self.web_client, diff --git a/tests/scenario_tests_async/test_message.py b/tests/scenario_tests_async/test_message.py index 5d98ff014..c11c3e8c8 100644 --- a/tests/scenario_tests_async/test_message.py +++ b/tests/scenario_tests_async/test_message.py @@ -81,6 +81,42 @@ async def test_string_keyword(self): await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 + @pytest.mark.asyncio + async def test_all_message_matching_1(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.message("") + async def handle_all_new_messages(say): + await say("Thanks!") + + request = self.build_request2() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_all_message_matching_2(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.message() + async def handle_all_new_messages(say): + await say("Thanks!") + + request = self.build_request2() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + @pytest.mark.asyncio async def test_string_keyword_capturing(self): app = AsyncApp( From c6f3e9a1126f250055ff642380131315e457f8c8 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 10 Mar 2023 21:08:41 +0900 Subject: [PATCH 562/865] Fix #850 by upgrading slack-sdk version to the latest (#852) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e91bd8e2b..ba1c1d367 100755 --- a/setup.py +++ b/setup.py @@ -58,7 +58,7 @@ ), include_package_data=True, # MANIFEST.in install_requires=[ - "slack_sdk>=3.18.5,<4", + "slack_sdk>=3.20.2,<4", ], setup_requires=["pytest-runner==5.2"], tests_require=async_test_dependencies, From 99e77eb5946184890b6fc6f7d97c1ef11188c673 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 10 Mar 2023 23:41:10 +0900 Subject: [PATCH 563/865] Add team param support to the /slack/install endpoint (#853) --- slack_bolt/oauth/async_oauth_flow.py | 6 +++++- slack_bolt/oauth/oauth_flow.py | 6 +++++- tests/slack_bolt/oauth/test_oauth_flow.py | 20 +++++++++++++++++++ .../oauth/test_async_oauth_flow.py | 20 +++++++++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 57ae284e1..f35d394e1 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -194,7 +194,11 @@ async def issue_new_state(self, request: AsyncBoltRequest) -> str: return await self.settings.state_store.async_issue() async def build_authorize_url(self, state: str, request: AsyncBoltRequest) -> str: - return self.settings.authorize_url_generator.generate(state) + team_ids: Optional[Sequence[str]] = request.query.get("team") + return self.settings.authorize_url_generator.generate( + state=state, + team=team_ids[0] if team_ids is not None else None, + ) async def build_install_page_html(self, url: str, request: AsyncBoltRequest) -> str: return _build_default_install_page_html(url) diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index 91be30b6f..6e00cc1c7 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -192,7 +192,11 @@ def issue_new_state(self, request: BoltRequest) -> str: return self.settings.state_store.issue() def build_authorize_url(self, state: str, request: BoltRequest) -> str: - return self.settings.authorize_url_generator.generate(state) + team_ids: Optional[Sequence[str]] = request.query.get("team") + return self.settings.authorize_url_generator.generate( + state=state, + team=team_ids[0] if team_ids is not None else None, + ) def build_install_page_html(self, url: str, request: BoltRequest) -> str: return _build_default_install_page_html(url) diff --git a/tests/slack_bolt/oauth/test_oauth_flow.py b/tests/slack_bolt/oauth/test_oauth_flow.py index b6e557d91..77258685d 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow.py +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -83,6 +83,26 @@ def test_handle_installation_no_rendering(self): assert "https://slack.com/oauth/v2/authorize?state=" in location_header assert resp.headers.get("set-cookie") is not None + def test_handle_installation_team_param(self): + oauth_flow = OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + user_scopes=["search:read"], + installation_store=FileInstallationStore(), + install_page_rendering_enabled=False, # disabled + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + req = BoltRequest(body="", query={"team": "T12345"}) + resp = oauth_flow.handle_installation(req) + assert resp.status == 302 + location_header = resp.headers.get("location")[0] + assert "https://slack.com/oauth/v2/authorize?state=" in location_header + assert "&team=T12345" in location_header + assert resp.headers.get("set-cookie") is not None + def test_handle_installation_no_state_validation(self): oauth_flow = OAuthFlow( settings=OAuthSettings( diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index 2f8fb83ec..43ee7b3da 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -130,6 +130,26 @@ async def test_handle_installation_no_rendering(self): assert "https://slack.com/oauth/v2/authorize?state=" in location_header assert resp.headers.get("set-cookie") is not None + @pytest.mark.asyncio + async def test_handle_installation_team_param(self): + oauth_flow = AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + install_page_rendering_enabled=False, # disabled + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + req = AsyncBoltRequest(body="", query={"team": "T12345"}) + resp = await oauth_flow.handle_installation(req) + assert resp.status == 302 + location_header = resp.headers.get("location")[0] + assert "https://slack.com/oauth/v2/authorize?state=" in location_header + assert "&team=T12345" in location_header + assert resp.headers.get("set-cookie") is not None + @pytest.mark.asyncio async def test_handle_installation_no_state_validation(self): oauth_flow = AsyncOAuthFlow( From 20d250f252f28bd9b9af4790dcbdbe40e89473af Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 11 Mar 2023 00:01:30 +0900 Subject: [PATCH 564/865] version 1.16.4 --- docs/api-docs/slack_bolt/app/app.html | 34 ++++++++++++------ docs/api-docs/slack_bolt/app/async_app.html | 36 ++++++++++++------- docs/api-docs/slack_bolt/app/index.html | 19 ++++++---- docs/api-docs/slack_bolt/async_app.html | 20 +++++++---- docs/api-docs/slack_bolt/index.html | 19 ++++++---- .../slack_bolt/oauth/async_oauth_flow.html | 18 ++++++++-- docs/api-docs/slack_bolt/oauth/index.html | 12 +++++-- .../api-docs/slack_bolt/oauth/oauth_flow.html | 18 ++++++++-- docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 10 files changed, 129 insertions(+), 51 deletions(-) diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index c1d42dc8e..1963d09b4 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -245,9 +245,16 @@

    Module slack_bolt.app.app

    self._authorize: Optional[Authorize] = None if authorize is not None: - if oauth_settings is not None or oauth_flow is not None: - raise BoltError(error_authorize_conflicts()) - self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize) + if isinstance(authorize, Authorize): + # As long as an advanced developer understands what they're doing, + # bolt-python should not prevent customizing authorize middleware + self._authorize = authorize + else: + if oauth_settings is not None or oauth_flow is not None: + # If the given authorize is a simple function, + # it does not work along with installation_store. + raise BoltError(error_authorize_conflicts()) + self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize) self._installation_store: Optional[InstallationStore] = installation_store if self._installation_store is not None and self._authorize is None: @@ -764,7 +771,7 @@

    Module slack_bolt.app.app

    def message( self, - keyword: Union[str, Pattern], + keyword: Union[str, Pattern] = "", matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: @@ -1669,9 +1676,16 @@

    Args

    self._authorize: Optional[Authorize] = None if authorize is not None: - if oauth_settings is not None or oauth_flow is not None: - raise BoltError(error_authorize_conflicts()) - self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize) + if isinstance(authorize, Authorize): + # As long as an advanced developer understands what they're doing, + # bolt-python should not prevent customizing authorize middleware + self._authorize = authorize + else: + if oauth_settings is not None or oauth_flow is not None: + # If the given authorize is a simple function, + # it does not work along with installation_store. + raise BoltError(error_authorize_conflicts()) + self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize) self._installation_store: Optional[InstallationStore] = installation_store if self._installation_store is not None and self._authorize is None: @@ -2188,7 +2202,7 @@

    Args

    def message( self, - keyword: Union[str, Pattern], + keyword: Union[str, Pattern] = "", matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: @@ -3455,7 +3469,7 @@

    Args

    -def message(self, keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +def message(self, keyword: Union[str, Pattern] = '', matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new message event listener. This method can be used as either a decorator or a method. @@ -3488,7 +3502,7 @@

    Args

    def message(
         self,
    -    keyword: Union[str, Pattern],
    +    keyword: Union[str, Pattern] = "",
         matchers: Optional[Sequence[Callable[..., bool]]] = None,
         middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
     ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html
    index 24a277331..0fdf7f437 100644
    --- a/docs/api-docs/slack_bolt/app/async_app.html
    +++ b/docs/api-docs/slack_bolt/app/async_app.html
    @@ -250,10 +250,16 @@ 

    Module slack_bolt.app.async_app

    self._async_authorize: Optional[AsyncAuthorize] = None if authorize is not None: - if oauth_settings is not None or oauth_flow is not None: - raise BoltError(error_authorize_conflicts()) - - self._async_authorize = AsyncCallableAuthorize(logger=self._framework_logger, func=authorize) + if isinstance(authorize, AsyncAuthorize): + # As long as an advanced developer understands what they're doing, + # bolt-python should not prevent customizing authorize middleware + self._async_authorize = authorize + else: + if oauth_settings is not None or oauth_flow is not None: + # If the given authorize is a simple function, + # it does not work along with installation_store. + raise BoltError(error_authorize_conflicts()) + self._async_authorize = AsyncCallableAuthorize(logger=self._framework_logger, func=authorize) self._async_installation_store: Optional[AsyncInstallationStore] = installation_store if self._async_installation_store is not None and self._async_authorize is None: @@ -795,7 +801,7 @@

    Module slack_bolt.app.async_app

    def message( self, - keyword: Union[str, Pattern], + keyword: Union[str, Pattern] = "", matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: @@ -1572,10 +1578,16 @@

    Args

    self._async_authorize: Optional[AsyncAuthorize] = None if authorize is not None: - if oauth_settings is not None or oauth_flow is not None: - raise BoltError(error_authorize_conflicts()) - - self._async_authorize = AsyncCallableAuthorize(logger=self._framework_logger, func=authorize) + if isinstance(authorize, AsyncAuthorize): + # As long as an advanced developer understands what they're doing, + # bolt-python should not prevent customizing authorize middleware + self._async_authorize = authorize + else: + if oauth_settings is not None or oauth_flow is not None: + # If the given authorize is a simple function, + # it does not work along with installation_store. + raise BoltError(error_authorize_conflicts()) + self._async_authorize = AsyncCallableAuthorize(logger=self._framework_logger, func=authorize) self._async_installation_store: Optional[AsyncInstallationStore] = installation_store if self._async_installation_store is not None and self._async_authorize is None: @@ -2117,7 +2129,7 @@

    Args

    def message( self, - keyword: Union[str, Pattern], + keyword: Union[str, Pattern] = "", matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: @@ -3413,7 +3425,7 @@

    Args

    -def message(self, keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +def message(self, keyword: Union[str, Pattern] = '', matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new message event listener. This method can be used as either a decorator or a method. @@ -3446,7 +3458,7 @@

    Args

    def message(
         self,
    -    keyword: Union[str, Pattern],
    +    keyword: Union[str, Pattern] = "",
         matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
         middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
     ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    diff --git a/docs/api-docs/slack_bolt/app/index.html b/docs/api-docs/slack_bolt/app/index.html
    index cd86807b5..d9332bacd 100644
    --- a/docs/api-docs/slack_bolt/app/index.html
    +++ b/docs/api-docs/slack_bolt/app/index.html
    @@ -292,9 +292,16 @@ 

    Args

    self._authorize: Optional[Authorize] = None if authorize is not None: - if oauth_settings is not None or oauth_flow is not None: - raise BoltError(error_authorize_conflicts()) - self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize) + if isinstance(authorize, Authorize): + # As long as an advanced developer understands what they're doing, + # bolt-python should not prevent customizing authorize middleware + self._authorize = authorize + else: + if oauth_settings is not None or oauth_flow is not None: + # If the given authorize is a simple function, + # it does not work along with installation_store. + raise BoltError(error_authorize_conflicts()) + self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize) self._installation_store: Optional[InstallationStore] = installation_store if self._installation_store is not None and self._authorize is None: @@ -811,7 +818,7 @@

    Args

    def message( self, - keyword: Union[str, Pattern], + keyword: Union[str, Pattern] = "", matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: @@ -2078,7 +2085,7 @@

    Args

    -def message(self, keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +def message(self, keyword: Union[str, Pattern] = '', matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new message event listener. This method can be used as either a decorator or a method. @@ -2111,7 +2118,7 @@

    Args

    def message(
         self,
    -    keyword: Union[str, Pattern],
    +    keyword: Union[str, Pattern] = "",
         matchers: Optional[Sequence[Callable[..., bool]]] = None,
         middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
     ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    diff --git a/docs/api-docs/slack_bolt/async_app.html b/docs/api-docs/slack_bolt/async_app.html
    index 34e435430..725f5929d 100644
    --- a/docs/api-docs/slack_bolt/async_app.html
    +++ b/docs/api-docs/slack_bolt/async_app.html
    @@ -403,10 +403,16 @@ 

    Args

    self._async_authorize: Optional[AsyncAuthorize] = None if authorize is not None: - if oauth_settings is not None or oauth_flow is not None: - raise BoltError(error_authorize_conflicts()) - - self._async_authorize = AsyncCallableAuthorize(logger=self._framework_logger, func=authorize) + if isinstance(authorize, AsyncAuthorize): + # As long as an advanced developer understands what they're doing, + # bolt-python should not prevent customizing authorize middleware + self._async_authorize = authorize + else: + if oauth_settings is not None or oauth_flow is not None: + # If the given authorize is a simple function, + # it does not work along with installation_store. + raise BoltError(error_authorize_conflicts()) + self._async_authorize = AsyncCallableAuthorize(logger=self._framework_logger, func=authorize) self._async_installation_store: Optional[AsyncInstallationStore] = installation_store if self._async_installation_store is not None and self._async_authorize is None: @@ -948,7 +954,7 @@

    Args

    def message( self, - keyword: Union[str, Pattern], + keyword: Union[str, Pattern] = "", matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: @@ -2244,7 +2250,7 @@

    Args

    -def message(self, keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]] +def message(self, keyword: Union[str, Pattern] = '', matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None) ‑> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]

    Registers a new message event listener. This method can be used as either a decorator or a method. @@ -2277,7 +2283,7 @@

    Args

    def message(
         self,
    -    keyword: Union[str, Pattern],
    +    keyword: Union[str, Pattern] = "",
         matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
         middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
     ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
    diff --git a/docs/api-docs/slack_bolt/index.html b/docs/api-docs/slack_bolt/index.html
    index ad313457a..b980a5f93 100644
    --- a/docs/api-docs/slack_bolt/index.html
    +++ b/docs/api-docs/slack_bolt/index.html
    @@ -430,9 +430,16 @@ 

    Args

    self._authorize: Optional[Authorize] = None if authorize is not None: - if oauth_settings is not None or oauth_flow is not None: - raise BoltError(error_authorize_conflicts()) - self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize) + if isinstance(authorize, Authorize): + # As long as an advanced developer understands what they're doing, + # bolt-python should not prevent customizing authorize middleware + self._authorize = authorize + else: + if oauth_settings is not None or oauth_flow is not None: + # If the given authorize is a simple function, + # it does not work along with installation_store. + raise BoltError(error_authorize_conflicts()) + self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize) self._installation_store: Optional[InstallationStore] = installation_store if self._installation_store is not None and self._authorize is None: @@ -949,7 +956,7 @@

    Args

    def message( self, - keyword: Union[str, Pattern], + keyword: Union[str, Pattern] = "", matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: @@ -2216,7 +2223,7 @@

    Args

    -def message(self, keyword: Union[str, Pattern], matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]] +def message(self, keyword: Union[str, Pattern] = '', matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None) ‑> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]

    Registers a new message event listener. This method can be used as either a decorator or a method. @@ -2249,7 +2256,7 @@

    Args

    def message(
         self,
    -    keyword: Union[str, Pattern],
    +    keyword: Union[str, Pattern] = "",
         matchers: Optional[Sequence[Callable[..., bool]]] = None,
         middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
     ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
    diff --git a/docs/api-docs/slack_bolt/oauth/async_oauth_flow.html b/docs/api-docs/slack_bolt/oauth/async_oauth_flow.html
    index 198eab734..95e51ffab 100644
    --- a/docs/api-docs/slack_bolt/oauth/async_oauth_flow.html
    +++ b/docs/api-docs/slack_bolt/oauth/async_oauth_flow.html
    @@ -222,7 +222,11 @@ 

    Module slack_bolt.oauth.async_oauth_flow

    return await self.settings.state_store.async_issue() async def build_authorize_url(self, state: str, request: AsyncBoltRequest) -> str: - return self.settings.authorize_url_generator.generate(state) + team_ids: Optional[Sequence[str]] = request.query.get("team") + return self.settings.authorize_url_generator.generate( + state=state, + team=team_ids[0] if team_ids is not None else None, + ) async def build_install_page_html(self, url: str, request: AsyncBoltRequest) -> str: return _build_default_install_page_html(url) @@ -587,7 +591,11 @@

    Args

    return await self.settings.state_store.async_issue() async def build_authorize_url(self, state: str, request: AsyncBoltRequest) -> str: - return self.settings.authorize_url_generator.generate(state) + team_ids: Optional[Sequence[str]] = request.query.get("team") + return self.settings.authorize_url_generator.generate( + state=state, + team=team_ids[0] if team_ids is not None else None, + ) async def build_install_page_html(self, url: str, request: AsyncBoltRequest) -> str: return _build_default_install_page_html(url) @@ -923,7 +931,11 @@

    Methods

    Expand source code
    async def build_authorize_url(self, state: str, request: AsyncBoltRequest) -> str:
    -    return self.settings.authorize_url_generator.generate(state)
    + team_ids: Optional[Sequence[str]] = request.query.get("team") + return self.settings.authorize_url_generator.generate( + state=state, + team=team_ids[0] if team_ids is not None else None, + )
    diff --git a/docs/api-docs/slack_bolt/oauth/index.html b/docs/api-docs/slack_bolt/oauth/index.html index 193eff286..a3e8f07c7 100644 --- a/docs/api-docs/slack_bolt/oauth/index.html +++ b/docs/api-docs/slack_bolt/oauth/index.html @@ -271,7 +271,11 @@

    Args

    return self.settings.state_store.issue() def build_authorize_url(self, state: str, request: BoltRequest) -> str: - return self.settings.authorize_url_generator.generate(state) + team_ids: Optional[Sequence[str]] = request.query.get("team") + return self.settings.authorize_url_generator.generate( + state=state, + team=team_ids[0] if team_ids is not None else None, + ) def build_install_page_html(self, url: str, request: BoltRequest) -> str: return _build_default_install_page_html(url) @@ -613,7 +617,11 @@

    Methods

    Expand source code
    def build_authorize_url(self, state: str, request: BoltRequest) -> str:
    -    return self.settings.authorize_url_generator.generate(state)
    + team_ids: Optional[Sequence[str]] = request.query.get("team") + return self.settings.authorize_url_generator.generate( + state=state, + team=team_ids[0] if team_ids is not None else None, + )
    diff --git a/docs/api-docs/slack_bolt/oauth/oauth_flow.html b/docs/api-docs/slack_bolt/oauth/oauth_flow.html index 094196a5a..42d823607 100644 --- a/docs/api-docs/slack_bolt/oauth/oauth_flow.html +++ b/docs/api-docs/slack_bolt/oauth/oauth_flow.html @@ -220,7 +220,11 @@

    Module slack_bolt.oauth.oauth_flow

    return self.settings.state_store.issue() def build_authorize_url(self, state: str, request: BoltRequest) -> str: - return self.settings.authorize_url_generator.generate(state) + team_ids: Optional[Sequence[str]] = request.query.get("team") + return self.settings.authorize_url_generator.generate( + state=state, + team=team_ids[0] if team_ids is not None else None, + ) def build_install_page_html(self, url: str, request: BoltRequest) -> str: return _build_default_install_page_html(url) @@ -584,7 +588,11 @@

    Args

    return self.settings.state_store.issue() def build_authorize_url(self, state: str, request: BoltRequest) -> str: - return self.settings.authorize_url_generator.generate(state) + team_ids: Optional[Sequence[str]] = request.query.get("team") + return self.settings.authorize_url_generator.generate( + state=state, + team=team_ids[0] if team_ids is not None else None, + ) def build_install_page_html(self, url: str, request: BoltRequest) -> str: return _build_default_install_page_html(url) @@ -926,7 +934,11 @@

    Methods

    Expand source code
    def build_authorize_url(self, state: str, request: BoltRequest) -> str:
    -    return self.settings.authorize_url_generator.generate(state)
    + team_ids: Optional[Sequence[str]] = request.query.get("team") + return self.settings.authorize_url_generator.generate( + state=state, + team=team_ids[0] if team_ids is not None else None, + )
    diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index 61fa013d0..e287e2ea6 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.16.3"
    +__version__ = "1.16.4"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 7d855cdd8..1c0f78ef4 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.16.3" +__version__ = "1.16.4" From 2837e114814d062a810d2790aa13aa7d0a91e126 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 13 Mar 2023 13:13:20 +0900 Subject: [PATCH 565/865] Enhance AuthorizeResult to have bot/user_scopes & resolve user_id for user token (#855) Enhance AuthorizeResult to have bot/user_scopes when they're available (optional) Also, this commit fixes the bug where authorize_result.user_id is not resolved when both bot and user tokens exist. --- slack_bolt/authorization/async_authorize.py | 48 +++-- slack_bolt/authorization/authorize.py | 48 +++-- slack_bolt/authorization/authorize_result.py | 23 ++- .../test_app_installation_store.py | 155 ++++++++++++++++ .../test_app_installation_store.py | 167 ++++++++++++++++++ 5 files changed, 410 insertions(+), 31 deletions(-) create mode 100644 tests/scenario_tests/test_app_installation_store.py create mode 100644 tests/scenario_tests_async/test_app_installation_store.py diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index 75fb5093e..b6825c7ab 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -1,5 +1,5 @@ from logging import Logger -from typing import Optional, Callable, Awaitable, Dict, Any +from typing import Optional, Callable, Awaitable, Dict, Any, List from slack_sdk.errors import SlackApiError from slack_sdk.oauth.installation_store import Bot, Installation @@ -156,6 +156,10 @@ async def __call__( bot_token: Optional[str] = None user_token: Optional[str] = None + bot_scopes: Optional[List[str]] = None + user_scopes: Optional[List[str]] = None + latest_bot_installation: Optional[Installation] = None + this_user_installation: Optional[Installation] = None if not self.bot_only and self.find_installation_available: # Since v1.1, this is the default way. @@ -163,7 +167,7 @@ async def __call__( try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. - latest_installation: Optional[Installation] = await self.installation_store.async_find_installation( + latest_bot_installation = await self.installation_store.async_find_installation( enterprise_id=enterprise_id, team_id=team_id, is_enterprise_install=context.is_enterprise_install, @@ -173,20 +177,20 @@ async def __call__( # The example use cases are: # - The app's installation requires both bot and user tokens # - The app has two installation paths 1) bot installation 2) individual user authorization - this_user_installation: Optional[Installation] = None - - if latest_installation is not None: + if latest_bot_installation is not None: # Save the latest bot token - bot_token = latest_installation.bot_token # this still can be None - user_token = latest_installation.user_token # this still can be None + bot_token = latest_bot_installation.bot_token # this still can be None + user_token = latest_bot_installation.user_token # this still can be None + bot_scopes = latest_bot_installation.bot_scopes # this still can be None + user_scopes = latest_bot_installation.user_scopes # this still can be None - if latest_installation.user_id != user_id: + if latest_bot_installation.user_id != user_id: # First off, remove the user token as the installer is a different user user_token = None - latest_installation.user_token = None - latest_installation.user_refresh_token = None - latest_installation.user_token_expires_at = None - latest_installation.user_scopes = [] + latest_bot_installation.user_token = None + latest_bot_installation.user_refresh_token = None + latest_bot_installation.user_token_expires_at = None + latest_bot_installation.user_scopes = [] # try to fetch the request user's installation # to reflect the user's access token if exists @@ -198,26 +202,32 @@ async def __call__( ) if this_user_installation is not None: user_token = this_user_installation.user_token - if latest_installation.bot_token is None: + user_scopes = this_user_installation.user_scopes + if latest_bot_installation.bot_token is None: # If latest_installation has a bot token, we never overwrite the value bot_token = this_user_installation.bot_token + bot_scopes = this_user_installation.bot_scopes # If token rotation is enabled, running rotation may be needed here refreshed = await self._rotate_and_save_tokens_if_necessary(this_user_installation) if refreshed is not None: user_token = refreshed.user_token - if latest_installation.bot_token is None: + user_scopes = refreshed.user_scopes + if latest_bot_installation.bot_token is None: # If latest_installation has a bot token, we never overwrite the value bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes # If token rotation is enabled, running rotation may be needed here - refreshed = await self._rotate_and_save_tokens_if_necessary(latest_installation) + refreshed = await self._rotate_and_save_tokens_if_necessary(latest_bot_installation) if refreshed is not None: bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes if this_user_installation is None: # Only when we don't have `this_user_installation` here, # the `user_token` is for the user associated with this request user_token = refreshed.user_token + user_scopes = refreshed.user_scopes except NotImplementedError as _: self.find_installation_available = False @@ -238,6 +248,7 @@ async def __call__( ) if bot is not None: bot_token = bot.bot_token + bot_scopes = bot.bot_scopes if bot.bot_refresh_token is not None: # Token rotation if self.token_rotator is None: @@ -249,6 +260,7 @@ async def __call__( if refreshed is not None: await self.installation_store.async_save_bot(refreshed) bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes except NotImplementedError as _: self.find_bot_available = False @@ -267,10 +279,16 @@ async def __call__( try: auth_test_api_response = await context.client.auth_test(token=token) + user_auth_test_response = None + if user_token is not None and token != user_token: + user_auth_test_response = await context.client.auth_test(token=user_token) authorize_result = AuthorizeResult.from_auth_test_response( auth_test_response=auth_test_api_response, + user_auth_test_response=user_auth_test_response, bot_token=bot_token, user_token=user_token, + bot_scopes=bot_scopes, + user_scopes=user_scopes, ) if self.cache_enabled: self.authorize_result_cache[token] = authorize_result diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index a382f7097..4f113c9cc 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -1,5 +1,5 @@ from logging import Logger -from typing import Optional, Callable, Dict, Any +from typing import Optional, Callable, Dict, Any, List from slack_sdk.errors import SlackApiError from slack_sdk.oauth import InstallationStore @@ -156,6 +156,10 @@ def __call__( bot_token: Optional[str] = None user_token: Optional[str] = None + bot_scopes: Optional[List[str]] = None + user_scopes: Optional[List[str]] = None + latest_bot_installation: Optional[Installation] = None + this_user_installation: Optional[Installation] = None if not self.bot_only and self.find_installation_available: # Since v1.1, this is the default way. @@ -163,7 +167,7 @@ def __call__( try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. - latest_installation: Optional[Installation] = self.installation_store.find_installation( + latest_bot_installation = self.installation_store.find_installation( enterprise_id=enterprise_id, team_id=team_id, is_enterprise_install=context.is_enterprise_install, @@ -173,20 +177,20 @@ def __call__( # The example use cases are: # - The app's installation requires both bot and user tokens # - The app has two installation paths 1) bot installation 2) individual user authorization - this_user_installation: Optional[Installation] = None - - if latest_installation is not None: + if latest_bot_installation is not None: # Save the latest bot token - bot_token = latest_installation.bot_token # this still can be None - user_token = latest_installation.user_token # this still can be None + bot_token = latest_bot_installation.bot_token # this still can be None + user_token = latest_bot_installation.user_token # this still can be None + bot_scopes = latest_bot_installation.bot_scopes # this still can be None + user_scopes = latest_bot_installation.user_scopes # this still can be None - if latest_installation.user_id != user_id: + if latest_bot_installation.user_id != user_id: # First off, remove the user token as the installer is a different user user_token = None - latest_installation.user_token = None - latest_installation.user_refresh_token = None - latest_installation.user_token_expires_at = None - latest_installation.user_scopes = [] + latest_bot_installation.user_token = None + latest_bot_installation.user_refresh_token = None + latest_bot_installation.user_token_expires_at = None + latest_bot_installation.user_scopes = [] # try to fetch the request user's installation # to reflect the user's access token if exists @@ -198,26 +202,32 @@ def __call__( ) if this_user_installation is not None: user_token = this_user_installation.user_token - if latest_installation.bot_token is None: + user_scopes = this_user_installation.user_scopes + if latest_bot_installation.bot_token is None: # If latest_installation has a bot token, we never overwrite the value bot_token = this_user_installation.bot_token + bot_scopes = this_user_installation.bot_scopes # If token rotation is enabled, running rotation may be needed here refreshed = self._rotate_and_save_tokens_if_necessary(this_user_installation) if refreshed is not None: user_token = refreshed.user_token - if latest_installation.bot_token is None: + user_scopes = refreshed.user_scopes + if latest_bot_installation.bot_token is None: # If latest_installation has a bot token, we never overwrite the value bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes # If token rotation is enabled, running rotation may be needed here - refreshed = self._rotate_and_save_tokens_if_necessary(latest_installation) + refreshed = self._rotate_and_save_tokens_if_necessary(latest_bot_installation) if refreshed is not None: bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes if this_user_installation is None: # Only when we don't have `this_user_installation` here, # the `user_token` is for the user associated with this request user_token = refreshed.user_token + user_scopes = refreshed.user_scopes except NotImplementedError as _: self.find_installation_available = False @@ -238,6 +248,7 @@ def __call__( ) if bot is not None: bot_token = bot.bot_token + bot_scopes = bot.bot_scopes if bot.bot_refresh_token is not None: # Token rotation if self.token_rotator is None: @@ -249,6 +260,7 @@ def __call__( if refreshed is not None: self.installation_store.save_bot(refreshed) bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes except NotImplementedError as _: self.find_bot_available = False @@ -267,10 +279,16 @@ def __call__( try: auth_test_api_response = context.client.auth_test(token=token) + user_auth_test_response = None + if user_token is not None and token != user_token: + user_auth_test_response = context.client.auth_test(token=user_token) authorize_result = AuthorizeResult.from_auth_test_response( auth_test_response=auth_test_api_response, + user_auth_test_response=user_auth_test_response, bot_token=bot_token, user_token=user_token, + bot_scopes=bot_scopes, + user_scopes=user_scopes, ) if self.cache_enabled: self.authorize_result_cache[token] = authorize_result diff --git a/slack_bolt/authorization/authorize_result.py b/slack_bolt/authorization/authorize_result.py index c27be6dc8..032375519 100644 --- a/slack_bolt/authorization/authorize_result.py +++ b/slack_bolt/authorization/authorize_result.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, List, Union from slack_sdk.web import SlackResponse @@ -11,8 +11,10 @@ class AuthorizeResult(dict): bot_id: Optional[str] bot_user_id: Optional[str] bot_token: Optional[str] + bot_scopes: Optional[List[str]] # since v1.17 user_id: Optional[str] user_token: Optional[str] + user_scopes: Optional[List[str]] # since v1.17 def __init__( self, @@ -23,9 +25,11 @@ def __init__( bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, + bot_scopes: Optional[Union[List[str], str]] = None, # user user_id: Optional[str] = None, user_token: Optional[str] = None, + user_scopes: Optional[Union[List[str], str]] = None, ): """ Args: @@ -34,8 +38,10 @@ def __init__( bot_user_id: Bot user's User ID starting with either `U` or `W` bot_id: Bot ID starting with `B` bot_token: Bot user access token starting with `xoxb-` + bot_scopes: The scopes associated with the bot token user_id: The request user ID user_token: User access token starting with `xoxp-` + user_scopes: The scopes associated wth the user token """ self["enterprise_id"] = self.enterprise_id = enterprise_id self["team_id"] = self.team_id = team_id @@ -43,9 +49,15 @@ def __init__( self["bot_user_id"] = self.bot_user_id = bot_user_id self["bot_id"] = self.bot_id = bot_id self["bot_token"] = self.bot_token = bot_token + if bot_scopes is not None and isinstance(bot_scopes, str): + bot_scopes = [scope.strip() for scope in bot_scopes.split(",")] + self["bot_scopes"] = self.bot_scopes = bot_scopes # type: ignore # user self["user_id"] = self.user_id = user_id self["user_token"] = self.user_token = user_token + if user_scopes is not None and isinstance(user_scopes, str): + user_scopes = [scope.strip() for scope in user_scopes.split(",")] + self["user_scopes"] = self.user_scopes = user_scopes # type: ignore @classmethod def from_auth_test_response( @@ -53,7 +65,10 @@ def from_auth_test_response( *, bot_token: Optional[str] = None, user_token: Optional[str] = None, + bot_scopes: Optional[Union[List[str], str]] = None, + user_scopes: Optional[Union[List[str], str]] = None, auth_test_response: SlackResponse, + user_auth_test_response: Optional[SlackResponse] = None, ) -> "AuthorizeResult": bot_user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is not None else None @@ -61,12 +76,18 @@ def from_auth_test_response( user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None ) + # Since v1.28, user_id can be set when user_token w/ its auth.test response exists + if user_id is None and user_auth_test_response is not None: + user_id: Optional[str] = user_auth_test_response.get("user_id") # type:ignore + return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), bot_id=auth_test_response.get("bot_id"), bot_user_id=bot_user_id, + bot_scopes=bot_scopes, user_id=user_id, bot_token=bot_token, user_token=user_token, + user_scopes=user_scopes, ) diff --git a/tests/scenario_tests/test_app_installation_store.py b/tests/scenario_tests/test_app_installation_store.py new file mode 100644 index 000000000..b3b4390e3 --- /dev/null +++ b/tests/scenario_tests/test_app_installation_store.py @@ -0,0 +1,155 @@ +import datetime +import json +import logging +from time import time, sleep +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import App, BoltRequest, Say, BoltContext +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class MemoryInstallationStore(InstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class TestApp: + 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_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_app_mention_request(self): + timestamp, body = str(int(time())), 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"], + } + ) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_authorize_result(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="secret", + scopes=["commands"], + user_scopes=["search:read"], + installation_store=MemoryInstallationStore(), + ), + ) + + @app.event("app_mention") + def handle_app_mention(context: BoltContext, say: Say): + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user_id == "W99999" + assert context.authorize_result.user_token == "xoxp-valid" + assert context.authorize_result.user_scopes == ["search:read"] + say("What's up?") + + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 diff --git a/tests/scenario_tests_async/test_app_installation_store.py b/tests/scenario_tests_async/test_app_installation_store.py new file mode 100644 index 000000000..17bf92255 --- /dev/null +++ b/tests/scenario_tests_async/test_app_installation_store.py @@ -0,0 +1,167 @@ +import asyncio +import datetime +import json +import logging +from time import time +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +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, + assert_auth_test_count_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestApp: + 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, + ) + + @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) + + 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_app_mention_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(app_mention_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_authorize_result(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=MemoryInstallationStore(), + ), + ) + + @app.event("app_mention") + async def handle_app_mention(context: AsyncBoltContext, say: AsyncSay): + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user_id == "W99999" + assert context.authorize_result.user_token == "xoxp-valid" + assert context.authorize_result.user_scopes == ["search:read"] + await say("What's up?") + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + +app_mention_body = { + "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"], +} + + +class MemoryInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) From b48c9fa5d8a7e82117ad0016dae17b6b80722210 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 13 Mar 2023 18:56:12 +0900 Subject: [PATCH 566/865] Fix tests --- tests/scenario_tests_async/test_web_client_customization.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/scenario_tests_async/test_web_client_customization.py b/tests/scenario_tests_async/test_web_client_customization.py index 8cd6d840e..6f810b471 100644 --- a/tests/scenario_tests_async/test_web_client_customization.py +++ b/tests/scenario_tests_async/test_web_client_customization.py @@ -69,8 +69,8 @@ async def test_web_client_customization(self): return self.web_client.retry_handlers = [ - AsyncConnectionErrorRetryHandler, - AsyncRateLimitErrorRetryHandler, + AsyncConnectionErrorRetryHandler(), + AsyncRateLimitErrorRetryHandler(), ] app = AsyncApp( client=self.web_client, From 65d1435a2256f3a23cab1f856d2571927b05f752 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 13 Mar 2023 20:17:43 +0900 Subject: [PATCH 567/865] Introduce actor enterprise/team/user_id for Slack Connect events (#854) --- .../aws_lambda/lambda_s3_oauth_flow.py | 1 + slack_bolt/app/app.py | 9 +- slack_bolt/app/async_app.py | 7 +- slack_bolt/authorization/async_authorize.py | 60 +- slack_bolt/authorization/authorize.py | 57 +- slack_bolt/context/base_context.py | 27 + .../async_multi_teams_authorization.py | 34 +- .../multi_teams_authorization.py | 28 +- slack_bolt/oauth/async_oauth_settings.py | 9 + slack_bolt/oauth/oauth_settings.py | 9 + slack_bolt/request/async_internals.py | 13 + slack_bolt/request/internals.py | 79 ++ .../test_app_actor_user_token.py | 178 ++++ .../test_app_actor_user_token.py | 189 ++++ tests/slack_bolt/request/test_internals.py | 857 ++++++++++++++++++ 15 files changed, 1526 insertions(+), 31 deletions(-) create mode 100644 tests/scenario_tests/test_app_actor_user_token.py create mode 100644 tests/scenario_tests_async/test_app_actor_user_token.py diff --git a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py index 4d6fae1dc..deacac7f5 100644 --- a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py +++ b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py @@ -59,6 +59,7 @@ def __init__( client_secret=settings.client_secret, installation_store=settings.installation_store, bot_only=settings.installation_store_bot_only, + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), ) OAuthFlow.__init__(self, client=client, logger=logger, settings=settings) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 4d27ddfec..9d8d09149 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -238,6 +238,7 @@ def message_hello(message, say): logger=self._framework_logger, bot_only=installation_store_bot_only, client=self._client, # for proxy use cases etc. + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), ) self._oauth_flow: Optional[OAuthFlow] = None @@ -379,7 +380,13 @@ def _init_middleware_list( else: raise BoltError(error_token_required()) else: - self._middleware_list.append(MultiTeamsAuthorization(authorize=self._authorize, base_logger=self._base_logger)) + self._middleware_list.append( + MultiTeamsAuthorization( + authorize=self._authorize, + base_logger=self._base_logger, + user_token_resolution=self._oauth_flow.settings.user_token_resolution, + ) + ) if ignoring_self_events_enabled is True: self._middleware_list.append(IgnoringSelfEvents(base_logger=self._base_logger)) if url_verification_enabled is True: diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index ae224e341..1070e4363 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -243,6 +243,7 @@ async def message_hello(message, say): # async function logger=self._framework_logger, bot_only=installation_store_bot_only, client=self._async_client, # for proxy use cases etc. + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), ) self._async_oauth_flow: Optional[AsyncOAuthFlow] = None @@ -374,7 +375,11 @@ def _init_async_middleware_list( raise BoltError(error_token_required()) else: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize, base_logger=self._base_logger) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, + base_logger=self._base_logger, + user_token_resolution=self._async_oauth_flow.settings.user_token_resolution, + ) ) if ignoring_self_events_enabled is True: diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index b6825c7ab..18189adc8 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -30,6 +30,10 @@ async def __call__( enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: raise NotImplementedError() @@ -51,6 +55,10 @@ async def __call__( enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: try: all_available_args = { @@ -66,6 +74,9 @@ async def __call__( "enterprise_id": enterprise_id, "team_id": team_id, "user_id": user_id, + "actor_enterprise_id": actor_enterprise_id, + "actor_team_id": actor_team_id, + "actor_user_id": actor_user_id, } for k, v in context.items(): if k not in all_available_args: @@ -103,6 +114,8 @@ class AsyncInstallationStoreAuthorize(AsyncAuthorize): """ authorize_result_cache: Dict[str, AuthorizeResult] + bot_only: bool + user_token_resolution: str find_installation_available: Optional[bool] find_bot_available: Optional[bool] token_rotator: Optional[AsyncTokenRotator] @@ -122,10 +135,13 @@ def __init__( bot_only: bool = False, cache_enabled: bool = False, client: Optional[AsyncWebClient] = None, + # Since v1.27, user token resolution can be actor ID based when the mode is enabled + user_token_resolution: str = "authed_user", ): self.logger = logger self.installation_store = installation_store self.bot_only = bot_only + self.user_token_resolution = user_token_resolution self.cache_enabled = cache_enabled self.authorize_result_cache = {} self.find_installation_available = None @@ -147,6 +163,10 @@ async def __call__( enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: if self.find_installation_available is None: @@ -194,16 +214,34 @@ async def __call__( # try to fetch the request user's installation # to reflect the user's access token if exists - this_user_installation = await self.installation_store.async_find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, - ) + # try to fetch the request user's installation + # to reflect the user's access token if exists + if self.user_token_resolution == "actor": + if actor_enterprise_id is not None or actor_team_id is not None: + # Note that actor_team_id can be absent for app_mention events + this_user_installation = await self.installation_store.async_find_installation( + enterprise_id=actor_enterprise_id, + team_id=actor_team_id, + user_id=actor_user_id, + is_enterprise_install=None, + ) + else: + this_user_installation = await self.installation_store.async_find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) if this_user_installation is not None: user_token = this_user_installation.user_token user_scopes = this_user_installation.user_scopes - if latest_bot_installation.bot_token is None: + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): # If latest_installation has a bot token, we never overwrite the value bot_token = this_user_installation.bot_token bot_scopes = this_user_installation.bot_scopes @@ -213,7 +251,13 @@ async def __call__( if refreshed is not None: user_token = refreshed.user_token user_scopes = refreshed.user_scopes - if latest_bot_installation.bot_token is None: + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): # If latest_installation has a bot token, we never overwrite the value bot_token = refreshed.bot_token bot_scopes = refreshed.bot_scopes diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 4f113c9cc..05b306165 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -29,6 +29,10 @@ def __call__( enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: raise NotImplementedError() @@ -55,6 +59,10 @@ def __call__( enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: try: all_available_args = { @@ -70,6 +78,9 @@ def __call__( "enterprise_id": enterprise_id, "team_id": team_id, "user_id": user_id, + "actor_enterprise_id": actor_enterprise_id, + "actor_team_id": actor_team_id, + "actor_user_id": actor_user_id, } for k, v in context.items(): if k not in all_available_args: @@ -108,6 +119,7 @@ class InstallationStoreAuthorize(Authorize): authorize_result_cache: Dict[str, AuthorizeResult] bot_only: bool + user_token_resolution: str find_installation_available: bool find_bot_available: bool token_rotator: Optional[TokenRotator] @@ -127,10 +139,13 @@ def __init__( bot_only: bool = False, cache_enabled: bool = False, client: Optional[WebClient] = None, + # Since v1.27, user token resolution can be actor ID based when the mode is enabled + user_token_resolution: str = "authed_user", ): self.logger = logger self.installation_store = installation_store self.bot_only = bot_only + self.user_token_resolution = user_token_resolution self.cache_enabled = cache_enabled self.authorize_result_cache = {} self.find_installation_available = hasattr(installation_store, "find_installation") @@ -152,6 +167,10 @@ def __call__( enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: bot_token: Optional[str] = None @@ -194,16 +213,32 @@ def __call__( # try to fetch the request user's installation # to reflect the user's access token if exists - this_user_installation = self.installation_store.find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, - ) + if self.user_token_resolution == "actor": + if actor_enterprise_id is not None or actor_team_id is not None: + # Note that actor_team_id can be absent for app_mention events + this_user_installation = self.installation_store.find_installation( + enterprise_id=actor_enterprise_id, + team_id=actor_team_id, + user_id=actor_user_id, + is_enterprise_install=None, + ) + else: + this_user_installation = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) if this_user_installation is not None: user_token = this_user_installation.user_token user_scopes = this_user_installation.user_scopes - if latest_bot_installation.bot_token is None: + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): # If latest_installation has a bot token, we never overwrite the value bot_token = this_user_installation.bot_token bot_scopes = this_user_installation.bot_scopes @@ -213,7 +248,13 @@ def __call__( if refreshed is not None: user_token = refreshed.user_token user_scopes = refreshed.user_scopes - if latest_bot_installation.bot_token is None: + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): # If latest_installation has a bot token, we never overwrite the value bot_token = refreshed.bot_token bot_scopes = refreshed.bot_scopes diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index f6c67bb60..bcbdae3c2 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -17,6 +17,9 @@ class BaseContext(dict): "is_enterprise_install", "team_id", "user_id", + "actor_enterprise_id", + "actor_team_id", + "actor_user_id", "channel_id", "response_url", "matches", @@ -61,6 +64,30 @@ def user_id(self) -> Optional[str]: """The user ID associated ith this request.""" return self.get("user_id") + @property + def actor_enterprise_id(self) -> Optional[str]: + """The action's actor's Enterprise Grid organization ID. + Note that this property is especially useful for handling events in Slack Connect channels. + That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency. + """ + return self.get("actor_enterprise_id") + + @property + def actor_team_id(self) -> Optional[str]: + """The action's actor's workspace ID. + Note that this property is especially useful for handling events in Slack Connect channels. + That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency. + """ + return self.get("actor_team_id") + + @property + def actor_user_id(self) -> Optional[str]: + """The action's actor's user ID. + Note that this property is especially useful for handling events in Slack Connect channels. + That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency. + """ + return self.get("actor_user_id") + @property def channel_id(self) -> Optional[str]: """The conversation ID associated with this request.""" diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index 4f977c642..ffc47dc29 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -14,16 +14,24 @@ class AsyncMultiTeamsAuthorization(AsyncAuthorization): authorize: AsyncAuthorize + user_token_resolution: str - def __init__(self, authorize: AsyncAuthorize, base_logger: Optional[Logger] = None): + def __init__( + self, + authorize: AsyncAuthorize, + base_logger: Optional[Logger] = None, + user_token_resolution: str = "authed_user", + ): """Multi-workspace authorization. Args: authorize: The function to authorize incoming requests from Slack. base_logger: The base logger + user_token_resolution: "authed_user" or "actor" """ self.authorize = authorize self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization, base_logger=base_logger) + self.user_token_resolution = user_token_resolution async def async_process( self, @@ -49,12 +57,24 @@ async def async_process( return await next() try: - auth_result: Optional[AuthorizeResult] = await self.authorize( - context=req.context, - enterprise_id=req.context.enterprise_id, - team_id=req.context.team_id, - user_id=req.context.user_id, - ) + auth_result: Optional[AuthorizeResult] = None + if self.user_token_resolution == "actor": + auth_result = await self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + actor_enterprise_id=req.context.actor_enterprise_id, + actor_team_id=req.context.actor_team_id, + actor_user_id=req.context.actor_user_id, + ) + else: + auth_result = await self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) if auth_result: req.context.set_authorize_result(auth_result) token = auth_result.bot_token or auth_result.user_token diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index dd7c9ad3e..3ce3a54ec 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -18,21 +18,25 @@ class MultiTeamsAuthorization(Authorization): authorize: Authorize + user_token_resolution: str def __init__( self, *, authorize: Authorize, base_logger: Optional[Logger] = None, + user_token_resolution: str = "authed_user", ): """Multi-workspace authorization. Args: authorize: The function to authorize incoming requests from Slack. base_logger: The base logger + user_token_resolution: "authed_user" or "actor" """ self.authorize = authorize self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger) + self.user_token_resolution = user_token_resolution def process( self, @@ -55,12 +59,24 @@ def process( return next() try: - auth_result: Optional[AuthorizeResult] = self.authorize( - context=req.context, - enterprise_id=req.context.enterprise_id, - team_id=req.context.team_id, - user_id=req.context.user_id, - ) + auth_result: Optional[AuthorizeResult] = None + if self.user_token_resolution == "actor": + auth_result = self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + actor_enterprise_id=req.context.actor_enterprise_id, + actor_team_id=req.context.actor_team_id, + actor_user_id=req.context.actor_user_id, + ) + else: + auth_result = self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) if auth_result is not None: req.context.set_authorize_result(auth_result) token = auth_result.bot_token or auth_result.user_token diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py index 080abd6be..c90fc97e1 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -42,6 +42,7 @@ class AsyncOAuthSettings: installation_store: AsyncInstallationStore installation_store_bot_only: bool token_rotation_expiration_minutes: int + user_token_resolution: str authorize: AsyncAuthorize # state parameter related configurations state_validation_enabled: bool @@ -76,6 +77,7 @@ def __init__( installation_store: Optional[AsyncInstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, + user_token_resolution: str = "authed_user", # state parameter related configurations state_validation_enabled: bool = True, state_store: Optional[AsyncOAuthStateStore] = None, @@ -102,6 +104,11 @@ def __init__( installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + user_token_resolution: The option to pick up a user token per request (Default: authed_user) + The available values are "authed_user" and "actor". When you want to resolve the user token per request + using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve + a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect + channels. Note that actor IDs can be absent in some scenarios. state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") @@ -143,6 +150,7 @@ def __init__( self.authorization_url = authorization_url or "https://slack.com/oauth/v2/authorize" # Installation Management self.installation_store = installation_store or get_or_create_default_installation_store(client_id) + self.user_token_resolution = user_token_resolution or "authed_user" self.installation_store_bot_only = installation_store_bot_only self.token_rotation_expiration_minutes = token_rotation_expiration_minutes self.authorize = AsyncInstallationStoreAuthorize( @@ -152,6 +160,7 @@ def __init__( token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, installation_store=self.installation_store, bot_only=self.installation_store_bot_only, + user_token_resolution=user_token_resolution, ) # state parameter related configurations self.state_validation_enabled = state_validation_enabled diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index d56856414..a3c2b49c5 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -38,6 +38,7 @@ class OAuthSettings: installation_store_bot_only: bool token_rotation_expiration_minutes: int authorize: Authorize + user_token_resolution: str # default: "authed_user" # state parameter related configurations state_validation_enabled: bool state_store: OAuthStateStore @@ -71,6 +72,7 @@ def __init__( installation_store: Optional[InstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, + user_token_resolution: str = "authed_user", # state parameter related configurations state_validation_enabled: bool = True, state_store: Optional[OAuthStateStore] = None, @@ -97,6 +99,11 @@ def __init__( installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + user_token_resolution: The option to pick up a user token per request (Default: authed_user) + The available values are "authed_user" and "actor". When you want to resolve the user token per request + using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve + a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect + channels. Note that actor IDs can be absent in some scenarios. state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") @@ -136,6 +143,7 @@ def __init__( self.authorization_url = authorization_url or "https://slack.com/oauth/v2/authorize" # Installation Management self.installation_store = installation_store or get_or_create_default_installation_store(client_id) + self.user_token_resolution = user_token_resolution or "authed_user" self.installation_store_bot_only = installation_store_bot_only self.token_rotation_expiration_minutes = token_rotation_expiration_minutes self.authorize = InstallationStoreAuthorize( @@ -145,6 +153,7 @@ def __init__( token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, installation_store=self.installation_store, bot_only=self.installation_store_bot_only, + user_token_resolution=user_token_resolution, ) # state parameter related configurations self.state_validation_enabled = state_validation_enabled diff --git a/slack_bolt/request/async_internals.py b/slack_bolt/request/async_internals.py index b32ccb20e..1d662c1d5 100644 --- a/slack_bolt/request/async_internals.py +++ b/slack_bolt/request/async_internals.py @@ -8,6 +8,9 @@ extract_user_id, extract_channel_id, debug_multiple_response_urls_detected, + extract_actor_enterprise_id, + extract_actor_team_id, + extract_actor_user_id, ) @@ -25,6 +28,16 @@ def build_async_context( user_id = extract_user_id(body) if user_id: context["user_id"] = user_id + # Actor IDs are useful for Events API on a Slack Connect channel + actor_enterprise_id = extract_actor_enterprise_id(body) + if actor_enterprise_id: + context["actor_enterprise_id"] = actor_enterprise_id + actor_team_id = extract_actor_team_id(body) + if actor_team_id: + context["actor_team_id"] = actor_team_id + actor_user_id = extract_actor_user_id(body) + if actor_user_id: + context["actor_user_id"] = actor_user_id channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 729976ff9..1a3d3e428 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -72,6 +72,20 @@ def extract_enterprise_id(payload: Dict[str, Any]) -> Optional[str]: return None +def extract_actor_enterprise_id(payload: Dict[str, Any]) -> Optional[str]: + if payload.get("is_ext_shared_channel") is True: + if payload.get("type") == "event_callback": + # For safety, we don't set actor IDs for the events like "file_shared", + # which do not provide any team ID in $.event data. In the case, the IDs cannot be correct. + event_team_id = payload.get("event", {}).get("user_team") or payload.get("event", {}).get("team") + if event_team_id is not None and str(event_team_id).startswith("E"): + return event_team_id + if event_team_id == payload.get("team_id"): + return payload.get("enterprise_id") + return None + return extract_enterprise_id(payload) + + def extract_team_id(payload: Dict[str, Any]) -> Optional[str]: if payload.get("view", {}).get("app_installed_team_id") is not None: # view_submission payloads can have `view.app_installed_team_id` when a modal view that was opened @@ -102,6 +116,48 @@ def extract_team_id(payload: Dict[str, Any]) -> Optional[str]: return None +def extract_actor_team_id(payload: Dict[str, Any]) -> Optional[str]: + if payload.get("is_ext_shared_channel") is True: + if payload.get("type") == "event_callback": + event_type = payload.get("event", {}).get("type") + if event_type == "app_mention": + # The $.event.user_team can be an enterprise_id in app_mention events. + # In the scenario, there is no way to retrieve actor_team_id as of March 2023 + user_team = payload.get("event", {}).get("user_team") + if user_team is None: + # working with an app installed in this user's org/workspace side + return payload.get("event", {}).get("team") + if str(user_team).startswith("T"): + # interacting from a connected non-grid workspace + return user_team + # Interacting from a connected grid workspace; in this case, team_id cannot be resolved as of March 2023 + return None + # For safety, we don't set actor IDs for the events like "file_shared", + # which do not provide any team ID in $.event data. In the case, the IDs cannot be correct. + event_user_team = payload.get("event", {}).get("user_team") + if event_user_team is not None: + if str(event_user_team).startswith("T"): + return event_user_team + elif str(event_user_team).startswith("E"): + if event_user_team == payload.get("enterprise_id"): + return payload.get("team_id") + elif event_user_team == payload.get("context_enterprise_id"): + return payload.get("context_team_id") + + event_team = payload.get("event", {}).get("team") + if event_team is not None: + if str(event_team).startswith("T"): + return event_team + elif str(event_team).startswith("E"): + if event_team == payload.get("enterprise_id"): + return payload.get("team_id") + elif event_team == payload.get("context_enterprise_id"): + return payload.get("context_team_id") + return None + + return extract_team_id(payload) + + def extract_user_id(payload: Dict[str, Any]) -> Optional[str]: user = payload.get("user") if user is not None: @@ -122,6 +178,19 @@ def extract_user_id(payload: Dict[str, Any]) -> Optional[str]: return None +def extract_actor_user_id(payload: Dict[str, Any]) -> Optional[str]: + if payload.get("is_ext_shared_channel") is True: + if payload.get("type") == "event_callback": + event = payload.get("event") + if event is None: + return None + if extract_actor_enterprise_id(payload) is None and extract_actor_team_id(payload) is None: + # When both enterprise_id and team_id are not identified, we skip returning user_id too for safety + return None + return event.get("user") or event.get("user_id") + return extract_user_id(payload) + + def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: channel = payload.get("channel") if channel is not None: @@ -150,6 +219,16 @@ def build_context(context: BoltContext, body: Dict[str, Any]) -> BoltContext: user_id = extract_user_id(body) if user_id: context["user_id"] = user_id + # Actor IDs are useful for Events API on a Slack Connect channel + actor_enterprise_id = extract_actor_enterprise_id(body) + if actor_enterprise_id: + context["actor_enterprise_id"] = actor_enterprise_id + actor_team_id = extract_actor_team_id(body) + if actor_team_id: + context["actor_team_id"] = actor_team_id + actor_user_id = extract_actor_user_id(body) + if actor_user_id: + context["actor_user_id"] = actor_user_id channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id diff --git a/tests/scenario_tests/test_app_actor_user_token.py b/tests/scenario_tests/test_app_actor_user_token.py new file mode 100644 index 000000000..4fd1b4751 --- /dev/null +++ b/tests/scenario_tests/test_app_actor_user_token.py @@ -0,0 +1,178 @@ +import datetime +import json +import logging +from time import time, sleep +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import App, BoltRequest, Say, BoltContext +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class MemoryInstallationStore(InstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + if team_id == "T0G9PQBBK": + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + installed_at=datetime.datetime.now().timestamp(), + ) + if team_id == "T014GJXU940" and enterprise_id == "E013Y3SHLAY": + return Installation( + app_id="A111", + enterprise_id="E013Y3SHLAY", + team_id="T014GJXU940", + user_id="W11111", + user_token="xoxp-valid-actor-based", + user_scopes=["search:read", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + return None + + +class TestApp: + 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_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self): + timestamp, body = str(int(time())), json.dumps( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "files": [], + "upload": False, + "user": "W013QGS7BPF", + "display_as_bot": False, + "team": "T014GJXU940", + "channel": "C04T3ACM40K", + "subtype": "file_share", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T0G9PQBBK", + "user_id": "W23456789", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + } + ) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_authorize_result(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="secret", + scopes=["commands", "chat:write"], + user_scopes=["search:read", "chat:write"], + installation_store=MemoryInstallationStore(), + user_token_resolution="actor", + ), + ) + + @app.event("message") + def handle_events(context: BoltContext, say: Say): + assert context.actor_enterprise_id == "E013Y3SHLAY" + assert context.actor_team_id == "T014GJXU940" + assert context.actor_user_id == "W013QGS7BPF" + + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user_id == "W99999" + assert context.authorize_result.user_token == "xoxp-valid-actor-based" + assert context.authorize_result.user_scopes == ["search:read", "chat:write"] + say("What's up?") + + response = app.dispatch(self.build_request()) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 diff --git a/tests/scenario_tests_async/test_app_actor_user_token.py b/tests/scenario_tests_async/test_app_actor_user_token.py new file mode 100644 index 000000000..c9c395297 --- /dev/null +++ b/tests/scenario_tests_async/test_app_actor_user_token.py @@ -0,0 +1,189 @@ +import asyncio +import datetime +import json +import logging +from time import time +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +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, + assert_auth_test_count_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestApp: + 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, + ) + + @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) + + 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): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "files": [], + "upload": False, + "user": "W013QGS7BPF", + "display_as_bot": False, + "team": "T014GJXU940", + "channel": "C04T3ACM40K", + "subtype": "file_share", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T0G9PQBBK", + "user_id": "W23456789", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + } + ) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_authorize_result(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=MemoryInstallationStore(), + user_token_resolution="actor", + ), + ) + + @app.event("message") + async def handle_events(context: AsyncBoltContext, say: AsyncSay): + assert context.actor_enterprise_id == "E013Y3SHLAY" + assert context.actor_team_id == "T014GJXU940" + assert context.actor_user_id == "W013QGS7BPF" + + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user_id == "W99999" + assert context.authorize_result.user_token == "xoxp-valid-actor-based" + assert context.authorize_result.user_scopes == ["search:read", "chat:write"] + await say("What's up?") + + request = self.build_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + +class MemoryInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + if team_id == "T0G9PQBBK": + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + installed_at=datetime.datetime.now().timestamp(), + ) + if team_id == "T014GJXU940" and enterprise_id == "E013Y3SHLAY": + return Installation( + app_id="A111", + enterprise_id="E013Y3SHLAY", + team_id="T014GJXU940", + user_id="W11111", + user_token="xoxp-valid-actor-based", + user_scopes=["search:read", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + return None diff --git a/tests/slack_bolt/request/test_internals.py b/tests/slack_bolt/request/test_internals.py index 5138f0ba5..6975a307d 100644 --- a/tests/slack_bolt/request/test_internals.py +++ b/tests/slack_bolt/request/test_internals.py @@ -7,6 +7,9 @@ extract_enterprise_id, parse_query, extract_is_enterprise_install, + extract_actor_enterprise_id, + extract_actor_team_id, + extract_actor_user_id, ) @@ -79,6 +82,173 @@ def teardown_method(self): }, ] + slack_connect_authorizations = [ + { + "enterprise_id": "INSTALLED_ENTERPRISE_ID", + "team_id": "INSTALLED_TEAM_ID", + "user_id": "INSTALLED_BOT_USER_ID", + "is_bot": True, + "is_enterprise_install": False, + } + ] + slack_connect_events_api_no_actor_team_requests = [ + { + "team_id": "INSTALLED_TEAM_ID", + "api_app_id": "A111", + "event": { + "type": "app_mention", + "text": "<@INSTALLED_BOT_USER_ID> hey", + "user": "USER_ID_ACTOR", + "ts": "1678451405.023359", + "team": "INSTALLED_TEAM_ID", + "user_team": "ENTERPRISE_ID_ACTOR", + "source_team": "ENTERPRISE_ID_ACTOR", + "user_profile": {"team": "ENTERPRISE_ID_ACTOR"}, + "channel": "C111", + "event_ts": "1678451405.023359", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + ] + slack_connect_events_api_no_actor_enterprise_team_requests = [ + { + "team_id": "INSTALLED_TEAM_ID", + "context_team_id": "INSTALLED_TEAM_ID", + "context_enterprise_id": "INSTALLED_ENTERPRISE_ID", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "USER_ID_ACTOR", + "reaction": "eyes", + "item": {"type": "message", "channel": "C111", "ts": "1678453386.979699"}, + "event_ts": "1678456876.000900", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + { + "enterprise_id": "INSTALLED_ENTERPRISE_ID", + "team_id": "INSTALLED_TEAM_ID", + "api_app_id": "A111", + "event": { + "file_id": "F111", + "user_id": "USER_ID_ACTOR", + "file": {"id": "F111"}, + "channel_id": "C111", + "type": "file_shared", + "event_ts": "1678454981.170300", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + { + "team_id": "INSTALLED_TEAM_ID", + "context_team_id": "INSTALLED_TEAM_ID", + "context_enterprise_id": "INSTALLED_ENTERPRISE_ID", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "USER_ID_ACTOR", + "reaction": "rocket", + "item": {"type": "message", "channel": "C111", "ts": "1678454602.316259"}, + "event_ts": "1678454724.000600", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + { + "team_id": "TEAM_ID_ACTOR", + "enterprise_id": "ENTERPRISE_ID_ACTOR", + "context_team_id": "INSTALLED_TEAM_ID", + "context_enterprise_id": "INSTALLED_ENTERPRISE_ID", + "api_app_id": "A111", + "event": { + "type": "message_metadata_posted", + "app_id": "A222", + "bot_id": "B222", + "user_id": "USER_ID_ACTOR", # Although this is always a bot's user ID, we can call it an actor + "team_id": "INSTALLED_TEAM_ID", + "channel_id": "C111", + "metadata": {"event_type": "task_created", "event_payload": {"id": "11223", "title": "Redesign Homepage"}}, + "message_ts": "1678458906.527119", + "event_ts": "1678458906.527119", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + ] + slack_connect_events_api_requests = [ + { + "team_id": "TEAM_ID_ACTOR", + "enterprise_id": "ENTERPRISE_ID_ACTOR", + "context_team_id": "INSTALLED_TEAM_ID", + "context_enterprise_id": "INSTALLED_ENTERPRISE_ID", + "api_app_id": "A111", + "event": { + "type": "message", + "text": "<@INSTALLED_BOT_USER_ID> Hey!", + "user": "USER_ID_ACTOR", + "ts": "1678455198.838499", + "team": "TEAM_ID_ACTOR", + "channel": "C111", + "event_ts": "1678455198.838499", + "channel_type": "channel", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + { + "team_id": "TEAM_ID_ACTOR", + "enterprise_id": "ENTERPRISE_ID_ACTOR", + "context_team_id": "INSTALLED_TEAM_ID", + "context_enterprise_id": "INSTALLED_ENTERPRISE_ID", + "api_app_id": "A111", + "event": { + "type": "message", + "text": "Hey!", + "user": "USER_ID_ACTOR", + "ts": "1678454365.204709", + "team": "TEAM_ID_ACTOR", + "channel": "C111", + "event_ts": "1678454365.204709", + "channel_type": "channel", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + { + "team_id": "TEAM_ID_ACTOR", + "enterprise_id": "ENTERPRISE_ID_ACTOR", + "context_team_id": "INSTALLED_TEAM_ID", + "context_enterprise_id": "INSTALLED_ENTERPRISE_ID", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "channel_name", + "ts": "1678454602.316259", + "user": "USER_ID_ACTOR", + "text": "renamed", + "old_name": "old", + "name": "new", + "team": "TEAM_ID_ACTOR", + "channel": "C111", + "event_ts": "1678454602.316259", + "channel_type": "channel", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + ] + def test_channel_id_extraction(self): for req in self.requests: channel_id = extract_channel_id(req) @@ -126,6 +296,48 @@ def test_is_enterprise_install_extraction(self): assert extract_is_enterprise_install({"is_enterprise_install": "true"}) is True assert extract_is_enterprise_install({"is_enterprise_install": "false"}) is False + def test_actor_enterprise_id(self): + for req in self.requests: + enterprise_id = extract_actor_enterprise_id(req) + assert enterprise_id == "E111" + for req in self.slack_connect_events_api_requests: + enterprise_id = extract_actor_enterprise_id(req) + assert enterprise_id == "ENTERPRISE_ID_ACTOR" + for req in self.slack_connect_events_api_no_actor_team_requests: + enterprise_id = extract_actor_enterprise_id(req) + assert enterprise_id == "ENTERPRISE_ID_ACTOR" + for req in self.slack_connect_events_api_no_actor_enterprise_team_requests: + enterprise_id = extract_actor_enterprise_id(req) + assert enterprise_id is None + + def test_actor_team_id(self): + for req in self.requests: + team_id = extract_actor_team_id(req) + assert team_id == "T111" + for req in self.slack_connect_events_api_requests: + team_id = extract_actor_team_id(req) + assert team_id == "TEAM_ID_ACTOR" + for req in self.slack_connect_events_api_no_actor_team_requests: + team_id = extract_actor_team_id(req) + assert team_id is None + for req in self.slack_connect_events_api_no_actor_enterprise_team_requests: + team_id = extract_actor_team_id(req) + assert team_id is None + + def test_actor_user_id(self): + for req in self.requests: + user_id = extract_actor_user_id(req) + assert user_id == "U111" + for req in self.slack_connect_events_api_requests: + user_id = extract_actor_user_id(req) + assert user_id == "USER_ID_ACTOR" + for req in self.slack_connect_events_api_no_actor_team_requests: + user_id = extract_actor_user_id(req) + assert user_id == "USER_ID_ACTOR" + for req in self.slack_connect_events_api_no_actor_enterprise_team_requests: + user_id = extract_actor_user_id(req) + assert user_id is None + def test_parse_query(self): expected = {"foo": ["bar"], "baz": ["123"]} @@ -140,3 +352,648 @@ def test_parse_query(self): with pytest.raises(ValueError): parse_query({"foo": {"bar": "ZZZ"}, "baz": {"123": "111"}}) + + slack_connect_from_non_grid_test_patterns = [ + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "user": "U03E94MK0", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + # context.enterprise_id/team_id/user_id, + (None, "T03E94MJU", "U03E94MK0"), + # context.actor_enterprise_id/team_id/user_id, + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "user": "U03E94MK0", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T03E94MJU", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "app_mention", + "text": "<@U04T5KKKLUE>", + "user": "U03E94MK0", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "user": "U03E94MK0", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "app_mention", + "user": "U03E94MK0", + "team": "T014GJXU940", + "user_team": "T03E94MJU", + "source_team": "T03E94MJU", + "user_profile": {"team": "T03E94MJU"}, + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "subtype": "channel_join", + "user": "UL5CBM924", + "team": "T03E94MJU", + "inviter": "U03E94MK0", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "UL5CBM924"), + (None, "T03E94MJU", "UL5CBM924"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T03E94MJU", + "context_enterprise_id": None, + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "member_joined_channel", + "user": "UL5CBM924", + "channel": "C04T3ACM40K", + "team": "T03E94MJU", + "inviter": "U03E94MK0", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "UL5CBM924"), + (None, "T03E94MJU", "UL5CBM924"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T03E94MJU", + "context_enterprise_id": None, + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "member_left_channel", + "user": "UL5CBM924", + "channel": "C04T3ACM40K", + "team": "T03E94MJU", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "UL5CBM924"), + (None, "T03E94MJU", "UL5CBM924"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "subtype": "channel_leave", + "user": "UL5CBM924", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "UL5CBM924"), + (None, "T03E94MJU", "UL5CBM924"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "files": [], + "upload": False, + "user": "U03E94MK0", + "display_as_bot": False, + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + "subtype": "file_share", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "file_id": "F04TL3HA3PC", + "user_id": "U03E94MK0", + "file": {"id": "F04TL3HA3PC"}, + "channel_id": "C04T3ACM40K", + "type": "file_shared", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + # Note that a complete set of actor IDs are not deterministic in this scenario + # So, we fall back to all None data for clarity + (None, None, None), + ), + ( + { + "team_id": "T03E94MJU", + "api_app_id": "A04TEM7H4S0", + "event": { + "file_id": "F04TL3HA3PC", + "user_id": "U03E94MK0", + "file": {"id": "F04TL3HA3PC"}, + "type": "file_public", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message_metadata_posted", + "app_id": "A013TFN1T7C", + "bot_id": "B013ZM43W3E", + "user_id": "W013TN008CB", + "team_id": "T014GJXU940", + "channel_id": "C04T3ACM40K", + "metadata": { + "event_type": "task_created", + "event_payload": {"id": "11223", "title": "Redesign Homepage"}, + }, + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013TN008CB"), + # Note that a complete set of actor IDs are not deterministic in this scenario + # So, we fall back to all None data for clarity + (None, None, None), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "bot_id": "B013ZM43W3E", + "type": "message", + "user": "W013TN008CB", + "metadata": { + "event_type": "task_created", + "event_payload": {"id": "11223", "title": "Redesign Homepage"}, + }, + "app_id": "A013TFN1T7C", + "team": "T014GJXU940", + "bot_profile": { + "id": "B013ZM43W3E", + "app_id": "A013TFN1T7C", + "team_id": "T014GJXU940", + }, + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013TN008CB"), + ("E013Y3SHLAY", "T014GJXU940", "W013TN008CB"), + ), + ] + + slack_connect_from_grid_test_patterns = [ + ( + { + "team_id": "T03E94MJU", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "app_mention", + "user": "W013QGS7BPF", + "team": "T03E94MJU", + "user_team": "E013Y3SHLAY", + "source_team": "E013Y3SHLAY", + "user_profile": { + "team": "E013Y3SHLAY", + }, + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + # context.enterprise_id/team_id/user_id, + (None, "T03E94MJU", "W013QGS7BPF"), + # context.actor_enterprise_id/team_id/user_id, + ("E013Y3SHLAY", None, "W013QGS7BPF"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "user": "W013QGS7BPF", + "team": "T014GJXU940", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013QGS7BPF"), + ("E013Y3SHLAY", "T014GJXU940", "W013QGS7BPF"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "file_id": "F04TDEYDCT0", + "user_id": "W013QGS7BPF", + "file": {"id": "F04TDEYDCT0"}, + "channel_id": "C04T3ACM40K", + "type": "file_shared", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013QGS7BPF"), + (None, None, None), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "files": [], + "upload": False, + "user": "W013QGS7BPF", + "display_as_bot": False, + "team": "T014GJXU940", + "channel": "C04T3ACM40K", + "subtype": "file_share", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013QGS7BPF"), + ("E013Y3SHLAY", "T014GJXU940", "W013QGS7BPF"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "file_id": "F04TDEYDCT0", + "user_id": "W013QGS7BPF", + "file": {"id": "F04TDEYDCT0"}, + "type": "file_public", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": "E013Y3SHLAY", + "team_id": "T014GJXU940", + "user_id": "U04TDAM3YUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + }, + ("E013Y3SHLAY", "T014GJXU940", "W013QGS7BPF"), + ("E013Y3SHLAY", "T014GJXU940", "W013QGS7BPF"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "member_joined_channel", + "user": "W013CV5UA87", + "channel": "C04T3ACM40K", + "team": "T014GJXU940", + "inviter": "W013QGS7BPF", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013CV5UA87"), + ("E013Y3SHLAY", "T014GJXU940", "W013CV5UA87"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "member_left_channel", + "user": "W013CV5UA87", + "channel": "C04T3ACM40K", + "team": "E013Y3SHLAY", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013CV5UA87"), + ("E013Y3SHLAY", "T014GJXU940", "W013CV5UA87"), + ), + ] + + def test_slack_connect_patterns(self): + for ( + request, + (enterprise_id, team_id, user_id), + (actor_enterprise_id, actor_team_id, actor_user_id), + ) in self.slack_connect_from_non_grid_test_patterns: + assert extract_enterprise_id(request) == enterprise_id + assert extract_team_id(request) == team_id + assert extract_user_id(request) == user_id + assert extract_actor_enterprise_id(request) == actor_enterprise_id + assert extract_actor_team_id(request) == actor_team_id + assert extract_actor_user_id(request) == actor_user_id + + for ( + request, + (enterprise_id, team_id, user_id), + (actor_enterprise_id, actor_team_id, actor_user_id), + ) in self.slack_connect_from_grid_test_patterns: + assert extract_enterprise_id(request) == enterprise_id + assert extract_team_id(request) == team_id + assert extract_user_id(request) == user_id + assert extract_actor_enterprise_id(request) == actor_enterprise_id + assert extract_actor_team_id(request) == actor_team_id + assert extract_actor_user_id(request) == actor_user_id From 01ff59eb0a3abe591517a6e71c917f5ba92aaff7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 13 Mar 2023 20:41:06 +0900 Subject: [PATCH 568/865] Set valid Chalice version range (#856) --- setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ba1c1d367..107c0e0e6 100755 --- a/setup.py +++ b/setup.py @@ -77,8 +77,7 @@ # used only under src/slack_bolt/adapter "boto3<=2", "bottle>=0.12,<1", - # TODO: chalice 1.28 dropped Python 3.6 support - "chalice<=1.27.3", + "chalice>=1.28,<2" if sys.version_info.minor > 6 else "chalice<=1.27.3", "CherryPy>=18,<19", "Django>=3,<5", "falcon>=3.1.1,<4" if sys.version_info.minor >= 11 else "falcon>=2,<4", From a301d3abf71381cb1a2adc7727d71ab690e3abbb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 13 Mar 2023 20:45:15 +0900 Subject: [PATCH 569/865] version 1.17.0rc1 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 1c0f78ef4..931c572b1 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.16.4" +__version__ = "1.17.0rc1" From fbba6cd0095d86c9726994fc20866e0bf36461f9 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 14 Mar 2023 07:22:34 +0900 Subject: [PATCH 570/865] Fix a #855 bug where user_scopes exist even when user token is absent (#858) --- slack_bolt/authorization/async_authorize.py | 3 +- slack_bolt/authorization/authorize.py | 3 +- .../test_app_actor_user_token.py | 43 +++++++++++++++++-- .../test_app_actor_user_token.py | 43 +++++++++++++++++-- 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index 18189adc8..76cfaf64c 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -207,10 +207,11 @@ async def __call__( if latest_bot_installation.user_id != user_id: # First off, remove the user token as the installer is a different user user_token = None + user_scopes = None latest_bot_installation.user_token = None latest_bot_installation.user_refresh_token = None latest_bot_installation.user_token_expires_at = None - latest_bot_installation.user_scopes = [] + latest_bot_installation.user_scopes = None # try to fetch the request user's installation # to reflect the user's access token if exists diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 05b306165..170955810 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -206,10 +206,11 @@ def __call__( if latest_bot_installation.user_id != user_id: # First off, remove the user token as the installer is a different user user_token = None + user_scopes = None latest_bot_installation.user_token = None latest_bot_installation.user_refresh_token = None latest_bot_installation.user_token_expires_at = None - latest_bot_installation.user_scopes = [] + latest_bot_installation.user_scopes = None # try to fetch the request user's installation # to reflect the user's access token if exists diff --git a/tests/scenario_tests/test_app_actor_user_token.py b/tests/scenario_tests/test_app_actor_user_token.py index 4fd1b4751..5c05de43e 100644 --- a/tests/scenario_tests/test_app_actor_user_token.py +++ b/tests/scenario_tests/test_app_actor_user_token.py @@ -109,12 +109,12 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_request(self): + def build_request(self, team_id: str = "T014GJXU940"): timestamp, body = str(int(time())), json.dumps( { - "team_id": "T014GJXU940", + "team_id": team_id, "enterprise_id": "E013Y3SHLAY", - "context_team_id": "T014GJXU940", + "context_team_id": team_id, "context_enterprise_id": "E013Y3SHLAY", "api_app_id": "A04TEM7H4S0", "event": { @@ -123,7 +123,7 @@ def build_request(self): "upload": False, "user": "W013QGS7BPF", "display_as_bot": False, - "team": "T014GJXU940", + "team": team_id, "channel": "C04T3ACM40K", "subtype": "file_share", }, @@ -176,3 +176,38 @@ def handle_events(context: BoltContext, say: Say): assert_auth_test_count(self, 1) sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_authorize_result_no_user_token(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="secret", + scopes=["commands", "chat:write"], + user_scopes=["search:read", "chat:write"], + installation_store=MemoryInstallationStore(), + user_token_resolution="actor", + ), + ) + + @app.event("message") + def handle_events(context: BoltContext, say: Say): + assert context.actor_enterprise_id == "E013Y3SHLAY" + assert context.actor_team_id == "T111111" + assert context.actor_user_id == "W013QGS7BPF" + + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user_id is None + assert context.authorize_result.user_token is None + assert context.authorize_result.user_scopes is None + say("What's up?") + + response = app.dispatch(self.build_request(team_id="T111111")) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 diff --git a/tests/scenario_tests_async/test_app_actor_user_token.py b/tests/scenario_tests_async/test_app_actor_user_token.py index c9c395297..f2a7ddd00 100644 --- a/tests/scenario_tests_async/test_app_actor_user_token.py +++ b/tests/scenario_tests_async/test_app_actor_user_token.py @@ -61,12 +61,12 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_request(self) -> AsyncBoltRequest: + def build_request(self, team_id: str = "T014GJXU940") -> AsyncBoltRequest: timestamp, body = str(int(time())), json.dumps( { - "team_id": "T014GJXU940", + "team_id": team_id, "enterprise_id": "E013Y3SHLAY", - "context_team_id": "T014GJXU940", + "context_team_id": team_id, "context_enterprise_id": "E013Y3SHLAY", "api_app_id": "A04TEM7H4S0", "event": { @@ -75,7 +75,7 @@ def build_request(self) -> AsyncBoltRequest: "upload": False, "user": "W013QGS7BPF", "display_as_bot": False, - "team": "T014GJXU940", + "team": team_id, "channel": "C04T3ACM40K", "subtype": "file_share", }, @@ -129,6 +129,41 @@ async def handle_events(context: AsyncBoltContext, say: AsyncSay): await asyncio.sleep(1) # wait a bit after auto ack() assert self.mock_received_requests["/chat.postMessage"] == 1 + @pytest.mark.asyncio + async def test_authorize_result_no_user_token(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=MemoryInstallationStore(), + user_token_resolution="actor", + ), + ) + + @app.event("message") + async def handle_events(context: AsyncBoltContext, say: AsyncSay): + assert context.actor_enterprise_id == "E013Y3SHLAY" + assert context.actor_team_id == "T11111" + assert context.actor_user_id == "W013QGS7BPF" + + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user_id is None + assert context.authorize_result.user_token is None + assert context.authorize_result.user_scopes is None + await say("What's up?") + + request = self.build_request(team_id="T11111") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(1) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + class MemoryInstallationStore(AsyncInstallationStore): @property From 84bb73d841c2cf0d672181dc56baf58c9c18a594 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 14 Mar 2023 07:23:22 +0900 Subject: [PATCH 571/865] version 1.17.0rc2 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 931c572b1..e0099f08d 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.17.0rc1" +__version__ = "1.17.0rc2" From 8334d17ddac15b6e41cc15a6cce533745c20ba7d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 15 Mar 2023 17:40:04 +0900 Subject: [PATCH 572/865] Improve token rotation error handling and installation error text (#861) --- slack_bolt/authorization/async_authorize.py | 12 +++++++++++- slack_bolt/authorization/authorize.py | 12 +++++++++++- .../middleware/authorization/async_internals.py | 3 ++- .../authorization/async_multi_teams_authorization.py | 5 ++++- .../authorization/async_single_team_authorization.py | 5 ++++- slack_bolt/middleware/authorization/internals.py | 9 ++++++++- .../authorization/multi_teams_authorization.py | 4 ++++ .../authorization/single_team_authorization.py | 4 ++++ tests/scenario_tests/test_authorize.py | 2 +- tests/scenario_tests_async/test_authorize.py | 2 +- .../authorization/test_single_team_authorization.py | 3 ++- .../authorization/test_single_team_authorization.py | 3 ++- 12 files changed, 54 insertions(+), 10 deletions(-) diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py index 76cfaf64c..18de1b27a 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -1,7 +1,7 @@ from logging import Logger from typing import Optional, Callable, Awaitable, Dict, Any, List -from slack_sdk.errors import SlackApiError +from slack_sdk.errors import SlackApiError, SlackTokenRotationError from slack_sdk.oauth.installation_store import Bot, Installation from slack_sdk.oauth.installation_store.async_installation_store import ( AsyncInstallationStore, @@ -274,6 +274,11 @@ async def __call__( user_token = refreshed.user_token user_scopes = refreshed.user_scopes + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None except NotImplementedError as _: self.find_installation_available = False @@ -307,6 +312,11 @@ async def __call__( bot_token = refreshed.bot_token bot_scopes = refreshed.bot_scopes + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None except NotImplementedError as _: self.find_bot_available = False except Exception as e: diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 170955810..fde30dd26 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -1,7 +1,7 @@ from logging import Logger from typing import Optional, Callable, Dict, Any, List -from slack_sdk.errors import SlackApiError +from slack_sdk.errors import SlackApiError, SlackTokenRotationError from slack_sdk.oauth import InstallationStore from slack_sdk.oauth.installation_store.models.bot import Bot from slack_sdk.oauth.installation_store.models.installation import Installation @@ -271,6 +271,11 @@ def __call__( user_token = refreshed.user_token user_scopes = refreshed.user_scopes + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None except NotImplementedError as _: self.find_installation_available = False @@ -304,6 +309,11 @@ def __call__( bot_token = refreshed.bot_token bot_scopes = refreshed.bot_scopes + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None except NotImplementedError as _: self.find_bot_available = False except Exception as e: diff --git a/slack_bolt/middleware/authorization/async_internals.py b/slack_bolt/middleware/authorization/async_internals.py index 583dba90f..e465d50d2 100644 --- a/slack_bolt/middleware/authorization/async_internals.py +++ b/slack_bolt/middleware/authorization/async_internals.py @@ -1,3 +1,4 @@ +from slack_bolt.middleware.authorization.internals import _build_error_text from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse @@ -18,5 +19,5 @@ def _build_error_response() -> BoltResponse: # show an ephemeral message to the end-user return BoltResponse( status=200, - body=":x: Please install this app into the workspace :bow:", + body=_build_error_text(), ) diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index ffc47dc29..3a89f0f2b 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -7,7 +7,7 @@ from slack_bolt.response import BoltResponse from .async_authorization import AsyncAuthorization from .async_internals import _build_error_response, _is_no_auth_required -from .internals import _is_no_auth_test_call_required +from .internals import _is_no_auth_test_call_required, _build_error_text from ...authorization import AuthorizeResult from ...authorization.async_authorize import AsyncAuthorize @@ -91,6 +91,9 @@ async def async_process( "Although the app should be installed into this workspace, " "the AuthorizeResult (returned value from authorize) for it was not found." ) + if req.context.response_url is not None: + await req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: diff --git a/slack_bolt/middleware/authorization/async_single_team_authorization.py b/slack_bolt/middleware/authorization/async_single_team_authorization.py index f5c6a08ef..8d3555a0e 100644 --- a/slack_bolt/middleware/authorization/async_single_team_authorization.py +++ b/slack_bolt/middleware/authorization/async_single_team_authorization.py @@ -8,7 +8,7 @@ from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.errors import SlackApiError from .async_internals import _build_error_response, _is_no_auth_required -from .internals import _to_authorize_result, _is_no_auth_test_call_required +from .internals import _to_authorize_result, _is_no_auth_test_call_required, _build_error_text from ...authorization import AuthorizeResult @@ -57,6 +57,9 @@ async def async_process( else: # Just in case self.logger.error("auth.test API call result is unexpectedly None") + if req.context.response_url is not None: + await req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") diff --git a/slack_bolt/middleware/authorization/internals.py b/slack_bolt/middleware/authorization/internals.py index 04d2fb512..af264854b 100644 --- a/slack_bolt/middleware/authorization/internals.py +++ b/slack_bolt/middleware/authorization/internals.py @@ -43,11 +43,18 @@ def _is_no_auth_test_call_required(req: Union[BoltRequest, "AsyncBoltRequest"]) return _is_no_auth_test_events(req) +def _build_error_text() -> str: + return ( + ":warning: We apologize, but for some unknown reason, your installation with this app is no longer available. " + "Please reinstall this app into your workspace :bow:" + ) + + def _build_error_response() -> BoltResponse: # show an ephemeral message to the end-user return BoltResponse( status=200, - body=":x: Please install this app into the workspace :bow:", + body=_build_error_text(), ) diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index 3ce3a54ec..5d464d5e4 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -11,6 +11,7 @@ _build_error_response, _is_no_auth_required, _is_no_auth_test_call_required, + _build_error_text, ) from ...authorization import AuthorizeResult from ...authorization.authorize import Authorize @@ -93,6 +94,9 @@ def process( "Although the app should be installed into this workspace, " "the AuthorizeResult (returned value from authorize) for it was not found." ) + if req.context.response_url is not None: + req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: diff --git a/slack_bolt/middleware/authorization/single_team_authorization.py b/slack_bolt/middleware/authorization/single_team_authorization.py index b567b4299..54cdff5c8 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -12,6 +12,7 @@ _is_no_auth_required, _to_authorize_result, _is_no_auth_test_call_required, + _build_error_text, ) from ...authorization import AuthorizeResult @@ -71,6 +72,9 @@ def process( else: # Just in case self.logger.error("auth.test API call result is unexpectedly None") + if req.context.response_url is not None: + req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") diff --git a/tests/scenario_tests/test_authorize.py b/tests/scenario_tests/test_authorize.py index abc1f2269..603175f03 100644 --- a/tests/scenario_tests/test_authorize.py +++ b/tests/scenario_tests/test_authorize.py @@ -107,7 +107,7 @@ def test_failure(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert response.body == ":x: Please install this app into the workspace :bow:" + assert response.body == "" assert self.mock_received_requests.get("/auth.test") == None def test_bot_context_attributes(self): diff --git a/tests/scenario_tests_async/test_authorize.py b/tests/scenario_tests_async/test_authorize.py index 82e270ed4..9e20ef152 100644 --- a/tests/scenario_tests_async/test_authorize.py +++ b/tests/scenario_tests_async/test_authorize.py @@ -115,7 +115,7 @@ async def test_failure(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert response.body == ":x: Please install this app into the workspace :bow:" + assert response.body == "" assert self.mock_received_requests.get("/auth.test") == None @pytest.mark.asyncio diff --git a/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py index 01821312d..7b7c2d6a0 100644 --- a/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py +++ b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py @@ -1,6 +1,7 @@ from slack_sdk import WebClient from slack_bolt.middleware import SingleTeamAuthorization +from slack_bolt.middleware.authorization.internals import _build_error_text from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from tests.mock_web_api_server import ( @@ -42,4 +43,4 @@ def test_failure_pattern(self): resp = authorization.process(req=req, resp=resp, next=next) assert resp.status == 200 - assert resp.body == ":x: Please install this app into the workspace :bow:" + assert resp.body == _build_error_text() diff --git a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py index 351841b13..fcb34db2f 100644 --- a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py +++ b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py @@ -6,6 +6,7 @@ from slack_bolt.middleware.authorization.async_single_team_authorization import ( AsyncSingleTeamAuthorization, ) +from slack_bolt.middleware.authorization.internals import _build_error_text from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from tests.mock_web_api_server import ( @@ -56,4 +57,4 @@ async def test_failure_pattern(self): resp = await authorization.async_process(req=req, resp=resp, next=next) assert resp.status == 200 - assert resp.body == ":x: Please install this app into the workspace :bow:" + assert resp.body == _build_error_text() From 12aae7ff9ecf49c2f1d11a8dc81088e84f26960e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 15 Mar 2023 17:40:39 +0900 Subject: [PATCH 573/865] version 1.17.0rc3 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index e0099f08d..67f984ffb 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.17.0rc2" +__version__ = "1.17.0rc3" From 48dd31e24b031900a4b478ae8cf1dd4acf629ccc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 22 Mar 2023 07:22:39 +0900 Subject: [PATCH 574/865] Add before_authorize middleware (#869) --- slack_bolt/app/app.py | 16 +++++ slack_bolt/app/async_app.py | 19 +++++- tests/scenario_tests/test_authorize.py | 72 +++++++++++++++++--- tests/scenario_tests_async/test_authorize.py | 72 ++++++++++++++++++-- 4 files changed, 163 insertions(+), 16 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 9d8d09149..e79cac07c 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -101,6 +101,7 @@ def __init__( token_verification_enabled: bool = True, client: Optional[WebClient] = None, # for multi-workspace apps + before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[InstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility @@ -155,6 +156,7 @@ def message_hello(message, say): token: The bot/user access token required only for single-workspace app. token_verification_enabled: Verifies the validity of the given token if True. client: The singleton `slack_sdk.WebClient` instance for this app. + before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data @@ -215,6 +217,17 @@ def message_hello(message, say): # Authorize & OAuthFlow initialization # -------------------------------------- + self._before_authorize: Optional[Middleware] = None + if before_authorize is not None: + if isinstance(before_authorize, Callable): + self._before_authorize = CustomMiddleware( + app_name=self._name, + func=before_authorize, + base_logger=self._framework_logger, + ) + elif isinstance(before_authorize, Middleware): + self._before_authorize = before_authorize + self._authorize: Optional[Authorize] = None if authorize is not None: if isinstance(authorize, Authorize): @@ -357,6 +370,9 @@ def _init_middleware_list( if request_verification_enabled is True: self._middleware_list.append(RequestVerification(self._signing_secret, base_logger=self._base_logger)) + if self._before_authorize is not None: + self._middleware_list.append(self._before_authorize) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._oauth_flow is None: if self._token is not None: diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 1070e4363..8e76bf4b6 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -2,7 +2,7 @@ import logging import os import time -from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable, Sequence +from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable, Sequence, Any from aiohttp import web @@ -112,6 +112,7 @@ def __init__( token: Optional[str] = None, client: Optional[AsyncWebClient] = None, # for multi-workspace apps + before_authorize: Optional[Union[AsyncMiddleware, Callable[..., Awaitable[Any]]]] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[AsyncInstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility @@ -163,6 +164,7 @@ async def message_hello(message, say): # async function signing_secret: The Signing Secret value used for verifying requests from Slack. token: The bot/user access token required only for single-workspace app. client: The singleton `slack_sdk.web.async_client.AsyncWebClient` instance for this app. + before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data @@ -220,6 +222,17 @@ async def message_hello(message, say): # async function # Authorize & OAuthFlow initialization # -------------------------------------- + self._async_before_authorize: Optional[AsyncMiddleware] = None + if before_authorize is not None: + if isinstance(before_authorize, Callable): + self._async_before_authorize = AsyncCustomMiddleware( + app_name=self._name, + func=before_authorize, + base_logger=self._framework_logger, + ) + elif isinstance(before_authorize, AsyncMiddleware): + self._async_before_authorize = before_authorize + self._async_authorize: Optional[AsyncAuthorize] = None if authorize is not None: if isinstance(authorize, AsyncAuthorize): @@ -363,6 +376,10 @@ def _init_async_middleware_list( ) if request_verification_enabled is True: self._async_middleware_list.append(AsyncRequestVerification(self._signing_secret, base_logger=self._base_logger)) + + if self._async_before_authorize is not None: + self._async_middleware_list.append(self._async_before_authorize) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: diff --git a/tests/scenario_tests/test_authorize.py b/tests/scenario_tests/test_authorize.py index 603175f03..bd730e329 100644 --- a/tests/scenario_tests/test_authorize.py +++ b/tests/scenario_tests/test_authorize.py @@ -5,9 +5,10 @@ from slack_sdk import WebClient from slack_sdk.signature import SignatureVerifier -from slack_bolt import BoltRequest +from slack_bolt import BoltRequest, BoltResponse from slack_bolt.app import App from slack_bolt.authorization import AuthorizeResult +from slack_bolt.request.payload_utils import is_event from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -78,8 +79,37 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_valid_request(self) -> BoltRequest: + def build_block_actions_request(self) -> BoltRequest: timestamp = str(int(time())) + return BoltRequest(body=block_actions_raw_body, headers=self.build_headers(timestamp, block_actions_raw_body)) + + def build_message_changed_event_request(self) -> BoltRequest: + timestamp = str(int(time())) + raw_body = json.dumps( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "message_changed", + "channel": "C2147483705", + "ts": "1358878755.000001", + "message": { + "type": "message", + "user": "U2147483697", + "text": "Hello, world!", + "ts": "1355517523.000005", + "edited": {"user": "U2147483697", "ts": "1358878755.000001"}, + }, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + ) return BoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) def test_success(self): @@ -90,7 +120,7 @@ def test_success(self): ) app.action("a")(simple_listener) - request = self.build_valid_request() + request = self.build_block_actions_request() response = app.dispatch(request) assert response.status == 200 assert response.body == "" @@ -104,7 +134,7 @@ def test_failure(self): ) app.action("a")(simple_listener) - request = self.build_valid_request() + request = self.build_block_actions_request() response = app.dispatch(request) assert response.status == 200 assert response.body == "" @@ -118,7 +148,7 @@ def test_bot_context_attributes(self): ) app.action("a")(assert_bot_context_attributes) - request = self.build_valid_request() + request = self.build_block_actions_request() response = app.dispatch(request) assert response.status == 200 assert response.body == "" @@ -132,14 +162,40 @@ def test_user_context_attributes(self): ) app.action("a")(assert_user_context_attributes) - request = self.build_valid_request() + request = self.build_block_actions_request() response = app.dispatch(request) assert response.status == 200 assert response.body == "" assert_auth_test_count(self, 1) + def test_before_authorize(self): + def skip_message_changed_events(body: dict, payload: dict, next_): + if is_event(body) and payload.get("type") == "message" and payload.get("subtype") == "message_changed": + return BoltResponse(status=200, body="as expected") + next_() + + app = App( + client=self.web_client, + before_authorize=skip_message_changed_events, + authorize=user_authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(assert_user_context_attributes) + + request = self.build_block_actions_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + request = self.build_message_changed_event_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "as expected" + assert_auth_test_count(self, 1) # should be skipped + -body = { +block_actions_body = { "type": "block_actions", "user": { "id": "W99999", @@ -176,7 +232,7 @@ def test_user_context_attributes(self): ], } -raw_body = f"payload={quote(json.dumps(body))}" +block_actions_raw_body = f"payload={quote(json.dumps(block_actions_body))}" def simple_listener(ack, body, payload, action): diff --git a/tests/scenario_tests_async/test_authorize.py b/tests/scenario_tests_async/test_authorize.py index 9e20ef152..dec9ebc2c 100644 --- a/tests/scenario_tests_async/test_authorize.py +++ b/tests/scenario_tests_async/test_authorize.py @@ -7,9 +7,11 @@ 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.authorization import AuthorizeResult from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.request.payload_utils import is_event from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -84,8 +86,37 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_valid_request(self) -> AsyncBoltRequest: + def build_block_actions_request(self) -> AsyncBoltRequest: timestamp = str(int(time())) + return AsyncBoltRequest(body=block_actions_raw_body, headers=self.build_headers(timestamp, block_actions_raw_body)) + + def build_message_changed_event_request(self) -> AsyncBoltRequest: + timestamp = str(int(time())) + raw_body = json.dumps( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "message_changed", + "channel": "C2147483705", + "ts": "1358878755.000001", + "message": { + "type": "message", + "user": "U2147483697", + "text": "Hello, world!", + "ts": "1355517523.000005", + "edited": {"user": "U2147483697", "ts": "1358878755.000001"}, + }, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + ) return AsyncBoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) @pytest.mark.asyncio @@ -97,7 +128,7 @@ async def test_success(self): ) app.action("a")(simple_listener) - request = self.build_valid_request() + request = self.build_block_actions_request() response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" @@ -112,7 +143,7 @@ async def test_failure(self): ) app.block_action("a")(simple_listener) - request = self.build_valid_request() + request = self.build_block_actions_request() response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" @@ -127,7 +158,7 @@ async def test_bot_context_attributes(self): ) app.action("a")(assert_bot_context_attributes) - request = self.build_valid_request() + request = self.build_block_actions_request() response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" @@ -142,14 +173,41 @@ async def test_user_context_attributes(self): ) app.action("a")(assert_user_context_attributes) - request = self.build_valid_request() + request = self.build_block_actions_request() response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" await assert_auth_test_count_async(self, 1) + @pytest.mark.asyncio + async def test_user_context_attributes(self): + async def skip_message_changed_events(body: dict, payload: dict, next_): + if is_event(body) and payload.get("type") == "message" and payload.get("subtype") == "message_changed": + return BoltResponse(status=200, body="as expected") + await next_() + + app = AsyncApp( + client=self.web_client, + before_authorize=skip_message_changed_events, + authorize=user_authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(assert_user_context_attributes) + + request = self.build_block_actions_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + request = self.build_message_changed_event_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "as expected" + await assert_auth_test_count_async(self, 1) # should be skipped + -body = { +block_actions_body = { "type": "block_actions", "user": { "id": "W99999", @@ -186,7 +244,7 @@ async def test_user_context_attributes(self): ], } -raw_body = f"payload={quote(json.dumps(body))}" +block_actions_raw_body = f"payload={quote(json.dumps(block_actions_body))}" async def simple_listener(ack, body, payload, action): From c15141e08efa64270cb587e641263743527ef88d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 22 Mar 2023 07:32:37 +0900 Subject: [PATCH 575/865] version 1.17.0rc4 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 67f984ffb..7376943ae 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.17.0rc3" +__version__ = "1.17.0rc4" From f52b7ff69b58ee465e8738ad0fbfc1703b15f37f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 27 Mar 2023 11:46:15 +0900 Subject: [PATCH 576/865] version 1.17.0 --- .../aws_lambda/lambda_s3_oauth_flow.html | 2 + docs/api-docs/slack_bolt/app/app.html | 54 +++- docs/api-docs/slack_bolt/app/async_app.html | 54 +++- docs/api-docs/slack_bolt/app/index.html | 29 ++- docs/api-docs/slack_bolt/async_app.html | 31 ++- .../authorization/async_authorize.html | 242 ++++++++++++++---- .../slack_bolt/authorization/authorize.html | 231 +++++++++++++---- .../authorization/authorize_result.html | 71 ++++- .../slack_bolt/authorization/index.html | 48 +++- .../slack_bolt/context/async_context.html | 3 + .../slack_bolt/context/base_context.html | 111 ++++++++ docs/api-docs/slack_bolt/context/context.html | 3 + docs/api-docs/slack_bolt/context/index.html | 3 + docs/api-docs/slack_bolt/index.html | 32 ++- .../authorization/async_internals.html | 5 +- .../async_multi_teams_authorization.html | 85 ++++-- .../async_single_team_authorization.html | 8 +- .../middleware/authorization/index.html | 43 +++- .../middleware/authorization/internals.html | 9 +- .../multi_teams_authorization.html | 72 +++++- .../single_team_authorization.html | 7 + .../api-docs/slack_bolt/middleware/index.html | 43 +++- .../oauth/async_oauth_settings.html | 31 ++- .../slack_bolt/oauth/oauth_settings.html | 31 ++- .../slack_bolt/request/async_internals.html | 23 ++ .../slack_bolt/request/internals.html | 188 ++++++++++++++ docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 28 files changed, 1308 insertions(+), 155 deletions(-) diff --git a/docs/api-docs/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.html b/docs/api-docs/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.html index 6b08e10fd..f3fe5a2ef 100644 --- a/docs/api-docs/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.html +++ b/docs/api-docs/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.html @@ -87,6 +87,7 @@

    Module slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flo client_secret=settings.client_secret, installation_store=settings.installation_store, bot_only=settings.installation_store_bot_only, + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), ) OAuthFlow.__init__(self, client=client, logger=logger, settings=settings) @@ -176,6 +177,7 @@

    Args

    client_secret=settings.client_secret, installation_store=settings.installation_store, bot_only=settings.installation_store_bot_only, + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), ) OAuthFlow.__init__(self, client=client, logger=logger, settings=settings) diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index 1963d09b4..9490358d6 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -129,6 +129,7 @@

    Module slack_bolt.app.app

    token_verification_enabled: bool = True, client: Optional[WebClient] = None, # for multi-workspace apps + before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[InstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility @@ -183,6 +184,7 @@

    Module slack_bolt.app.app

    token: The bot/user access token required only for single-workspace app. token_verification_enabled: Verifies the validity of the given token if True. client: The singleton `slack_sdk.WebClient` instance for this app. + before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data @@ -243,6 +245,17 @@

    Module slack_bolt.app.app

    # Authorize & OAuthFlow initialization # -------------------------------------- + self._before_authorize: Optional[Middleware] = None + if before_authorize is not None: + if isinstance(before_authorize, Callable): + self._before_authorize = CustomMiddleware( + app_name=self._name, + func=before_authorize, + base_logger=self._framework_logger, + ) + elif isinstance(before_authorize, Middleware): + self._before_authorize = before_authorize + self._authorize: Optional[Authorize] = None if authorize is not None: if isinstance(authorize, Authorize): @@ -266,6 +279,7 @@

    Module slack_bolt.app.app

    logger=self._framework_logger, bot_only=installation_store_bot_only, client=self._client, # for proxy use cases etc. + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), ) self._oauth_flow: Optional[OAuthFlow] = None @@ -384,6 +398,9 @@

    Module slack_bolt.app.app

    if request_verification_enabled is True: self._middleware_list.append(RequestVerification(self._signing_secret, base_logger=self._base_logger)) + if self._before_authorize is not None: + self._middleware_list.append(self._before_authorize) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._oauth_flow is None: if self._token is not None: @@ -407,7 +424,13 @@

    Module slack_bolt.app.app

    else: raise BoltError(error_token_required()) else: - self._middleware_list.append(MultiTeamsAuthorization(authorize=self._authorize, base_logger=self._base_logger)) + self._middleware_list.append( + MultiTeamsAuthorization( + authorize=self._authorize, + base_logger=self._base_logger, + user_token_resolution=self._oauth_flow.settings.user_token_resolution, + ) + ) if ignoring_self_events_enabled is True: self._middleware_list.append(IgnoringSelfEvents(base_logger=self._base_logger)) if url_verification_enabled is True: @@ -1456,7 +1479,7 @@

    Classes

    class App -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, before_authorize: Union[Middleware, Callable[..., Any], ForwardRef(None)] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None)

    Bolt App that provides functionalities to register middleware/listeners.

    @@ -1502,6 +1525,8 @@

    Args

    Verifies the validity of the given token if True.
    client
    The singleton slack_sdk.WebClient instance for this app.
    +
    before_authorize
    +
    A global middleware that can be executed right before authorize function
    authorize
    The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data.
    @@ -1560,6 +1585,7 @@

    Args

    token_verification_enabled: bool = True, client: Optional[WebClient] = None, # for multi-workspace apps + before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[InstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility @@ -1614,6 +1640,7 @@

    Args

    token: The bot/user access token required only for single-workspace app. token_verification_enabled: Verifies the validity of the given token if True. client: The singleton `slack_sdk.WebClient` instance for this app. + before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data @@ -1674,6 +1701,17 @@

    Args

    # Authorize & OAuthFlow initialization # -------------------------------------- + self._before_authorize: Optional[Middleware] = None + if before_authorize is not None: + if isinstance(before_authorize, Callable): + self._before_authorize = CustomMiddleware( + app_name=self._name, + func=before_authorize, + base_logger=self._framework_logger, + ) + elif isinstance(before_authorize, Middleware): + self._before_authorize = before_authorize + self._authorize: Optional[Authorize] = None if authorize is not None: if isinstance(authorize, Authorize): @@ -1697,6 +1735,7 @@

    Args

    logger=self._framework_logger, bot_only=installation_store_bot_only, client=self._client, # for proxy use cases etc. + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), ) self._oauth_flow: Optional[OAuthFlow] = None @@ -1815,6 +1854,9 @@

    Args

    if request_verification_enabled is True: self._middleware_list.append(RequestVerification(self._signing_secret, base_logger=self._base_logger)) + if self._before_authorize is not None: + self._middleware_list.append(self._before_authorize) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._oauth_flow is None: if self._token is not None: @@ -1838,7 +1880,13 @@

    Args

    else: raise BoltError(error_token_required()) else: - self._middleware_list.append(MultiTeamsAuthorization(authorize=self._authorize, base_logger=self._base_logger)) + self._middleware_list.append( + MultiTeamsAuthorization( + authorize=self._authorize, + base_logger=self._base_logger, + user_token_resolution=self._oauth_flow.settings.user_token_resolution, + ) + ) if ignoring_self_events_enabled is True: self._middleware_list.append(IgnoringSelfEvents(base_logger=self._base_logger)) if url_verification_enabled is True: diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index 0fdf7f437..f373a32e6 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -30,7 +30,7 @@

    Module slack_bolt.app.async_app

    import logging import os import time -from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable, Sequence +from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable, Sequence, Any from aiohttp import web @@ -140,6 +140,7 @@

    Module slack_bolt.app.async_app

    token: Optional[str] = None, client: Optional[AsyncWebClient] = None, # for multi-workspace apps + before_authorize: Optional[Union[AsyncMiddleware, Callable[..., Awaitable[Any]]]] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[AsyncInstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility @@ -191,6 +192,7 @@

    Module slack_bolt.app.async_app

    signing_secret: The Signing Secret value used for verifying requests from Slack. token: The bot/user access token required only for single-workspace app. client: The singleton `slack_sdk.web.async_client.AsyncWebClient` instance for this app. + before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data @@ -248,6 +250,17 @@

    Module slack_bolt.app.async_app

    # Authorize & OAuthFlow initialization # -------------------------------------- + self._async_before_authorize: Optional[AsyncMiddleware] = None + if before_authorize is not None: + if isinstance(before_authorize, Callable): + self._async_before_authorize = AsyncCustomMiddleware( + app_name=self._name, + func=before_authorize, + base_logger=self._framework_logger, + ) + elif isinstance(before_authorize, AsyncMiddleware): + self._async_before_authorize = before_authorize + self._async_authorize: Optional[AsyncAuthorize] = None if authorize is not None: if isinstance(authorize, AsyncAuthorize): @@ -271,6 +284,7 @@

    Module slack_bolt.app.async_app

    logger=self._framework_logger, bot_only=installation_store_bot_only, client=self._async_client, # for proxy use cases etc. + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), ) self._async_oauth_flow: Optional[AsyncOAuthFlow] = None @@ -390,6 +404,10 @@

    Module slack_bolt.app.async_app

    ) if request_verification_enabled is True: self._async_middleware_list.append(AsyncRequestVerification(self._signing_secret, base_logger=self._base_logger)) + + if self._async_before_authorize is not None: + self._async_middleware_list.append(self._async_before_authorize) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: @@ -402,7 +420,11 @@

    Module slack_bolt.app.async_app

    raise BoltError(error_token_required()) else: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize, base_logger=self._base_logger) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, + base_logger=self._base_logger, + user_token_resolution=self._async_oauth_flow.settings.user_token_resolution, + ) ) if ignoring_self_events_enabled is True: @@ -1370,7 +1392,7 @@

    Classes

    class AsyncApp -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, verification_token: Optional[str] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, before_authorize: Union[AsyncMiddleware, Callable[..., Awaitable[Any]], ForwardRef(None)] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, verification_token: Optional[str] = None)

    Bolt App that provides functionalities to register middleware/listeners.

    @@ -1414,6 +1436,8 @@

    Args

    The bot/user access token required only for single-workspace app.
    client
    The singleton slack_sdk.web.async_client.AsyncWebClient instance for this app.
    +
    before_authorize
    +
    A global middleware that can be executed right before authorize function
    authorize
    The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data.
    @@ -1468,6 +1492,7 @@

    Args

    token: Optional[str] = None, client: Optional[AsyncWebClient] = None, # for multi-workspace apps + before_authorize: Optional[Union[AsyncMiddleware, Callable[..., Awaitable[Any]]]] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[AsyncInstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility @@ -1519,6 +1544,7 @@

    Args

    signing_secret: The Signing Secret value used for verifying requests from Slack. token: The bot/user access token required only for single-workspace app. client: The singleton `slack_sdk.web.async_client.AsyncWebClient` instance for this app. + before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data @@ -1576,6 +1602,17 @@

    Args

    # Authorize & OAuthFlow initialization # -------------------------------------- + self._async_before_authorize: Optional[AsyncMiddleware] = None + if before_authorize is not None: + if isinstance(before_authorize, Callable): + self._async_before_authorize = AsyncCustomMiddleware( + app_name=self._name, + func=before_authorize, + base_logger=self._framework_logger, + ) + elif isinstance(before_authorize, AsyncMiddleware): + self._async_before_authorize = before_authorize + self._async_authorize: Optional[AsyncAuthorize] = None if authorize is not None: if isinstance(authorize, AsyncAuthorize): @@ -1599,6 +1636,7 @@

    Args

    logger=self._framework_logger, bot_only=installation_store_bot_only, client=self._async_client, # for proxy use cases etc. + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), ) self._async_oauth_flow: Optional[AsyncOAuthFlow] = None @@ -1718,6 +1756,10 @@

    Args

    ) if request_verification_enabled is True: self._async_middleware_list.append(AsyncRequestVerification(self._signing_secret, base_logger=self._base_logger)) + + if self._async_before_authorize is not None: + self._async_middleware_list.append(self._async_before_authorize) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: @@ -1730,7 +1772,11 @@

    Args

    raise BoltError(error_token_required()) else: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize, base_logger=self._base_logger) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, + base_logger=self._base_logger, + user_token_resolution=self._async_oauth_flow.settings.user_token_resolution, + ) ) if ignoring_self_events_enabled is True: diff --git a/docs/api-docs/slack_bolt/app/index.html b/docs/api-docs/slack_bolt/app/index.html index d9332bacd..4175c54ac 100644 --- a/docs/api-docs/slack_bolt/app/index.html +++ b/docs/api-docs/slack_bolt/app/index.html @@ -72,7 +72,7 @@

    Classes

    class App -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, before_authorize: Union[Middleware, Callable[..., Any], ForwardRef(None)] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None)

    Bolt App that provides functionalities to register middleware/listeners.

    @@ -118,6 +118,8 @@

    Args

    Verifies the validity of the given token if True.
    client
    The singleton slack_sdk.WebClient instance for this app.
    +
    before_authorize
    +
    A global middleware that can be executed right before authorize function
    authorize
    The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data.
    @@ -176,6 +178,7 @@

    Args

    token_verification_enabled: bool = True, client: Optional[WebClient] = None, # for multi-workspace apps + before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[InstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility @@ -230,6 +233,7 @@

    Args

    token: The bot/user access token required only for single-workspace app. token_verification_enabled: Verifies the validity of the given token if True. client: The singleton `slack_sdk.WebClient` instance for this app. + before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data @@ -290,6 +294,17 @@

    Args

    # Authorize & OAuthFlow initialization # -------------------------------------- + self._before_authorize: Optional[Middleware] = None + if before_authorize is not None: + if isinstance(before_authorize, Callable): + self._before_authorize = CustomMiddleware( + app_name=self._name, + func=before_authorize, + base_logger=self._framework_logger, + ) + elif isinstance(before_authorize, Middleware): + self._before_authorize = before_authorize + self._authorize: Optional[Authorize] = None if authorize is not None: if isinstance(authorize, Authorize): @@ -313,6 +328,7 @@

    Args

    logger=self._framework_logger, bot_only=installation_store_bot_only, client=self._client, # for proxy use cases etc. + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), ) self._oauth_flow: Optional[OAuthFlow] = None @@ -431,6 +447,9 @@

    Args

    if request_verification_enabled is True: self._middleware_list.append(RequestVerification(self._signing_secret, base_logger=self._base_logger)) + if self._before_authorize is not None: + self._middleware_list.append(self._before_authorize) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._oauth_flow is None: if self._token is not None: @@ -454,7 +473,13 @@

    Args

    else: raise BoltError(error_token_required()) else: - self._middleware_list.append(MultiTeamsAuthorization(authorize=self._authorize, base_logger=self._base_logger)) + self._middleware_list.append( + MultiTeamsAuthorization( + authorize=self._authorize, + base_logger=self._base_logger, + user_token_resolution=self._oauth_flow.settings.user_token_resolution, + ) + ) if ignoring_self_events_enabled is True: self._middleware_list.append(IgnoringSelfEvents(base_logger=self._base_logger)) if url_verification_enabled is True: diff --git a/docs/api-docs/slack_bolt/async_app.html b/docs/api-docs/slack_bolt/async_app.html index 725f5929d..e6b693440 100644 --- a/docs/api-docs/slack_bolt/async_app.html +++ b/docs/api-docs/slack_bolt/async_app.html @@ -195,7 +195,7 @@

    Class variables

    class AsyncApp -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, verification_token: Optional[str] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, before_authorize: Union[AsyncMiddleware, Callable[..., Awaitable[Any]], ForwardRef(None)] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, verification_token: Optional[str] = None)

    Bolt App that provides functionalities to register middleware/listeners.

    @@ -239,6 +239,8 @@

    Args

    The bot/user access token required only for single-workspace app.
    client
    The singleton slack_sdk.web.async_client.AsyncWebClient instance for this app.
    +
    before_authorize
    +
    A global middleware that can be executed right before authorize function
    authorize
    The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data.
    @@ -293,6 +295,7 @@

    Args

    token: Optional[str] = None, client: Optional[AsyncWebClient] = None, # for multi-workspace apps + before_authorize: Optional[Union[AsyncMiddleware, Callable[..., Awaitable[Any]]]] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[AsyncInstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility @@ -344,6 +347,7 @@

    Args

    signing_secret: The Signing Secret value used for verifying requests from Slack. token: The bot/user access token required only for single-workspace app. client: The singleton `slack_sdk.web.async_client.AsyncWebClient` instance for this app. + before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data @@ -401,6 +405,17 @@

    Args

    # Authorize & OAuthFlow initialization # -------------------------------------- + self._async_before_authorize: Optional[AsyncMiddleware] = None + if before_authorize is not None: + if isinstance(before_authorize, Callable): + self._async_before_authorize = AsyncCustomMiddleware( + app_name=self._name, + func=before_authorize, + base_logger=self._framework_logger, + ) + elif isinstance(before_authorize, AsyncMiddleware): + self._async_before_authorize = before_authorize + self._async_authorize: Optional[AsyncAuthorize] = None if authorize is not None: if isinstance(authorize, AsyncAuthorize): @@ -424,6 +439,7 @@

    Args

    logger=self._framework_logger, bot_only=installation_store_bot_only, client=self._async_client, # for proxy use cases etc. + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), ) self._async_oauth_flow: Optional[AsyncOAuthFlow] = None @@ -543,6 +559,10 @@

    Args

    ) if request_verification_enabled is True: self._async_middleware_list.append(AsyncRequestVerification(self._signing_secret, base_logger=self._base_logger)) + + if self._async_before_authorize is not None: + self._async_middleware_list.append(self._async_before_authorize) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: @@ -555,7 +575,11 @@

    Args

    raise BoltError(error_token_required()) else: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize, base_logger=self._base_logger) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, + base_logger=self._base_logger, + user_token_resolution=self._async_oauth_flow.settings.user_token_resolution, + ) ) if ignoring_self_events_enabled is True: @@ -3328,6 +3352,9 @@

    Inherited members

    • BaseContext:
        +
      • actor_enterprise_id
      • +
      • actor_team_id
      • +
      • actor_user_id
      • authorize_result
      • bot_id
      • bot_token
      • diff --git a/docs/api-docs/slack_bolt/authorization/async_authorize.html b/docs/api-docs/slack_bolt/authorization/async_authorize.html index f74cb5f26..3b906d61f 100644 --- a/docs/api-docs/slack_bolt/authorization/async_authorize.html +++ b/docs/api-docs/slack_bolt/authorization/async_authorize.html @@ -27,9 +27,9 @@

        Module slack_bolt.authorization.async_authorizeExpand source code
        from logging import Logger
        -from typing import Optional, Callable, Awaitable, Dict, Any
        +from typing import Optional, Callable, Awaitable, Dict, Any, List
         
        -from slack_sdk.errors import SlackApiError
        +from slack_sdk.errors import SlackApiError, SlackTokenRotationError
         from slack_sdk.oauth.installation_store import Bot, Installation
         from slack_sdk.oauth.installation_store.async_installation_store import (
             AsyncInstallationStore,
        @@ -58,6 +58,10 @@ 

        Module slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeModule slack_bolt.authorization.async_authorizeClasses

        enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: raise NotImplementedError()
        @@ -404,6 +481,10 @@

        Subclasses

        enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: try: all_available_args = { @@ -419,6 +500,9 @@

        Subclasses

        "enterprise_id": enterprise_id, "team_id": team_id, "user_id": user_id, + "actor_enterprise_id": actor_enterprise_id, + "actor_team_id": actor_team_id, + "actor_user_id": actor_user_id, } for k, v in context.items(): if k not in all_available_args: @@ -455,7 +539,7 @@

        Ancestors

        class AsyncInstallationStoreAuthorize -(*, logger: logging.Logger, installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore, client_id: Optional[str] = None, client_secret: Optional[str] = None, token_rotation_expiration_minutes: Optional[int] = None, bot_only: bool = False, cache_enabled: bool = False, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None) +(*, logger: logging.Logger, installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore, client_id: Optional[str] = None, client_secret: Optional[str] = None, token_rotation_expiration_minutes: Optional[int] = None, bot_only: bool = False, cache_enabled: bool = False, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, user_token_resolution: str = 'authed_user')

        If you use the OAuth flow settings, this authorize implementation will be used. @@ -472,6 +556,8 @@

        Ancestors

        """ authorize_result_cache: Dict[str, AuthorizeResult] + bot_only: bool + user_token_resolution: str find_installation_available: Optional[bool] find_bot_available: Optional[bool] token_rotator: Optional[AsyncTokenRotator] @@ -491,10 +577,13 @@

        Ancestors

        bot_only: bool = False, cache_enabled: bool = False, client: Optional[AsyncWebClient] = None, + # Since v1.27, user token resolution can be actor ID based when the mode is enabled + user_token_resolution: str = "authed_user", ): self.logger = logger self.installation_store = installation_store self.bot_only = bot_only + self.user_token_resolution = user_token_resolution self.cache_enabled = cache_enabled self.authorize_result_cache = {} self.find_installation_available = None @@ -516,6 +605,10 @@

        Ancestors

        enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: if self.find_installation_available is None: @@ -525,6 +618,10 @@

        Ancestors

        bot_token: Optional[str] = None user_token: Optional[str] = None + bot_scopes: Optional[List[str]] = None + user_scopes: Optional[List[str]] = None + latest_bot_installation: Optional[Installation] = None + this_user_installation: Optional[Installation] = None if not self.bot_only and self.find_installation_available: # Since v1.1, this is the default way. @@ -532,7 +629,7 @@

        Ancestors

        try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. - latest_installation: Optional[Installation] = await self.installation_store.async_find_installation( + latest_bot_installation = await self.installation_store.async_find_installation( enterprise_id=enterprise_id, team_id=team_id, is_enterprise_install=context.is_enterprise_install, @@ -542,52 +639,88 @@

        Ancestors

        # The example use cases are: # - The app's installation requires both bot and user tokens # - The app has two installation paths 1) bot installation 2) individual user authorization - this_user_installation: Optional[Installation] = None - - if latest_installation is not None: + if latest_bot_installation is not None: # Save the latest bot token - bot_token = latest_installation.bot_token # this still can be None - user_token = latest_installation.user_token # this still can be None + bot_token = latest_bot_installation.bot_token # this still can be None + user_token = latest_bot_installation.user_token # this still can be None + bot_scopes = latest_bot_installation.bot_scopes # this still can be None + user_scopes = latest_bot_installation.user_scopes # this still can be None - if latest_installation.user_id != user_id: + if latest_bot_installation.user_id != user_id: # First off, remove the user token as the installer is a different user user_token = None - latest_installation.user_token = None - latest_installation.user_refresh_token = None - latest_installation.user_token_expires_at = None - latest_installation.user_scopes = [] + user_scopes = None + latest_bot_installation.user_token = None + latest_bot_installation.user_refresh_token = None + latest_bot_installation.user_token_expires_at = None + latest_bot_installation.user_scopes = None # try to fetch the request user's installation # to reflect the user's access token if exists - this_user_installation = await self.installation_store.async_find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, - ) + # try to fetch the request user's installation + # to reflect the user's access token if exists + if self.user_token_resolution == "actor": + if actor_enterprise_id is not None or actor_team_id is not None: + # Note that actor_team_id can be absent for app_mention events + this_user_installation = await self.installation_store.async_find_installation( + enterprise_id=actor_enterprise_id, + team_id=actor_team_id, + user_id=actor_user_id, + is_enterprise_install=None, + ) + else: + this_user_installation = await self.installation_store.async_find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) if this_user_installation is not None: user_token = this_user_installation.user_token - if latest_installation.bot_token is None: + user_scopes = this_user_installation.user_scopes + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): # If latest_installation has a bot token, we never overwrite the value bot_token = this_user_installation.bot_token + bot_scopes = this_user_installation.bot_scopes # If token rotation is enabled, running rotation may be needed here refreshed = await self._rotate_and_save_tokens_if_necessary(this_user_installation) if refreshed is not None: user_token = refreshed.user_token - if latest_installation.bot_token is None: + user_scopes = refreshed.user_scopes + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): # If latest_installation has a bot token, we never overwrite the value bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes # If token rotation is enabled, running rotation may be needed here - refreshed = await self._rotate_and_save_tokens_if_necessary(latest_installation) + refreshed = await self._rotate_and_save_tokens_if_necessary(latest_bot_installation) if refreshed is not None: bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes if this_user_installation is None: # Only when we don't have `this_user_installation` here, # the `user_token` is for the user associated with this request user_token = refreshed.user_token + user_scopes = refreshed.user_scopes + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None except NotImplementedError as _: self.find_installation_available = False @@ -607,6 +740,7 @@

        Ancestors

        ) if bot is not None: bot_token = bot.bot_token + bot_scopes = bot.bot_scopes if bot.bot_refresh_token is not None: # Token rotation if self.token_rotator is None: @@ -618,7 +752,13 @@

        Ancestors

        if refreshed is not None: await self.installation_store.async_save_bot(refreshed) bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None except NotImplementedError as _: self.find_bot_available = False except Exception as e: @@ -636,10 +776,16 @@

        Ancestors

        try: auth_test_api_response = await context.client.auth_test(token=token) + user_auth_test_response = None + if user_token is not None and token != user_token: + user_auth_test_response = await context.client.auth_test(token=user_token) authorize_result = AuthorizeResult.from_auth_test_response( auth_test_response=auth_test_api_response, + user_auth_test_response=user_auth_test_response, bot_token=bot_token, user_token=user_token, + bot_scopes=bot_scopes, + user_scopes=user_scopes, ) if self.cache_enabled: self.authorize_result_cache[token] = authorize_result @@ -684,6 +830,10 @@

        Class variables

        +
        var bot_only : bool
        +
        +
        +
        var find_bot_available : Optional[bool]
        @@ -696,6 +846,10 @@

        Class variables

        +
        var user_token_resolution : str
        +
        +
        +
    @@ -724,9 +878,11 @@

    AsyncInstallationStoreAuthorize

    diff --git a/docs/api-docs/slack_bolt/authorization/authorize.html b/docs/api-docs/slack_bolt/authorization/authorize.html index 5e1015260..81e17746f 100644 --- a/docs/api-docs/slack_bolt/authorization/authorize.html +++ b/docs/api-docs/slack_bolt/authorization/authorize.html @@ -27,9 +27,9 @@

    Module slack_bolt.authorization.authorize

    Expand source code
    from logging import Logger
    -from typing import Optional, Callable, Dict, Any
    +from typing import Optional, Callable, Dict, Any, List
     
    -from slack_sdk.errors import SlackApiError
    +from slack_sdk.errors import SlackApiError, SlackTokenRotationError
     from slack_sdk.oauth import InstallationStore
     from slack_sdk.oauth.installation_store.models.bot import Bot
     from slack_sdk.oauth.installation_store.models.installation import Installation
    @@ -57,6 +57,10 @@ 

    Module slack_bolt.authorization.authorize

    enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: raise NotImplementedError() @@ -83,6 +87,10 @@

    Module slack_bolt.authorization.authorize

    enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: try: all_available_args = { @@ -98,6 +106,9 @@

    Module slack_bolt.authorization.authorize

    "enterprise_id": enterprise_id, "team_id": team_id, "user_id": user_id, + "actor_enterprise_id": actor_enterprise_id, + "actor_team_id": actor_team_id, + "actor_user_id": actor_user_id, } for k, v in context.items(): if k not in all_available_args: @@ -136,6 +147,7 @@

    Module slack_bolt.authorization.authorize

    authorize_result_cache: Dict[str, AuthorizeResult] bot_only: bool + user_token_resolution: str find_installation_available: bool find_bot_available: bool token_rotator: Optional[TokenRotator] @@ -155,10 +167,13 @@

    Module slack_bolt.authorization.authorize

    bot_only: bool = False, cache_enabled: bool = False, client: Optional[WebClient] = None, + # Since v1.27, user token resolution can be actor ID based when the mode is enabled + user_token_resolution: str = "authed_user", ): self.logger = logger self.installation_store = installation_store self.bot_only = bot_only + self.user_token_resolution = user_token_resolution self.cache_enabled = cache_enabled self.authorize_result_cache = {} self.find_installation_available = hasattr(installation_store, "find_installation") @@ -180,10 +195,18 @@

    Module slack_bolt.authorization.authorize

    enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: bot_token: Optional[str] = None user_token: Optional[str] = None + bot_scopes: Optional[List[str]] = None + user_scopes: Optional[List[str]] = None + latest_bot_installation: Optional[Installation] = None + this_user_installation: Optional[Installation] = None if not self.bot_only and self.find_installation_available: # Since v1.1, this is the default way. @@ -191,7 +214,7 @@

    Module slack_bolt.authorization.authorize

    try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. - latest_installation: Optional[Installation] = self.installation_store.find_installation( + latest_bot_installation = self.installation_store.find_installation( enterprise_id=enterprise_id, team_id=team_id, is_enterprise_install=context.is_enterprise_install, @@ -201,52 +224,86 @@

    Module slack_bolt.authorization.authorize

    # The example use cases are: # - The app's installation requires both bot and user tokens # - The app has two installation paths 1) bot installation 2) individual user authorization - this_user_installation: Optional[Installation] = None - - if latest_installation is not None: + if latest_bot_installation is not None: # Save the latest bot token - bot_token = latest_installation.bot_token # this still can be None - user_token = latest_installation.user_token # this still can be None + bot_token = latest_bot_installation.bot_token # this still can be None + user_token = latest_bot_installation.user_token # this still can be None + bot_scopes = latest_bot_installation.bot_scopes # this still can be None + user_scopes = latest_bot_installation.user_scopes # this still can be None - if latest_installation.user_id != user_id: + if latest_bot_installation.user_id != user_id: # First off, remove the user token as the installer is a different user user_token = None - latest_installation.user_token = None - latest_installation.user_refresh_token = None - latest_installation.user_token_expires_at = None - latest_installation.user_scopes = [] + user_scopes = None + latest_bot_installation.user_token = None + latest_bot_installation.user_refresh_token = None + latest_bot_installation.user_token_expires_at = None + latest_bot_installation.user_scopes = None # try to fetch the request user's installation # to reflect the user's access token if exists - this_user_installation = self.installation_store.find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, - ) + if self.user_token_resolution == "actor": + if actor_enterprise_id is not None or actor_team_id is not None: + # Note that actor_team_id can be absent for app_mention events + this_user_installation = self.installation_store.find_installation( + enterprise_id=actor_enterprise_id, + team_id=actor_team_id, + user_id=actor_user_id, + is_enterprise_install=None, + ) + else: + this_user_installation = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) if this_user_installation is not None: user_token = this_user_installation.user_token - if latest_installation.bot_token is None: + user_scopes = this_user_installation.user_scopes + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): # If latest_installation has a bot token, we never overwrite the value bot_token = this_user_installation.bot_token + bot_scopes = this_user_installation.bot_scopes # If token rotation is enabled, running rotation may be needed here refreshed = self._rotate_and_save_tokens_if_necessary(this_user_installation) if refreshed is not None: user_token = refreshed.user_token - if latest_installation.bot_token is None: + user_scopes = refreshed.user_scopes + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): # If latest_installation has a bot token, we never overwrite the value bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes # If token rotation is enabled, running rotation may be needed here - refreshed = self._rotate_and_save_tokens_if_necessary(latest_installation) + refreshed = self._rotate_and_save_tokens_if_necessary(latest_bot_installation) if refreshed is not None: bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes if this_user_installation is None: # Only when we don't have `this_user_installation` here, # the `user_token` is for the user associated with this request user_token = refreshed.user_token + user_scopes = refreshed.user_scopes + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None except NotImplementedError as _: self.find_installation_available = False @@ -266,6 +323,7 @@

    Module slack_bolt.authorization.authorize

    ) if bot is not None: bot_token = bot.bot_token + bot_scopes = bot.bot_scopes if bot.bot_refresh_token is not None: # Token rotation if self.token_rotator is None: @@ -277,7 +335,13 @@

    Module slack_bolt.authorization.authorize

    if refreshed is not None: self.installation_store.save_bot(refreshed) bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None except NotImplementedError as _: self.find_bot_available = False except Exception as e: @@ -295,10 +359,16 @@

    Module slack_bolt.authorization.authorize

    try: auth_test_api_response = context.client.auth_test(token=token) + user_auth_test_response = None + if user_token is not None and token != user_token: + user_auth_test_response = context.client.auth_test(token=user_token) authorize_result = AuthorizeResult.from_auth_test_response( auth_test_response=auth_test_api_response, + user_auth_test_response=user_auth_test_response, bot_token=bot_token, user_token=user_token, + bot_scopes=bot_scopes, + user_scopes=user_scopes, ) if self.cache_enabled: self.authorize_result_cache[token] = authorize_result @@ -367,6 +437,10 @@

    Classes

    enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: raise NotImplementedError()
    @@ -409,6 +483,10 @@

    Subclasses

    enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: try: all_available_args = { @@ -424,6 +502,9 @@

    Subclasses

    "enterprise_id": enterprise_id, "team_id": team_id, "user_id": user_id, + "actor_enterprise_id": actor_enterprise_id, + "actor_team_id": actor_team_id, + "actor_user_id": actor_user_id, } for k, v in context.items(): if k not in all_available_args: @@ -460,7 +541,7 @@

    Ancestors

    class InstallationStoreAuthorize -(*, logger: logging.Logger, installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore, client_id: Optional[str] = None, client_secret: Optional[str] = None, token_rotation_expiration_minutes: Optional[int] = None, bot_only: bool = False, cache_enabled: bool = False, client: Optional[slack_sdk.web.client.WebClient] = None) +(*, logger: logging.Logger, installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore, client_id: Optional[str] = None, client_secret: Optional[str] = None, token_rotation_expiration_minutes: Optional[int] = None, bot_only: bool = False, cache_enabled: bool = False, client: Optional[slack_sdk.web.client.WebClient] = None, user_token_resolution: str = 'authed_user')

    If you use the OAuth flow settings, this authorize implementation will be used. @@ -478,6 +559,7 @@

    Ancestors

    authorize_result_cache: Dict[str, AuthorizeResult] bot_only: bool + user_token_resolution: str find_installation_available: bool find_bot_available: bool token_rotator: Optional[TokenRotator] @@ -497,10 +579,13 @@

    Ancestors

    bot_only: bool = False, cache_enabled: bool = False, client: Optional[WebClient] = None, + # Since v1.27, user token resolution can be actor ID based when the mode is enabled + user_token_resolution: str = "authed_user", ): self.logger = logger self.installation_store = installation_store self.bot_only = bot_only + self.user_token_resolution = user_token_resolution self.cache_enabled = cache_enabled self.authorize_result_cache = {} self.find_installation_available = hasattr(installation_store, "find_installation") @@ -522,10 +607,18 @@

    Ancestors

    enterprise_id: Optional[str], team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, ) -> Optional[AuthorizeResult]: bot_token: Optional[str] = None user_token: Optional[str] = None + bot_scopes: Optional[List[str]] = None + user_scopes: Optional[List[str]] = None + latest_bot_installation: Optional[Installation] = None + this_user_installation: Optional[Installation] = None if not self.bot_only and self.find_installation_available: # Since v1.1, this is the default way. @@ -533,7 +626,7 @@

    Ancestors

    try: # Note that this is the latest information for the org/workspace. # The installer may not be the user associated with this incoming request. - latest_installation: Optional[Installation] = self.installation_store.find_installation( + latest_bot_installation = self.installation_store.find_installation( enterprise_id=enterprise_id, team_id=team_id, is_enterprise_install=context.is_enterprise_install, @@ -543,52 +636,86 @@

    Ancestors

    # The example use cases are: # - The app's installation requires both bot and user tokens # - The app has two installation paths 1) bot installation 2) individual user authorization - this_user_installation: Optional[Installation] = None - - if latest_installation is not None: + if latest_bot_installation is not None: # Save the latest bot token - bot_token = latest_installation.bot_token # this still can be None - user_token = latest_installation.user_token # this still can be None + bot_token = latest_bot_installation.bot_token # this still can be None + user_token = latest_bot_installation.user_token # this still can be None + bot_scopes = latest_bot_installation.bot_scopes # this still can be None + user_scopes = latest_bot_installation.user_scopes # this still can be None - if latest_installation.user_id != user_id: + if latest_bot_installation.user_id != user_id: # First off, remove the user token as the installer is a different user user_token = None - latest_installation.user_token = None - latest_installation.user_refresh_token = None - latest_installation.user_token_expires_at = None - latest_installation.user_scopes = [] + user_scopes = None + latest_bot_installation.user_token = None + latest_bot_installation.user_refresh_token = None + latest_bot_installation.user_token_expires_at = None + latest_bot_installation.user_scopes = None # try to fetch the request user's installation # to reflect the user's access token if exists - this_user_installation = self.installation_store.find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=user_id, - is_enterprise_install=context.is_enterprise_install, - ) + if self.user_token_resolution == "actor": + if actor_enterprise_id is not None or actor_team_id is not None: + # Note that actor_team_id can be absent for app_mention events + this_user_installation = self.installation_store.find_installation( + enterprise_id=actor_enterprise_id, + team_id=actor_team_id, + user_id=actor_user_id, + is_enterprise_install=None, + ) + else: + this_user_installation = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) if this_user_installation is not None: user_token = this_user_installation.user_token - if latest_installation.bot_token is None: + user_scopes = this_user_installation.user_scopes + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): # If latest_installation has a bot token, we never overwrite the value bot_token = this_user_installation.bot_token + bot_scopes = this_user_installation.bot_scopes # If token rotation is enabled, running rotation may be needed here refreshed = self._rotate_and_save_tokens_if_necessary(this_user_installation) if refreshed is not None: user_token = refreshed.user_token - if latest_installation.bot_token is None: + user_scopes = refreshed.user_scopes + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): # If latest_installation has a bot token, we never overwrite the value bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes # If token rotation is enabled, running rotation may be needed here - refreshed = self._rotate_and_save_tokens_if_necessary(latest_installation) + refreshed = self._rotate_and_save_tokens_if_necessary(latest_bot_installation) if refreshed is not None: bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes if this_user_installation is None: # Only when we don't have `this_user_installation` here, # the `user_token` is for the user associated with this request user_token = refreshed.user_token + user_scopes = refreshed.user_scopes + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None except NotImplementedError as _: self.find_installation_available = False @@ -608,6 +735,7 @@

    Ancestors

    ) if bot is not None: bot_token = bot.bot_token + bot_scopes = bot.bot_scopes if bot.bot_refresh_token is not None: # Token rotation if self.token_rotator is None: @@ -619,7 +747,13 @@

    Ancestors

    if refreshed is not None: self.installation_store.save_bot(refreshed) bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None except NotImplementedError as _: self.find_bot_available = False except Exception as e: @@ -637,10 +771,16 @@

    Ancestors

    try: auth_test_api_response = context.client.auth_test(token=token) + user_auth_test_response = None + if user_token is not None and token != user_token: + user_auth_test_response = context.client.auth_test(token=user_token) authorize_result = AuthorizeResult.from_auth_test_response( auth_test_response=auth_test_api_response, + user_auth_test_response=user_auth_test_response, bot_token=bot_token, user_token=user_token, + bot_scopes=bot_scopes, + user_scopes=user_scopes, ) if self.cache_enabled: self.authorize_result_cache[token] = authorize_result @@ -701,6 +841,10 @@

    Class variables

    +
    var user_token_resolution : str
    +
    +
    +
    @@ -733,6 +877,7 @@

    find_bot_available
  • find_installation_available
  • token_rotator
  • +
  • user_token_resolution
  • diff --git a/docs/api-docs/slack_bolt/authorization/authorize_result.html b/docs/api-docs/slack_bolt/authorization/authorize_result.html index 4f84e22ba..185aa8cec 100644 --- a/docs/api-docs/slack_bolt/authorization/authorize_result.html +++ b/docs/api-docs/slack_bolt/authorization/authorize_result.html @@ -26,7 +26,7 @@

    Module slack_bolt.authorization.authorize_result< Expand source code -
    from typing import Optional
    +
    from typing import Optional, List, Union
     
     from slack_sdk.web import SlackResponse
     
    @@ -39,8 +39,10 @@ 

    Module slack_bolt.authorization.authorize_result< bot_id: Optional[str] bot_user_id: Optional[str] bot_token: Optional[str] + bot_scopes: Optional[List[str]] # since v1.17 user_id: Optional[str] user_token: Optional[str] + user_scopes: Optional[List[str]] # since v1.17 def __init__( self, @@ -51,9 +53,11 @@

    Module slack_bolt.authorization.authorize_result< bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, + bot_scopes: Optional[Union[List[str], str]] = None, # user user_id: Optional[str] = None, user_token: Optional[str] = None, + user_scopes: Optional[Union[List[str], str]] = None, ): """ Args: @@ -62,8 +66,10 @@

    Module slack_bolt.authorization.authorize_result< bot_user_id: Bot user's User ID starting with either `U` or `W` bot_id: Bot ID starting with `B` bot_token: Bot user access token starting with `xoxb-` + bot_scopes: The scopes associated with the bot token user_id: The request user ID user_token: User access token starting with `xoxp-` + user_scopes: The scopes associated wth the user token """ self["enterprise_id"] = self.enterprise_id = enterprise_id self["team_id"] = self.team_id = team_id @@ -71,9 +77,15 @@

    Module slack_bolt.authorization.authorize_result< self["bot_user_id"] = self.bot_user_id = bot_user_id self["bot_id"] = self.bot_id = bot_id self["bot_token"] = self.bot_token = bot_token + if bot_scopes is not None and isinstance(bot_scopes, str): + bot_scopes = [scope.strip() for scope in bot_scopes.split(",")] + self["bot_scopes"] = self.bot_scopes = bot_scopes # type: ignore # user self["user_id"] = self.user_id = user_id self["user_token"] = self.user_token = user_token + if user_scopes is not None and isinstance(user_scopes, str): + user_scopes = [scope.strip() for scope in user_scopes.split(",")] + self["user_scopes"] = self.user_scopes = user_scopes # type: ignore @classmethod def from_auth_test_response( @@ -81,7 +93,10 @@

    Module slack_bolt.authorization.authorize_result< *, bot_token: Optional[str] = None, user_token: Optional[str] = None, + bot_scopes: Optional[Union[List[str], str]] = None, + user_scopes: Optional[Union[List[str], str]] = None, auth_test_response: SlackResponse, + user_auth_test_response: Optional[SlackResponse] = None, ) -> "AuthorizeResult": bot_user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is not None else None @@ -89,14 +104,20 @@

    Module slack_bolt.authorization.authorize_result< user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None ) + # Since v1.28, user_id can be set when user_token w/ its auth.test response exists + if user_id is None and user_auth_test_response is not None: + user_id: Optional[str] = user_auth_test_response.get("user_id") # type:ignore + return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), bot_id=auth_test_response.get("bot_id"), bot_user_id=bot_user_id, + bot_scopes=bot_scopes, user_id=user_id, bot_token=bot_token, user_token=user_token, + user_scopes=user_scopes, )

    @@ -111,7 +132,7 @@

    Classes

    class AuthorizeResult -(*, enterprise_id: Optional[str], team_id: Optional[str], bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, user_id: Optional[str] = None, user_token: Optional[str] = None) +(*, enterprise_id: Optional[str], team_id: Optional[str], bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, bot_scopes: Union[List[str], str, ForwardRef(None)] = None, user_id: Optional[str] = None, user_token: Optional[str] = None, user_scopes: Union[List[str], str, ForwardRef(None)] = None)

    Authorize function call result

    @@ -127,10 +148,14 @@

    Args

    Bot ID starting with B
    bot_token
    Bot user access token starting with xoxb-
    +
    bot_scopes
    +
    The scopes associated with the bot token
    user_id
    The request user ID
    user_token
    User access token starting with xoxp-
    +
    user_scopes
    +
    The scopes associated wth the user token
    @@ -144,8 +169,10 @@

    Args

    bot_id: Optional[str] bot_user_id: Optional[str] bot_token: Optional[str] + bot_scopes: Optional[List[str]] # since v1.17 user_id: Optional[str] user_token: Optional[str] + user_scopes: Optional[List[str]] # since v1.17 def __init__( self, @@ -156,9 +183,11 @@

    Args

    bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, + bot_scopes: Optional[Union[List[str], str]] = None, # user user_id: Optional[str] = None, user_token: Optional[str] = None, + user_scopes: Optional[Union[List[str], str]] = None, ): """ Args: @@ -167,8 +196,10 @@

    Args

    bot_user_id: Bot user's User ID starting with either `U` or `W` bot_id: Bot ID starting with `B` bot_token: Bot user access token starting with `xoxb-` + bot_scopes: The scopes associated with the bot token user_id: The request user ID user_token: User access token starting with `xoxp-` + user_scopes: The scopes associated wth the user token """ self["enterprise_id"] = self.enterprise_id = enterprise_id self["team_id"] = self.team_id = team_id @@ -176,9 +207,15 @@

    Args

    self["bot_user_id"] = self.bot_user_id = bot_user_id self["bot_id"] = self.bot_id = bot_id self["bot_token"] = self.bot_token = bot_token + if bot_scopes is not None and isinstance(bot_scopes, str): + bot_scopes = [scope.strip() for scope in bot_scopes.split(",")] + self["bot_scopes"] = self.bot_scopes = bot_scopes # type: ignore # user self["user_id"] = self.user_id = user_id self["user_token"] = self.user_token = user_token + if user_scopes is not None and isinstance(user_scopes, str): + user_scopes = [scope.strip() for scope in user_scopes.split(",")] + self["user_scopes"] = self.user_scopes = user_scopes # type: ignore @classmethod def from_auth_test_response( @@ -186,7 +223,10 @@

    Args

    *, bot_token: Optional[str] = None, user_token: Optional[str] = None, + bot_scopes: Optional[Union[List[str], str]] = None, + user_scopes: Optional[Union[List[str], str]] = None, auth_test_response: SlackResponse, + user_auth_test_response: Optional[SlackResponse] = None, ) -> "AuthorizeResult": bot_user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is not None else None @@ -194,14 +234,20 @@

    Args

    user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None ) + # Since v1.28, user_id can be set when user_token w/ its auth.test response exists + if user_id is None and user_auth_test_response is not None: + user_id: Optional[str] = user_auth_test_response.get("user_id") # type:ignore + return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), bot_id=auth_test_response.get("bot_id"), bot_user_id=bot_user_id, + bot_scopes=bot_scopes, user_id=user_id, bot_token=bot_token, user_token=user_token, + user_scopes=user_scopes, )

    Ancestors

    @@ -214,6 +260,10 @@

    Class variables

    +
    var bot_scopes : Optional[List[str]]
    +
    +
    +
    var bot_token : Optional[str]
    @@ -234,6 +284,10 @@

    Class variables

    +
    var user_scopes : Optional[List[str]]
    +
    +
    +
    var user_token : Optional[str]
    @@ -242,7 +296,7 @@

    Class variables

    Static methods

    -def from_auth_test_response(*, bot_token: Optional[str] = None, user_token: Optional[str] = None, auth_test_response: slack_sdk.web.slack_response.SlackResponse) ‑> AuthorizeResult +def from_auth_test_response(*, bot_token: Optional[str] = None, user_token: Optional[str] = None, bot_scopes: Union[List[str], str, ForwardRef(None)] = None, user_scopes: Union[List[str], str, ForwardRef(None)] = None, auth_test_response: slack_sdk.web.slack_response.SlackResponse, user_auth_test_response: Optional[slack_sdk.web.slack_response.SlackResponse] = None) ‑> AuthorizeResult
    @@ -256,7 +310,10 @@

    Static methods

    *, bot_token: Optional[str] = None, user_token: Optional[str] = None, + bot_scopes: Optional[Union[List[str], str]] = None, + user_scopes: Optional[Union[List[str], str]] = None, auth_test_response: SlackResponse, + user_auth_test_response: Optional[SlackResponse] = None, ) -> "AuthorizeResult": bot_user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is not None else None @@ -264,14 +321,20 @@

    Static methods

    user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None ) + # Since v1.28, user_id can be set when user_token w/ its auth.test response exists + if user_id is None and user_auth_test_response is not None: + user_id: Optional[str] = user_auth_test_response.get("user_id") # type:ignore + return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), bot_id=auth_test_response.get("bot_id"), bot_user_id=bot_user_id, + bot_scopes=bot_scopes, user_id=user_id, bot_token=bot_token, user_token=user_token, + user_scopes=user_scopes, )
    @@ -297,12 +360,14 @@

    Index

    AuthorizeResult

    diff --git a/docs/api-docs/slack_bolt/authorization/index.html b/docs/api-docs/slack_bolt/authorization/index.html index 4eb6a59bc..86a00bfda 100644 --- a/docs/api-docs/slack_bolt/authorization/index.html +++ b/docs/api-docs/slack_bolt/authorization/index.html @@ -76,7 +76,7 @@

    Classes

    class AuthorizeResult -(*, enterprise_id: Optional[str], team_id: Optional[str], bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, user_id: Optional[str] = None, user_token: Optional[str] = None) +(*, enterprise_id: Optional[str], team_id: Optional[str], bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, bot_scopes: Union[List[str], str, ForwardRef(None)] = None, user_id: Optional[str] = None, user_token: Optional[str] = None, user_scopes: Union[List[str], str, ForwardRef(None)] = None)

    Authorize function call result

    @@ -92,10 +92,14 @@

    Args

    Bot ID starting with B
    bot_token
    Bot user access token starting with xoxb-
    +
    bot_scopes
    +
    The scopes associated with the bot token
    user_id
    The request user ID
    user_token
    User access token starting with xoxp-
    +
    user_scopes
    +
    The scopes associated wth the user token
    @@ -109,8 +113,10 @@

    Args

    bot_id: Optional[str] bot_user_id: Optional[str] bot_token: Optional[str] + bot_scopes: Optional[List[str]] # since v1.17 user_id: Optional[str] user_token: Optional[str] + user_scopes: Optional[List[str]] # since v1.17 def __init__( self, @@ -121,9 +127,11 @@

    Args

    bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, + bot_scopes: Optional[Union[List[str], str]] = None, # user user_id: Optional[str] = None, user_token: Optional[str] = None, + user_scopes: Optional[Union[List[str], str]] = None, ): """ Args: @@ -132,8 +140,10 @@

    Args

    bot_user_id: Bot user's User ID starting with either `U` or `W` bot_id: Bot ID starting with `B` bot_token: Bot user access token starting with `xoxb-` + bot_scopes: The scopes associated with the bot token user_id: The request user ID user_token: User access token starting with `xoxp-` + user_scopes: The scopes associated wth the user token """ self["enterprise_id"] = self.enterprise_id = enterprise_id self["team_id"] = self.team_id = team_id @@ -141,9 +151,15 @@

    Args

    self["bot_user_id"] = self.bot_user_id = bot_user_id self["bot_id"] = self.bot_id = bot_id self["bot_token"] = self.bot_token = bot_token + if bot_scopes is not None and isinstance(bot_scopes, str): + bot_scopes = [scope.strip() for scope in bot_scopes.split(",")] + self["bot_scopes"] = self.bot_scopes = bot_scopes # type: ignore # user self["user_id"] = self.user_id = user_id self["user_token"] = self.user_token = user_token + if user_scopes is not None and isinstance(user_scopes, str): + user_scopes = [scope.strip() for scope in user_scopes.split(",")] + self["user_scopes"] = self.user_scopes = user_scopes # type: ignore @classmethod def from_auth_test_response( @@ -151,7 +167,10 @@

    Args

    *, bot_token: Optional[str] = None, user_token: Optional[str] = None, + bot_scopes: Optional[Union[List[str], str]] = None, + user_scopes: Optional[Union[List[str], str]] = None, auth_test_response: SlackResponse, + user_auth_test_response: Optional[SlackResponse] = None, ) -> "AuthorizeResult": bot_user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is not None else None @@ -159,14 +178,20 @@

    Args

    user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None ) + # Since v1.28, user_id can be set when user_token w/ its auth.test response exists + if user_id is None and user_auth_test_response is not None: + user_id: Optional[str] = user_auth_test_response.get("user_id") # type:ignore + return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), bot_id=auth_test_response.get("bot_id"), bot_user_id=bot_user_id, + bot_scopes=bot_scopes, user_id=user_id, bot_token=bot_token, user_token=user_token, + user_scopes=user_scopes, )

    Ancestors

    @@ -179,6 +204,10 @@

    Class variables

    +
    var bot_scopes : Optional[List[str]]
    +
    +
    +
    var bot_token : Optional[str]
    @@ -199,6 +228,10 @@

    Class variables

    +
    var user_scopes : Optional[List[str]]
    +
    +
    +
    var user_token : Optional[str]
    @@ -207,7 +240,7 @@

    Class variables

    Static methods

    -def from_auth_test_response(*, bot_token: Optional[str] = None, user_token: Optional[str] = None, auth_test_response: slack_sdk.web.slack_response.SlackResponse) ‑> AuthorizeResult +def from_auth_test_response(*, bot_token: Optional[str] = None, user_token: Optional[str] = None, bot_scopes: Union[List[str], str, ForwardRef(None)] = None, user_scopes: Union[List[str], str, ForwardRef(None)] = None, auth_test_response: slack_sdk.web.slack_response.SlackResponse, user_auth_test_response: Optional[slack_sdk.web.slack_response.SlackResponse] = None) ‑> AuthorizeResult
    @@ -221,7 +254,10 @@

    Static methods

    *, bot_token: Optional[str] = None, user_token: Optional[str] = None, + bot_scopes: Optional[Union[List[str], str]] = None, + user_scopes: Optional[Union[List[str], str]] = None, auth_test_response: SlackResponse, + user_auth_test_response: Optional[SlackResponse] = None, ) -> "AuthorizeResult": bot_user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is not None else None @@ -229,14 +265,20 @@

    Static methods

    user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None ) + # Since v1.28, user_id can be set when user_token w/ its auth.test response exists + if user_id is None and user_auth_test_response is not None: + user_id: Optional[str] = user_auth_test_response.get("user_id") # type:ignore + return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), bot_id=auth_test_response.get("bot_id"), bot_user_id=bot_user_id, + bot_scopes=bot_scopes, user_id=user_id, bot_token=bot_token, user_token=user_token, + user_scopes=user_scopes, )
    @@ -271,12 +313,14 @@

    Index

    AuthorizeResult

    diff --git a/docs/api-docs/slack_bolt/context/async_context.html b/docs/api-docs/slack_bolt/context/async_context.html index d38ac04f1..0a74c4ff1 100644 --- a/docs/api-docs/slack_bolt/context/async_context.html +++ b/docs/api-docs/slack_bolt/context/async_context.html @@ -507,6 +507,9 @@

    Inherited members

    • BaseContext:
        +
      • actor_enterprise_id
      • +
      • actor_team_id
      • +
      • actor_user_id
      • authorize_result
      • bot_id
      • bot_token
      • diff --git a/docs/api-docs/slack_bolt/context/base_context.html b/docs/api-docs/slack_bolt/context/base_context.html index fcf7bbb85..833a9e564 100644 --- a/docs/api-docs/slack_bolt/context/base_context.html +++ b/docs/api-docs/slack_bolt/context/base_context.html @@ -45,6 +45,9 @@

        Module slack_bolt.context.base_context

        "is_enterprise_install", "team_id", "user_id", + "actor_enterprise_id", + "actor_team_id", + "actor_user_id", "channel_id", "response_url", "matches", @@ -89,6 +92,30 @@

        Module slack_bolt.context.base_context

        """The user ID associated ith this request.""" return self.get("user_id") + @property + def actor_enterprise_id(self) -> Optional[str]: + """The action's actor's Enterprise Grid organization ID. + Note that this property is especially useful for handling events in Slack Connect channels. + That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency. + """ + return self.get("actor_enterprise_id") + + @property + def actor_team_id(self) -> Optional[str]: + """The action's actor's workspace ID. + Note that this property is especially useful for handling events in Slack Connect channels. + That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency. + """ + return self.get("actor_team_id") + + @property + def actor_user_id(self) -> Optional[str]: + """The action's actor's user ID. + Note that this property is especially useful for handling events in Slack Connect channels. + That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency. + """ + return self.get("actor_user_id") + @property def channel_id(self) -> Optional[str]: """The conversation ID associated with this request.""" @@ -174,6 +201,9 @@

        Classes

        "is_enterprise_install", "team_id", "user_id", + "actor_enterprise_id", + "actor_team_id", + "actor_user_id", "channel_id", "response_url", "matches", @@ -218,6 +248,30 @@

        Classes

        """The user ID associated ith this request.""" return self.get("user_id") + @property + def actor_enterprise_id(self) -> Optional[str]: + """The action's actor's Enterprise Grid organization ID. + Note that this property is especially useful for handling events in Slack Connect channels. + That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency. + """ + return self.get("actor_enterprise_id") + + @property + def actor_team_id(self) -> Optional[str]: + """The action's actor's workspace ID. + Note that this property is especially useful for handling events in Slack Connect channels. + That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency. + """ + return self.get("actor_team_id") + + @property + def actor_user_id(self) -> Optional[str]: + """The action's actor's user ID. + Note that this property is especially useful for handling events in Slack Connect channels. + That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency. + """ + return self.get("actor_user_id") + @property def channel_id(self) -> Optional[str]: """The conversation ID associated with this request.""" @@ -291,6 +345,60 @@

        Class variables

    Instance variables

    +
    var actor_enterprise_id : Optional[str]
    +
    +

    The action's actor's Enterprise Grid organization ID. +Note that this property is especially useful for handling events in Slack Connect channels. +That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.

    +
    + +Expand source code + +
    @property
    +def actor_enterprise_id(self) -> Optional[str]:
    +    """The action's actor's Enterprise Grid organization ID.
    +    Note that this property is especially useful for handling events in Slack Connect channels.
    +    That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.
    +    """
    +    return self.get("actor_enterprise_id")
    +
    +
    +
    var actor_team_id : Optional[str]
    +
    +

    The action's actor's workspace ID. +Note that this property is especially useful for handling events in Slack Connect channels. +That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.

    +
    + +Expand source code + +
    @property
    +def actor_team_id(self) -> Optional[str]:
    +    """The action's actor's workspace ID.
    +    Note that this property is especially useful for handling events in Slack Connect channels.
    +    That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.
    +    """
    +    return self.get("actor_team_id")
    +
    +
    +
    var actor_user_id : Optional[str]
    +
    +

    The action's actor's user ID. +Note that this property is especially useful for handling events in Slack Connect channels. +That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.

    +
    + +Expand source code + +
    @property
    +def actor_user_id(self) -> Optional[str]:
    +    """The action's actor's user ID.
    +    Note that this property is especially useful for handling events in Slack Connect channels.
    +    That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.
    +    """
    +    return self.get("actor_user_id")
    +
    +
    var authorize_result : Optional[AuthorizeResult]

    The authorize result resolved for this request.

    @@ -520,6 +628,9 @@

    Index

  • BaseContext

  • class App -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, before_authorize: Union[Middleware, Callable[..., Any], ForwardRef(None)] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None)

    Bolt App that provides functionalities to register middleware/listeners.

    @@ -256,6 +256,8 @@

    Args

    Verifies the validity of the given token if True.
    client
    The singleton slack_sdk.WebClient instance for this app.
    +
    before_authorize
    +
    A global middleware that can be executed right before authorize function
    authorize
    The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data.
    @@ -314,6 +316,7 @@

    Args

    token_verification_enabled: bool = True, client: Optional[WebClient] = None, # for multi-workspace apps + before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[InstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility @@ -368,6 +371,7 @@

    Args

    token: The bot/user access token required only for single-workspace app. token_verification_enabled: Verifies the validity of the given token if True. client: The singleton `slack_sdk.WebClient` instance for this app. + before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. installation_store: The module offering save/find operations of installation data @@ -428,6 +432,17 @@

    Args

    # Authorize & OAuthFlow initialization # -------------------------------------- + self._before_authorize: Optional[Middleware] = None + if before_authorize is not None: + if isinstance(before_authorize, Callable): + self._before_authorize = CustomMiddleware( + app_name=self._name, + func=before_authorize, + base_logger=self._framework_logger, + ) + elif isinstance(before_authorize, Middleware): + self._before_authorize = before_authorize + self._authorize: Optional[Authorize] = None if authorize is not None: if isinstance(authorize, Authorize): @@ -451,6 +466,7 @@

    Args

    logger=self._framework_logger, bot_only=installation_store_bot_only, client=self._client, # for proxy use cases etc. + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), ) self._oauth_flow: Optional[OAuthFlow] = None @@ -569,6 +585,9 @@

    Args

    if request_verification_enabled is True: self._middleware_list.append(RequestVerification(self._signing_secret, base_logger=self._base_logger)) + if self._before_authorize is not None: + self._middleware_list.append(self._before_authorize) + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._oauth_flow is None: if self._token is not None: @@ -592,7 +611,13 @@

    Args

    else: raise BoltError(error_token_required()) else: - self._middleware_list.append(MultiTeamsAuthorization(authorize=self._authorize, base_logger=self._base_logger)) + self._middleware_list.append( + MultiTeamsAuthorization( + authorize=self._authorize, + base_logger=self._base_logger, + user_token_resolution=self._oauth_flow.settings.user_token_resolution, + ) + ) if ignoring_self_events_enabled is True: self._middleware_list.append(IgnoringSelfEvents(base_logger=self._base_logger)) if url_verification_enabled is True: @@ -3483,6 +3508,9 @@

    Inherited members

    • BaseContext:
        +
      • actor_enterprise_id
      • +
      • actor_team_id
      • +
      • actor_user_id
      • authorize_result
      • bot_id
      • bot_token
      • diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_internals.html b/docs/api-docs/slack_bolt/middleware/authorization/async_internals.html index 1fd04b969..f7a7a6da5 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/async_internals.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_internals.html @@ -26,7 +26,8 @@

        Module slack_bolt.middleware.authorization.async_interna Expand source code -
        from slack_bolt.request.async_request import AsyncBoltRequest
        +
        from slack_bolt.middleware.authorization.internals import _build_error_text
        +from slack_bolt.request.async_request import AsyncBoltRequest
         from slack_bolt.response import BoltResponse
         
         
        @@ -46,7 +47,7 @@ 

        Module slack_bolt.middleware.authorization.async_interna # show an ephemeral message to the end-user return BoltResponse( status=200, - body=":x: Please install this app into the workspace :bow:", + body=_build_error_text(), )

        diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html index 5340725ad..5ffd5d51a 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html @@ -35,23 +35,31 @@

        Module slack_bolt.middleware.authorization.async_multi_t from slack_bolt.response import BoltResponse from .async_authorization import AsyncAuthorization from .async_internals import _build_error_response, _is_no_auth_required -from .internals import _is_no_auth_test_call_required +from .internals import _is_no_auth_test_call_required, _build_error_text from ...authorization import AuthorizeResult from ...authorization.async_authorize import AsyncAuthorize class AsyncMultiTeamsAuthorization(AsyncAuthorization): authorize: AsyncAuthorize + user_token_resolution: str - def __init__(self, authorize: AsyncAuthorize, base_logger: Optional[Logger] = None): + def __init__( + self, + authorize: AsyncAuthorize, + base_logger: Optional[Logger] = None, + user_token_resolution: str = "authed_user", + ): """Multi-workspace authorization. Args: authorize: The function to authorize incoming requests from Slack. base_logger: The base logger + user_token_resolution: "authed_user" or "actor" """ self.authorize = authorize self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization, base_logger=base_logger) + self.user_token_resolution = user_token_resolution async def async_process( self, @@ -77,12 +85,24 @@

        Module slack_bolt.middleware.authorization.async_multi_t return await next() try: - auth_result: Optional[AuthorizeResult] = await self.authorize( - context=req.context, - enterprise_id=req.context.enterprise_id, - team_id=req.context.team_id, - user_id=req.context.user_id, - ) + auth_result: Optional[AuthorizeResult] = None + if self.user_token_resolution == "actor": + auth_result = await self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + actor_enterprise_id=req.context.actor_enterprise_id, + actor_team_id=req.context.actor_team_id, + actor_user_id=req.context.actor_user_id, + ) + else: + auth_result = await self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) if auth_result: req.context.set_authorize_result(auth_result) token = auth_result.bot_token or auth_result.user_token @@ -99,6 +119,9 @@

        Module slack_bolt.middleware.authorization.async_multi_t "Although the app should be installed into this workspace, " "the AuthorizeResult (returned value from authorize) for it was not found." ) + if req.context.response_url is not None: + await req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: @@ -117,7 +140,7 @@

        Classes

        class AsyncMultiTeamsAuthorization -(authorize: AsyncAuthorize, base_logger: Optional[logging.Logger] = None) +(authorize: AsyncAuthorize, base_logger: Optional[logging.Logger] = None, user_token_resolution: str = 'authed_user')

        A middleware can process request data before other middleware and listener functions.

        @@ -128,6 +151,8 @@

        Args

        The function to authorize incoming requests from Slack.
        base_logger
        The base logger
        +
        user_token_resolution
        +
        "authed_user" or "actor"
        @@ -135,16 +160,24 @@

        Args

        class AsyncMultiTeamsAuthorization(AsyncAuthorization):
             authorize: AsyncAuthorize
        +    user_token_resolution: str
         
        -    def __init__(self, authorize: AsyncAuthorize, base_logger: Optional[Logger] = None):
        +    def __init__(
        +        self,
        +        authorize: AsyncAuthorize,
        +        base_logger: Optional[Logger] = None,
        +        user_token_resolution: str = "authed_user",
        +    ):
                 """Multi-workspace authorization.
         
                 Args:
                     authorize: The function to authorize incoming requests from Slack.
                     base_logger: The base logger
        +            user_token_resolution: "authed_user" or "actor"
                 """
                 self.authorize = authorize
                 self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization, base_logger=base_logger)
        +        self.user_token_resolution = user_token_resolution
         
             async def async_process(
                 self,
        @@ -170,12 +203,24 @@ 

        Args

        return await next() try: - auth_result: Optional[AuthorizeResult] = await self.authorize( - context=req.context, - enterprise_id=req.context.enterprise_id, - team_id=req.context.team_id, - user_id=req.context.user_id, - ) + auth_result: Optional[AuthorizeResult] = None + if self.user_token_resolution == "actor": + auth_result = await self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + actor_enterprise_id=req.context.actor_enterprise_id, + actor_team_id=req.context.actor_team_id, + actor_user_id=req.context.actor_user_id, + ) + else: + auth_result = await self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) if auth_result: req.context.set_authorize_result(auth_result) token = auth_result.bot_token or auth_result.user_token @@ -192,6 +237,9 @@

        Args

        "Although the app should be installed into this workspace, " "the AuthorizeResult (returned value from authorize) for it was not found." ) + if req.context.response_url is not None: + await req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: @@ -210,6 +258,10 @@

        Class variables

        +
        var user_token_resolution : str
        +
        +
        +

    Inherited members

    diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html index 487c348d0..8f3bcff67 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html @@ -36,7 +36,7 @@

    Module slack_bolt.middleware.authorization.async_single_ from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.errors import SlackApiError from .async_internals import _build_error_response, _is_no_auth_required -from .internals import _to_authorize_result, _is_no_auth_test_call_required +from .internals import _to_authorize_result, _is_no_auth_test_call_required, _build_error_text from ...authorization import AuthorizeResult @@ -85,6 +85,9 @@

    Module slack_bolt.middleware.authorization.async_single_ else: # Just in case self.logger.error("auth.test API call result is unexpectedly None") + if req.context.response_url is not None: + await req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") @@ -156,6 +159,9 @@

    Classes

    else: # Just in case self.logger.error("auth.test API call result is unexpectedly None") + if req.context.response_url is not None: + await req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") diff --git a/docs/api-docs/slack_bolt/middleware/authorization/index.html b/docs/api-docs/slack_bolt/middleware/authorization/index.html index 5504cfe7d..3a1f6e064 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/index.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/index.html @@ -115,7 +115,7 @@

    Inherited members

    class MultiTeamsAuthorization -(*, authorize: Authorize, base_logger: Optional[logging.Logger] = None) +(*, authorize: Authorize, base_logger: Optional[logging.Logger] = None, user_token_resolution: str = 'authed_user')

    A middleware can process request data before other middleware and listener functions.

    @@ -126,6 +126,8 @@

    Args

    The function to authorize incoming requests from Slack.
    base_logger
    The base logger
    +
    user_token_resolution
    +
    "authed_user" or "actor"
    @@ -133,21 +135,25 @@

    Args

    class MultiTeamsAuthorization(Authorization):
         authorize: Authorize
    +    user_token_resolution: str
     
         def __init__(
             self,
             *,
             authorize: Authorize,
             base_logger: Optional[Logger] = None,
    +        user_token_resolution: str = "authed_user",
         ):
             """Multi-workspace authorization.
     
             Args:
                 authorize: The function to authorize incoming requests from Slack.
                 base_logger: The base logger
    +            user_token_resolution: "authed_user" or "actor"
             """
             self.authorize = authorize
             self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger)
    +        self.user_token_resolution = user_token_resolution
     
         def process(
             self,
    @@ -170,12 +176,24 @@ 

    Args

    return next() try: - auth_result: Optional[AuthorizeResult] = self.authorize( - context=req.context, - enterprise_id=req.context.enterprise_id, - team_id=req.context.team_id, - user_id=req.context.user_id, - ) + auth_result: Optional[AuthorizeResult] = None + if self.user_token_resolution == "actor": + auth_result = self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + actor_enterprise_id=req.context.actor_enterprise_id, + actor_team_id=req.context.actor_team_id, + actor_user_id=req.context.actor_user_id, + ) + else: + auth_result = self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) if auth_result is not None: req.context.set_authorize_result(auth_result) token = auth_result.bot_token or auth_result.user_token @@ -192,6 +210,9 @@

    Args

    "Although the app should be installed into this workspace, " "the AuthorizeResult (returned value from authorize) for it was not found." ) + if req.context.response_url is not None: + req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: @@ -209,6 +230,10 @@

    Class variables

    +
    var user_token_resolution : str
    +
    +
    +

    Inherited members

      @@ -293,6 +318,9 @@

      Args

      else: # Just in case self.logger.error("auth.test API call result is unexpectedly None") + if req.context.response_url is not None: + req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") @@ -348,6 +376,7 @@

      MultiTeamsAuthorization

    • diff --git a/docs/api-docs/slack_bolt/middleware/authorization/internals.html b/docs/api-docs/slack_bolt/middleware/authorization/internals.html index 75aff361a..4cee299f3 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/internals.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/internals.html @@ -71,11 +71,18 @@

      Module slack_bolt.middleware.authorization.internalsModule slack_bolt.middleware.authorization.multi_teams_a _build_error_response, _is_no_auth_required, _is_no_auth_test_call_required, + _build_error_text, ) from ...authorization import AuthorizeResult from ...authorization.authorize import Authorize @@ -46,21 +47,25 @@

      Module slack_bolt.middleware.authorization.multi_teams_a class MultiTeamsAuthorization(Authorization): authorize: Authorize + user_token_resolution: str def __init__( self, *, authorize: Authorize, base_logger: Optional[Logger] = None, + user_token_resolution: str = "authed_user", ): """Multi-workspace authorization. Args: authorize: The function to authorize incoming requests from Slack. base_logger: The base logger + user_token_resolution: "authed_user" or "actor" """ self.authorize = authorize self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger) + self.user_token_resolution = user_token_resolution def process( self, @@ -83,12 +88,24 @@

      Module slack_bolt.middleware.authorization.multi_teams_a return next() try: - auth_result: Optional[AuthorizeResult] = self.authorize( - context=req.context, - enterprise_id=req.context.enterprise_id, - team_id=req.context.team_id, - user_id=req.context.user_id, - ) + auth_result: Optional[AuthorizeResult] = None + if self.user_token_resolution == "actor": + auth_result = self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + actor_enterprise_id=req.context.actor_enterprise_id, + actor_team_id=req.context.actor_team_id, + actor_user_id=req.context.actor_user_id, + ) + else: + auth_result = self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) if auth_result is not None: req.context.set_authorize_result(auth_result) token = auth_result.bot_token or auth_result.user_token @@ -105,6 +122,9 @@

      Module slack_bolt.middleware.authorization.multi_teams_a "Although the app should be installed into this workspace, " "the AuthorizeResult (returned value from authorize) for it was not found." ) + if req.context.response_url is not None: + req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: @@ -123,7 +143,7 @@

      Classes

      class MultiTeamsAuthorization -(*, authorize: Authorize, base_logger: Optional[logging.Logger] = None) +(*, authorize: Authorize, base_logger: Optional[logging.Logger] = None, user_token_resolution: str = 'authed_user')

      A middleware can process request data before other middleware and listener functions.

      @@ -134,6 +154,8 @@

      Args

      The function to authorize incoming requests from Slack.
      base_logger
      The base logger
      +
      user_token_resolution
      +
      "authed_user" or "actor"
      @@ -141,21 +163,25 @@

      Args

      class MultiTeamsAuthorization(Authorization):
           authorize: Authorize
      +    user_token_resolution: str
       
           def __init__(
               self,
               *,
               authorize: Authorize,
               base_logger: Optional[Logger] = None,
      +        user_token_resolution: str = "authed_user",
           ):
               """Multi-workspace authorization.
       
               Args:
                   authorize: The function to authorize incoming requests from Slack.
                   base_logger: The base logger
      +            user_token_resolution: "authed_user" or "actor"
               """
               self.authorize = authorize
               self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger)
      +        self.user_token_resolution = user_token_resolution
       
           def process(
               self,
      @@ -178,12 +204,24 @@ 

      Args

      return next() try: - auth_result: Optional[AuthorizeResult] = self.authorize( - context=req.context, - enterprise_id=req.context.enterprise_id, - team_id=req.context.team_id, - user_id=req.context.user_id, - ) + auth_result: Optional[AuthorizeResult] = None + if self.user_token_resolution == "actor": + auth_result = self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + actor_enterprise_id=req.context.actor_enterprise_id, + actor_team_id=req.context.actor_team_id, + actor_user_id=req.context.actor_user_id, + ) + else: + auth_result = self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) if auth_result is not None: req.context.set_authorize_result(auth_result) token = auth_result.bot_token or auth_result.user_token @@ -200,6 +238,9 @@

      Args

      "Although the app should be installed into this workspace, " "the AuthorizeResult (returned value from authorize) for it was not found." ) + if req.context.response_url is not None: + req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: @@ -217,6 +258,10 @@

      Class variables

      +
      var user_token_resolution : str
      +
      +
      +

      Inherited members

      diff --git a/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html index 3d48d602e..9d023b2d6 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html @@ -40,6 +40,7 @@

      Module slack_bolt.middleware.authorization.single_team_a _is_no_auth_required, _to_authorize_result, _is_no_auth_test_call_required, + _build_error_text, ) from ...authorization import AuthorizeResult @@ -99,6 +100,9 @@

      Module slack_bolt.middleware.authorization.single_team_a else: # Just in case self.logger.error("auth.test API call result is unexpectedly None") + if req.context.response_url is not None: + req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") @@ -187,6 +191,9 @@

      Args

      else: # Just in case self.logger.error("auth.test API call result is unexpectedly None") + if req.context.response_url is not None: + req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") diff --git a/docs/api-docs/slack_bolt/middleware/index.html b/docs/api-docs/slack_bolt/middleware/index.html index dfd0ba795..ed61cf1ba 100644 --- a/docs/api-docs/slack_bolt/middleware/index.html +++ b/docs/api-docs/slack_bolt/middleware/index.html @@ -463,7 +463,7 @@

      Returns

    class MultiTeamsAuthorization -(*, authorize: Authorize, base_logger: Optional[logging.Logger] = None) +(*, authorize: Authorize, base_logger: Optional[logging.Logger] = None, user_token_resolution: str = 'authed_user')

    A middleware can process request data before other middleware and listener functions.

    @@ -474,6 +474,8 @@

    Args

    The function to authorize incoming requests from Slack.
    base_logger
    The base logger
    +
    user_token_resolution
    +
    "authed_user" or "actor"
    @@ -481,21 +483,25 @@

    Args

    class MultiTeamsAuthorization(Authorization):
         authorize: Authorize
    +    user_token_resolution: str
     
         def __init__(
             self,
             *,
             authorize: Authorize,
             base_logger: Optional[Logger] = None,
    +        user_token_resolution: str = "authed_user",
         ):
             """Multi-workspace authorization.
     
             Args:
                 authorize: The function to authorize incoming requests from Slack.
                 base_logger: The base logger
    +            user_token_resolution: "authed_user" or "actor"
             """
             self.authorize = authorize
             self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger)
    +        self.user_token_resolution = user_token_resolution
     
         def process(
             self,
    @@ -518,12 +524,24 @@ 

    Args

    return next() try: - auth_result: Optional[AuthorizeResult] = self.authorize( - context=req.context, - enterprise_id=req.context.enterprise_id, - team_id=req.context.team_id, - user_id=req.context.user_id, - ) + auth_result: Optional[AuthorizeResult] = None + if self.user_token_resolution == "actor": + auth_result = self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + actor_enterprise_id=req.context.actor_enterprise_id, + actor_team_id=req.context.actor_team_id, + actor_user_id=req.context.actor_user_id, + ) + else: + auth_result = self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) if auth_result is not None: req.context.set_authorize_result(auth_result) token = auth_result.bot_token or auth_result.user_token @@ -540,6 +558,9 @@

    Args

    "Although the app should be installed into this workspace, " "the AuthorizeResult (returned value from authorize) for it was not found." ) + if req.context.response_url is not None: + req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: @@ -557,6 +578,10 @@

    Class variables

    +
    var user_token_resolution : str
    +
    +
    +

    Inherited members

      @@ -730,6 +755,9 @@

      Args

      else: # Just in case self.logger.error("auth.test API call result is unexpectedly None") + if req.context.response_url is not None: + req.context.respond(_build_error_text()) + return BoltResponse(status=200, body="") return _build_error_response() except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") @@ -983,6 +1011,7 @@

      MultiTeamsAuthorization

    • diff --git a/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html b/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html index 063f3918e..94f521fdd 100644 --- a/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html +++ b/docs/api-docs/slack_bolt/oauth/async_oauth_settings.html @@ -70,6 +70,7 @@

      Module slack_bolt.oauth.async_oauth_settings

      installation_store: AsyncInstallationStore installation_store_bot_only: bool token_rotation_expiration_minutes: int + user_token_resolution: str authorize: AsyncAuthorize # state parameter related configurations state_validation_enabled: bool @@ -104,6 +105,7 @@

      Module slack_bolt.oauth.async_oauth_settings

      installation_store: Optional[AsyncInstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, + user_token_resolution: str = "authed_user", # state parameter related configurations state_validation_enabled: bool = True, state_store: Optional[AsyncOAuthStateStore] = None, @@ -130,6 +132,11 @@

      Module slack_bolt.oauth.async_oauth_settings

      installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + user_token_resolution: The option to pick up a user token per request (Default: authed_user) + The available values are "authed_user" and "actor". When you want to resolve the user token per request + using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve + a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect + channels. Note that actor IDs can be absent in some scenarios. state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") @@ -171,6 +178,7 @@

      Module slack_bolt.oauth.async_oauth_settings

      self.authorization_url = authorization_url or "https://slack.com/oauth/v2/authorize" # Installation Management self.installation_store = installation_store or get_or_create_default_installation_store(client_id) + self.user_token_resolution = user_token_resolution or "authed_user" self.installation_store_bot_only = installation_store_bot_only self.token_rotation_expiration_minutes = token_rotation_expiration_minutes self.authorize = AsyncInstallationStoreAuthorize( @@ -180,6 +188,7 @@

      Module slack_bolt.oauth.async_oauth_settings

      token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, installation_store=self.installation_store, bot_only=self.installation_store_bot_only, + user_token_resolution=user_token_resolution, ) # state parameter related configurations self.state_validation_enabled = state_validation_enabled @@ -220,7 +229,7 @@

      Classes

      class AsyncOAuthSettings -(*, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Union[str, Sequence[str], ForwardRef(None)] = None, user_scopes: Union[str, Sequence[str], ForwardRef(None)] = None, redirect_uri: Optional[str] = None, install_path: str = '/slack/install', install_page_rendering_enabled: bool = True, redirect_uri_path: str = '/slack/oauth_redirect', callback_options: Optional[AsyncCallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, state_validation_enabled: bool = True, state_store: Optional[slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, logger: logging.Logger = <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>) +(*, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Union[str, Sequence[str], ForwardRef(None)] = None, user_scopes: Union[str, Sequence[str], ForwardRef(None)] = None, redirect_uri: Optional[str] = None, install_path: str = '/slack/install', install_page_rendering_enabled: bool = True, redirect_uri_path: str = '/slack/oauth_redirect', callback_options: Optional[AsyncCallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, user_token_resolution: str = 'authed_user', state_validation_enabled: bool = True, state_store: Optional[slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, logger: logging.Logger = <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>)

      The settings for Slack App installation (OAuth flow).

      @@ -256,6 +265,12 @@

      Args

      Use InstallationStore#find_bot() if True (Default: False)
      token_rotation_expiration_minutes
      Minutes before refreshing tokens (Default: 2 hours)
      +
      user_token_resolution
      +
      The option to pick up a user token per request (Default: authed_user) +The available values are "authed_user" and "actor". When you want to resolve the user token per request +using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve +a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect +channels. Note that actor IDs can be absent in some scenarios.
      state_validation_enabled
      Set False if your OAuth flow omits the state parameter validation (Default: True)
      state_store
      @@ -290,6 +305,7 @@

      Args

      installation_store: AsyncInstallationStore installation_store_bot_only: bool token_rotation_expiration_minutes: int + user_token_resolution: str authorize: AsyncAuthorize # state parameter related configurations state_validation_enabled: bool @@ -324,6 +340,7 @@

      Args

      installation_store: Optional[AsyncInstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, + user_token_resolution: str = "authed_user", # state parameter related configurations state_validation_enabled: bool = True, state_store: Optional[AsyncOAuthStateStore] = None, @@ -350,6 +367,11 @@

      Args

      installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + user_token_resolution: The option to pick up a user token per request (Default: authed_user) + The available values are "authed_user" and "actor". When you want to resolve the user token per request + using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve + a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect + channels. Note that actor IDs can be absent in some scenarios. state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") @@ -391,6 +413,7 @@

      Args

      self.authorization_url = authorization_url or "https://slack.com/oauth/v2/authorize" # Installation Management self.installation_store = installation_store or get_or_create_default_installation_store(client_id) + self.user_token_resolution = user_token_resolution or "authed_user" self.installation_store_bot_only = installation_store_bot_only self.token_rotation_expiration_minutes = token_rotation_expiration_minutes self.authorize = AsyncInstallationStoreAuthorize( @@ -400,6 +423,7 @@

      Args

      token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, installation_store=self.installation_store, bot_only=self.installation_store_bot_only, + user_token_resolution=user_token_resolution, ) # state parameter related configurations self.state_validation_enabled = state_validation_enabled @@ -526,6 +550,10 @@

      Class variables

      +
      var user_token_resolution : str
      +
      +
      +
      @@ -571,6 +599,7 @@

      success_url

    • token_rotation_expiration_minutes
    • user_scopes
    • +
    • user_token_resolution
    diff --git a/docs/api-docs/slack_bolt/oauth/oauth_settings.html b/docs/api-docs/slack_bolt/oauth/oauth_settings.html index 7235e1767..683cef706 100644 --- a/docs/api-docs/slack_bolt/oauth/oauth_settings.html +++ b/docs/api-docs/slack_bolt/oauth/oauth_settings.html @@ -66,6 +66,7 @@

    Module slack_bolt.oauth.oauth_settings

    installation_store_bot_only: bool token_rotation_expiration_minutes: int authorize: Authorize + user_token_resolution: str # default: "authed_user" # state parameter related configurations state_validation_enabled: bool state_store: OAuthStateStore @@ -99,6 +100,7 @@

    Module slack_bolt.oauth.oauth_settings

    installation_store: Optional[InstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, + user_token_resolution: str = "authed_user", # state parameter related configurations state_validation_enabled: bool = True, state_store: Optional[OAuthStateStore] = None, @@ -125,6 +127,11 @@

    Module slack_bolt.oauth.oauth_settings

    installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + user_token_resolution: The option to pick up a user token per request (Default: authed_user) + The available values are "authed_user" and "actor". When you want to resolve the user token per request + using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve + a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect + channels. Note that actor IDs can be absent in some scenarios. state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") @@ -164,6 +171,7 @@

    Module slack_bolt.oauth.oauth_settings

    self.authorization_url = authorization_url or "https://slack.com/oauth/v2/authorize" # Installation Management self.installation_store = installation_store or get_or_create_default_installation_store(client_id) + self.user_token_resolution = user_token_resolution or "authed_user" self.installation_store_bot_only = installation_store_bot_only self.token_rotation_expiration_minutes = token_rotation_expiration_minutes self.authorize = InstallationStoreAuthorize( @@ -173,6 +181,7 @@

    Module slack_bolt.oauth.oauth_settings

    token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, installation_store=self.installation_store, bot_only=self.installation_store_bot_only, + user_token_resolution=user_token_resolution, ) # state parameter related configurations self.state_validation_enabled = state_validation_enabled @@ -213,7 +222,7 @@

    Classes

    class OAuthSettings -(*, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Union[str, Sequence[str], ForwardRef(None)] = None, user_scopes: Union[str, Sequence[str], ForwardRef(None)] = None, redirect_uri: Optional[str] = None, install_path: str = '/slack/install', install_page_rendering_enabled: bool = True, redirect_uri_path: str = '/slack/oauth_redirect', callback_options: Optional[CallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, state_validation_enabled: bool = True, state_store: Optional[slack_sdk.oauth.state_store.state_store.OAuthStateStore] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, logger: logging.Logger = <Logger slack_bolt.oauth.oauth_settings (WARNING)>) +(*, client_id: Optional[str] = None, client_secret: Optional[str] = None, scopes: Union[str, Sequence[str], ForwardRef(None)] = None, user_scopes: Union[str, Sequence[str], ForwardRef(None)] = None, redirect_uri: Optional[str] = None, install_path: str = '/slack/install', install_page_rendering_enabled: bool = True, redirect_uri_path: str = '/slack/oauth_redirect', callback_options: Optional[CallbackOptions] = None, success_url: Optional[str] = None, failure_url: Optional[str] = None, authorization_url: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, user_token_resolution: str = 'authed_user', state_validation_enabled: bool = True, state_store: Optional[slack_sdk.oauth.state_store.state_store.OAuthStateStore] = None, state_cookie_name: str = 'slack-app-oauth-state', state_expiration_seconds: int = 600, logger: logging.Logger = <Logger slack_bolt.oauth.oauth_settings (WARNING)>)

    The settings for Slack App installation (OAuth flow).

    @@ -249,6 +258,12 @@

    Args

    Use InstallationStore#find_bot() if True (Default: False)
    token_rotation_expiration_minutes
    Minutes before refreshing tokens (Default: 2 hours)
    +
    user_token_resolution
    +
    The option to pick up a user token per request (Default: authed_user) +The available values are "authed_user" and "actor". When you want to resolve the user token per request +using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve +a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect +channels. Note that actor IDs can be absent in some scenarios.
    state_validation_enabled
    Set False if your OAuth flow omits the state parameter validation (Default: True)
    state_store
    @@ -284,6 +299,7 @@

    Args

    installation_store_bot_only: bool token_rotation_expiration_minutes: int authorize: Authorize + user_token_resolution: str # default: "authed_user" # state parameter related configurations state_validation_enabled: bool state_store: OAuthStateStore @@ -317,6 +333,7 @@

    Args

    installation_store: Optional[InstallationStore] = None, installation_store_bot_only: bool = False, token_rotation_expiration_minutes: int = 120, + user_token_resolution: str = "authed_user", # state parameter related configurations state_validation_enabled: bool = True, state_store: Optional[OAuthStateStore] = None, @@ -343,6 +360,11 @@

    Args

    installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + user_token_resolution: The option to pick up a user token per request (Default: authed_user) + The available values are "authed_user" and "actor". When you want to resolve the user token per request + using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve + a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect + channels. Note that actor IDs can be absent in some scenarios. state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") @@ -382,6 +404,7 @@

    Args

    self.authorization_url = authorization_url or "https://slack.com/oauth/v2/authorize" # Installation Management self.installation_store = installation_store or get_or_create_default_installation_store(client_id) + self.user_token_resolution = user_token_resolution or "authed_user" self.installation_store_bot_only = installation_store_bot_only self.token_rotation_expiration_minutes = token_rotation_expiration_minutes self.authorize = InstallationStoreAuthorize( @@ -391,6 +414,7 @@

    Args

    token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, installation_store=self.installation_store, bot_only=self.installation_store_bot_only, + user_token_resolution=user_token_resolution, ) # state parameter related configurations self.state_validation_enabled = state_validation_enabled @@ -517,6 +541,10 @@

    Class variables

    +
    var user_token_resolution : str
    +
    +
    +
    @@ -562,6 +590,7 @@

    success_url
  • token_rotation_expiration_minutes
  • user_scopes
  • +
  • user_token_resolution
  • diff --git a/docs/api-docs/slack_bolt/request/async_internals.html b/docs/api-docs/slack_bolt/request/async_internals.html index 0505fb696..b36d03677 100644 --- a/docs/api-docs/slack_bolt/request/async_internals.html +++ b/docs/api-docs/slack_bolt/request/async_internals.html @@ -36,6 +36,9 @@

    Module slack_bolt.request.async_internals

    extract_user_id, extract_channel_id, debug_multiple_response_urls_detected, + extract_actor_enterprise_id, + extract_actor_team_id, + extract_actor_user_id, ) @@ -53,6 +56,16 @@

    Module slack_bolt.request.async_internals

    user_id = extract_user_id(body) if user_id: context["user_id"] = user_id + # Actor IDs are useful for Events API on a Slack Connect channel + actor_enterprise_id = extract_actor_enterprise_id(body) + if actor_enterprise_id: + context["actor_enterprise_id"] = actor_enterprise_id + actor_team_id = extract_actor_team_id(body) + if actor_team_id: + context["actor_team_id"] = actor_team_id + actor_user_id = extract_actor_user_id(body) + if actor_user_id: + context["actor_user_id"] = actor_user_id channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id @@ -99,6 +112,16 @@

    Functions

    user_id = extract_user_id(body) if user_id: context["user_id"] = user_id + # Actor IDs are useful for Events API on a Slack Connect channel + actor_enterprise_id = extract_actor_enterprise_id(body) + if actor_enterprise_id: + context["actor_enterprise_id"] = actor_enterprise_id + actor_team_id = extract_actor_team_id(body) + if actor_team_id: + context["actor_team_id"] = actor_team_id + actor_user_id = extract_actor_user_id(body) + if actor_user_id: + context["actor_user_id"] = actor_user_id channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id diff --git a/docs/api-docs/slack_bolt/request/internals.html b/docs/api-docs/slack_bolt/request/internals.html index b9ab47c15..462365822 100644 --- a/docs/api-docs/slack_bolt/request/internals.html +++ b/docs/api-docs/slack_bolt/request/internals.html @@ -100,6 +100,20 @@

    Module slack_bolt.request.internals

    return None +def extract_actor_enterprise_id(payload: Dict[str, Any]) -> Optional[str]: + if payload.get("is_ext_shared_channel") is True: + if payload.get("type") == "event_callback": + # For safety, we don't set actor IDs for the events like "file_shared", + # which do not provide any team ID in $.event data. In the case, the IDs cannot be correct. + event_team_id = payload.get("event", {}).get("user_team") or payload.get("event", {}).get("team") + if event_team_id is not None and str(event_team_id).startswith("E"): + return event_team_id + if event_team_id == payload.get("team_id"): + return payload.get("enterprise_id") + return None + return extract_enterprise_id(payload) + + def extract_team_id(payload: Dict[str, Any]) -> Optional[str]: if payload.get("view", {}).get("app_installed_team_id") is not None: # view_submission payloads can have `view.app_installed_team_id` when a modal view that was opened @@ -130,6 +144,48 @@

    Module slack_bolt.request.internals

    return None +def extract_actor_team_id(payload: Dict[str, Any]) -> Optional[str]: + if payload.get("is_ext_shared_channel") is True: + if payload.get("type") == "event_callback": + event_type = payload.get("event", {}).get("type") + if event_type == "app_mention": + # The $.event.user_team can be an enterprise_id in app_mention events. + # In the scenario, there is no way to retrieve actor_team_id as of March 2023 + user_team = payload.get("event", {}).get("user_team") + if user_team is None: + # working with an app installed in this user's org/workspace side + return payload.get("event", {}).get("team") + if str(user_team).startswith("T"): + # interacting from a connected non-grid workspace + return user_team + # Interacting from a connected grid workspace; in this case, team_id cannot be resolved as of March 2023 + return None + # For safety, we don't set actor IDs for the events like "file_shared", + # which do not provide any team ID in $.event data. In the case, the IDs cannot be correct. + event_user_team = payload.get("event", {}).get("user_team") + if event_user_team is not None: + if str(event_user_team).startswith("T"): + return event_user_team + elif str(event_user_team).startswith("E"): + if event_user_team == payload.get("enterprise_id"): + return payload.get("team_id") + elif event_user_team == payload.get("context_enterprise_id"): + return payload.get("context_team_id") + + event_team = payload.get("event", {}).get("team") + if event_team is not None: + if str(event_team).startswith("T"): + return event_team + elif str(event_team).startswith("E"): + if event_team == payload.get("enterprise_id"): + return payload.get("team_id") + elif event_team == payload.get("context_enterprise_id"): + return payload.get("context_team_id") + return None + + return extract_team_id(payload) + + def extract_user_id(payload: Dict[str, Any]) -> Optional[str]: user = payload.get("user") if user is not None: @@ -150,6 +206,19 @@

    Module slack_bolt.request.internals

    return None +def extract_actor_user_id(payload: Dict[str, Any]) -> Optional[str]: + if payload.get("is_ext_shared_channel") is True: + if payload.get("type") == "event_callback": + event = payload.get("event") + if event is None: + return None + if extract_actor_enterprise_id(payload) is None and extract_actor_team_id(payload) is None: + # When both enterprise_id and team_id are not identified, we skip returning user_id too for safety + return None + return event.get("user") or event.get("user_id") + return extract_user_id(payload) + + def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: channel = payload.get("channel") if channel is not None: @@ -178,6 +247,16 @@

    Module slack_bolt.request.internals

    user_id = extract_user_id(body) if user_id: context["user_id"] = user_id + # Actor IDs are useful for Events API on a Slack Connect channel + actor_enterprise_id = extract_actor_enterprise_id(body) + if actor_enterprise_id: + context["actor_enterprise_id"] = actor_enterprise_id + actor_team_id = extract_actor_team_id(body) + if actor_team_id: + context["actor_team_id"] = actor_team_id + actor_user_id = extract_actor_user_id(body) + if actor_user_id: + context["actor_user_id"] = actor_user_id channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id @@ -254,6 +333,16 @@

    Functions

    user_id = extract_user_id(body) if user_id: context["user_id"] = user_id + # Actor IDs are useful for Events API on a Slack Connect channel + actor_enterprise_id = extract_actor_enterprise_id(body) + if actor_enterprise_id: + context["actor_enterprise_id"] = actor_enterprise_id + actor_team_id = extract_actor_team_id(body) + if actor_team_id: + context["actor_team_id"] = actor_team_id + actor_user_id = extract_actor_user_id(body) + if actor_user_id: + context["actor_user_id"] = actor_user_id channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id @@ -323,6 +412,102 @@

    Functions

    return "`body` must be a raw string data when running in the HTTP server mode"
    +
    +def extract_actor_enterprise_id(payload: Dict[str, Any]) ‑> Optional[str] +
    +
    +
    +
    + +Expand source code + +
    def extract_actor_enterprise_id(payload: Dict[str, Any]) -> Optional[str]:
    +    if payload.get("is_ext_shared_channel") is True:
    +        if payload.get("type") == "event_callback":
    +            # For safety, we don't set actor IDs for the events like "file_shared",
    +            # which do not provide any team ID in $.event data. In the case, the IDs cannot be correct.
    +            event_team_id = payload.get("event", {}).get("user_team") or payload.get("event", {}).get("team")
    +            if event_team_id is not None and str(event_team_id).startswith("E"):
    +                return event_team_id
    +            if event_team_id == payload.get("team_id"):
    +                return payload.get("enterprise_id")
    +            return None
    +    return extract_enterprise_id(payload)
    +
    +
    +
    +def extract_actor_team_id(payload: Dict[str, Any]) ‑> Optional[str] +
    +
    +
    +
    + +Expand source code + +
    def extract_actor_team_id(payload: Dict[str, Any]) -> Optional[str]:
    +    if payload.get("is_ext_shared_channel") is True:
    +        if payload.get("type") == "event_callback":
    +            event_type = payload.get("event", {}).get("type")
    +            if event_type == "app_mention":
    +                # The $.event.user_team can be an enterprise_id in app_mention events.
    +                # In the scenario, there is no way to retrieve actor_team_id as of March 2023
    +                user_team = payload.get("event", {}).get("user_team")
    +                if user_team is None:
    +                    # working with an app installed in this user's org/workspace side
    +                    return payload.get("event", {}).get("team")
    +                if str(user_team).startswith("T"):
    +                    # interacting from a connected non-grid workspace
    +                    return user_team
    +                # Interacting from a connected grid workspace; in this case, team_id cannot be resolved as of March 2023
    +                return None
    +            # For safety, we don't set actor IDs for the events like "file_shared",
    +            # which do not provide any team ID in $.event data. In the case, the IDs cannot be correct.
    +            event_user_team = payload.get("event", {}).get("user_team")
    +            if event_user_team is not None:
    +                if str(event_user_team).startswith("T"):
    +                    return event_user_team
    +                elif str(event_user_team).startswith("E"):
    +                    if event_user_team == payload.get("enterprise_id"):
    +                        return payload.get("team_id")
    +                    elif event_user_team == payload.get("context_enterprise_id"):
    +                        return payload.get("context_team_id")
    +
    +            event_team = payload.get("event", {}).get("team")
    +            if event_team is not None:
    +                if str(event_team).startswith("T"):
    +                    return event_team
    +                elif str(event_team).startswith("E"):
    +                    if event_team == payload.get("enterprise_id"):
    +                        return payload.get("team_id")
    +                    elif event_team == payload.get("context_enterprise_id"):
    +                        return payload.get("context_team_id")
    +            return None
    +
    +    return extract_team_id(payload)
    +
    +
    +
    +def extract_actor_user_id(payload: Dict[str, Any]) ‑> Optional[str] +
    +
    +
    +
    + +Expand source code + +
    def extract_actor_user_id(payload: Dict[str, Any]) -> Optional[str]:
    +    if payload.get("is_ext_shared_channel") is True:
    +        if payload.get("type") == "event_callback":
    +            event = payload.get("event")
    +            if event is None:
    +                return None
    +            if extract_actor_enterprise_id(payload) is None and extract_actor_team_id(payload) is None:
    +                # When both enterprise_id and team_id are not identified, we skip returning user_id too for safety
    +                return None
    +            return event.get("user") or event.get("user_id")
    +    return extract_user_id(payload)
    +
    +
    def extract_channel_id(payload: Dict[str, Any]) ‑> Optional[str]
    @@ -558,6 +743,9 @@

    Index

  • build_normalized_headers
  • debug_multiple_response_urls_detected
  • error_message_raw_body_required_in_http_mode
  • +
  • extract_actor_enterprise_id
  • +
  • extract_actor_team_id
  • +
  • extract_actor_user_id
  • extract_channel_id
  • extract_content_type
  • extract_enterprise_id
  • diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index e287e2ea6..b6ea4a21f 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.16.4"
    +__version__ = "1.17.0"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 7376943ae..6b81098d6 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.17.0rc4" +__version__ = "1.17.0" From eae0d4e47b4dd8234cd555c2ee0c0bb92a0ef6bd Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 5 Apr 2023 12:49:42 -0400 Subject: [PATCH 577/865] Remove legacy tag (#875) --- docs/.markdownlint.yml | 198 ++++++++++++++++++++++++++++++++++++ docs/_includes/sidebar.html | 5 +- docs/_layouts/default.html | 5 +- 3 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 docs/.markdownlint.yml diff --git a/docs/.markdownlint.yml b/docs/.markdownlint.yml new file mode 100644 index 000000000..4780b8f6b --- /dev/null +++ b/docs/.markdownlint.yml @@ -0,0 +1,198 @@ +# Default state for all rules +default: true + +# Path to configuration file to extend +extends: null + +# MD001/heading-increment/header-increment - Heading levels should only increment by one level at a time +MD001: false + +# MD002/first-heading-h1/first-header-h1 - First heading should be a top-level heading +MD002: + # Heading level + level: 1 + +# MD003/heading-style/header-style - Heading style +MD003: + # Heading style + style: "consistent" + +# MD004/ul-style - Unordered list style +MD004: + # List style + style: "consistent" + +# MD005/list-indent - Inconsistent indentation for list items at the same level +MD005: true + +# MD006/ul-start-left - Consider starting bulleted lists at the beginning of the line +MD006: true + +# MD007/ul-indent - Unordered list indentation +MD007: false + +# MD009/no-trailing-spaces - Trailing spaces +MD009: + # Spaces for line break + br_spaces: 2 + # Allow spaces for empty lines in list items + list_item_empty_lines: false + # Include unnecessary breaks + strict: false + +# MD010/no-hard-tabs - Hard tabs +MD010: + # Include code blocks + code_blocks: true + # Fenced code languages to ignore + ignore_code_languages: [] + # Number of spaces for each hard tab + spaces_per_tab: 1 + +# MD011/no-reversed-links - Reversed link syntax +MD011: true + +# MD012/no-multiple-blanks - Multiple consecutive blank lines +MD012: + # Consecutive blank lines + maximum: 1 + +# MD013/line-length - Line length +MD013: false + +# MD014/commands-show-output - Dollar signs used before commands without showing output +MD014: true + +# MD018/no-missing-space-atx - No space after hash on atx style heading +MD018: true + +# MD019/no-multiple-space-atx - Multiple spaces after hash on atx style heading +MD019: true + +# MD020/no-missing-space-closed-atx - No space inside hashes on closed atx style heading +MD020: true + +# MD021/no-multiple-space-closed-atx - Multiple spaces inside hashes on closed atx style heading +MD021: true + +# MD022/blanks-around-headings/blanks-around-headers - Headings should be surrounded by blank lines +MD022: false + +# MD023/heading-start-left/header-start-left - Headings must start at the beginning of the line +MD023: true + +# MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content +MD024: false + +# MD025/single-title/single-h1 - Multiple top-level headings in the same document +MD025: false + +# MD026/no-trailing-punctuation - Trailing punctuation in heading +MD026: + # Punctuation characters + punctuation: ".,;:!。,;:!" + +# MD027/no-multiple-space-blockquote - Multiple spaces after blockquote symbol +MD027: true + +# MD028/no-blanks-blockquote - Blank line inside blockquote +MD028: true + +# MD029/ol-prefix - Ordered list item prefix +MD029: false + +# MD030/list-marker-space - Spaces after list markers +MD030: false + +# MD031/blanks-around-fences - Fenced code blocks should be surrounded by blank lines +MD031: + # Include list items + list_items: true + +# MD032/blanks-around-lists - Lists should be surrounded by blank lines +MD032: true + +# MD033/no-inline-html - Inline HTML +MD033: false + +# MD034/no-bare-urls - Bare URL used +MD034: true + +# MD035/hr-style - Horizontal rule style +MD035: + # Horizontal rule style + style: "consistent" + +# MD036/no-emphasis-as-heading/no-emphasis-as-header - Emphasis used instead of a heading +MD036: false + # Punctuation characters + # punctuation: ".,;:!?。,;:!?" + +# MD037/no-space-in-emphasis - Spaces inside emphasis markers +MD037: true + +# MD038/no-space-in-code - Spaces inside code span elements +MD038: true + +# MD039/no-space-in-links - Spaces inside link text +MD039: true + +# MD040/fenced-code-language - Fenced code blocks should have a language specified +MD040: false + +# MD041/first-line-heading/first-line-h1 - First line in a file should be a top-level heading +MD041: + # Heading level + level: 1 + # RegExp for matching title in front matter + front_matter_title: "^\\s*title\\s*[:=]" + +# MD042/no-empty-links - No empty links +MD042: true + +# MD043/required-headings/required-headers - Required heading structure +MD043: false + +# MD044/proper-names - Proper names should have the correct capitalization +MD044: + # List of proper names + names: [] + # Include code blocks + code_blocks: true + # Include HTML elements + html_elements: true + +# MD045/no-alt-text - Images should have alternate text (alt text) +MD045: true + +# MD046/code-block-style - Code block style +MD046: + # Block style + style: "consistent" + +# MD047/single-trailing-newline - Files should end with a single newline character +MD047: true + +# MD048/code-fence-style - Code fence style +MD048: + # Code fence style + style: "consistent" + +# MD049/emphasis-style - Emphasis style should be consistent +MD049: + # Emphasis style should be consistent + style: "consistent" + +# MD050/strong-style - Strong style should be consistent +MD050: + # Strong style should be consistent + style: "consistent" + +# MD051/link-fragments - Link fragments should be valid +MD051: false + +# MD052/reference-links-images - Reference links and images should use a label that is defined +MD052: true + +# MD053/link-image-reference-definitions - Link and image reference definitions should be needed +MD053: true diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html index 3a8d7f30a..5e9fa141d 100644 --- a/docs/_includes/sidebar.html +++ b/docs/_includes/sidebar.html @@ -80,7 +80,8 @@ - \ No newline at end of file + diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 913e47a61..3d9e34c8f 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -49,7 +49,8 @@

    {{ section.title }}

    workflow_steps %}

    - {{ section.title }} {{ site.t[page.lang].legacy }} + {{ section.title }} +

    {{ section.content | markdownify }}
    @@ -61,4 +62,4 @@

    {% include analytics.html %} - \ No newline at end of file + From 7b4b082b896ba222ed9b8d208be3d841f4727f75 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 13 Apr 2023 12:30:18 +0900 Subject: [PATCH 578/865] Improve the default OAuth page renderers not to embed any params without escaping them (#882) --- setup.py | 2 +- slack_bolt/oauth/internals.py | 12 ++++----- .../adapter_tests_async/test_async_falcon.py | 2 +- .../adapter_tests_async/test_async_fastapi.py | 2 +- tests/adapter_tests_async/test_async_sanic.py | 2 +- .../test_async_starlette.py | 2 +- tests/slack_bolt/oauth/test_internals.py | 25 ++++++++++++++++++- 7 files changed, 35 insertions(+), 12 deletions(-) diff --git a/setup.py b/setup.py index 107c0e0e6..470188554 100755 --- a/setup.py +++ b/setup.py @@ -58,7 +58,7 @@ ), include_package_data=True, # MANIFEST.in install_requires=[ - "slack_sdk>=3.20.2,<4", + "slack_sdk>=3.21.1,<4", ], setup_requires=["pytest-runner==5.2"], tests_require=async_test_dependencies, diff --git a/slack_bolt/oauth/internals.py b/slack_bolt/oauth/internals.py index 801b6e63d..efd9dc58b 100644 --- a/slack_bolt/oauth/internals.py +++ b/slack_bolt/oauth/internals.py @@ -1,3 +1,4 @@ +import html from logging import Logger from typing import Optional from typing import Union @@ -32,7 +33,7 @@ def _build_callback_success_response( # type: ignore debug_message = f"Handling an OAuth callback success (request: {request.query})" self._logger.debug(debug_message) - html = self._redirect_uri_page_renderer.render_success_page( + page_content = self._redirect_uri_page_renderer.render_success_page( app_id=installation.app_id, team_id=installation.team_id, is_enterprise_install=installation.is_enterprise_install, @@ -44,7 +45,7 @@ def _build_callback_success_response( # type: ignore "Content-Type": "text/html; charset=utf-8", "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), }, - body=html, + body=page_content, ) def _build_callback_failure_response( # type: ignore @@ -60,14 +61,13 @@ def _build_callback_failure_response( # type: ignore # Adding a bit more details to the error code to help installers understand what's happening. # This modification in the HTML page works only when developers use this built-in failure handler. detailed_error = build_detailed_error(reason) - html = self._redirect_uri_page_renderer.render_failure_page(detailed_error) return BoltResponse( status=status, headers={ "Content-Type": "text/html; charset=utf-8", "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), }, - body=html, + body=self._redirect_uri_page_renderer.render_failure_page(detailed_error), ) @@ -85,7 +85,7 @@ def _build_default_install_page_html(url: str) -> str:

    Slack App Installation

    -

    +

    """ # noqa: E501 @@ -142,4 +142,4 @@ def build_detailed_error(reason: str) -> str: elif reason == "storage_error": return f"{reason}: The app's server encountered an issue. Contact the app developer." else: - return f"{reason}: This error code is returned from Slack. Refer to the documents for details." + return f"{html.escape(reason)}: This error code is returned from Slack. Refer to the documents for details." diff --git a/tests/adapter_tests_async/test_async_falcon.py b/tests/adapter_tests_async/test_async_falcon.py index 559d83a31..ada39307a 100644 --- a/tests/adapter_tests_async/test_async_falcon.py +++ b/tests/adapter_tests_async/test_async_falcon.py @@ -201,5 +201,5 @@ def test_oauth(self): response = client.simulate_get("/slack/install") assert response.status_code == 200 assert response.headers.get("content-type") == "text/html; charset=utf-8" - assert response.headers.get("content-length") == "597" + assert response.headers.get("content-length") == "609" assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/adapter_tests_async/test_async_fastapi.py b/tests/adapter_tests_async/test_async_fastapi.py index 21fa77508..e3d92e1b9 100644 --- a/tests/adapter_tests_async/test_async_fastapi.py +++ b/tests/adapter_tests_async/test_async_fastapi.py @@ -209,7 +209,7 @@ async def endpoint(req: Request): response = client.get("/slack/install", allow_redirects=False) assert response.status_code == 200 assert response.headers.get("content-type") == "text/html; charset=utf-8" - assert response.headers.get("content-length") == "597" + assert response.headers.get("content-length") == "609" assert "https://slack.com/oauth/v2/authorize?state=" in response.text def test_custom_props(self): diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py index 2100bac01..b4a75431f 100644 --- a/tests/adapter_tests_async/test_async_sanic.py +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -221,6 +221,6 @@ async def endpoint(req: Request): # NOTE: Although sanic-testing 0.6 does not have this value, # Sanic apps properly generate the content-length header - # assert response.headers.get("content-length") == "597" + # assert response.headers.get("content-length") == "609" assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/adapter_tests_async/test_async_starlette.py b/tests/adapter_tests_async/test_async_starlette.py index 9227ba690..d233dd8bb 100644 --- a/tests/adapter_tests_async/test_async_starlette.py +++ b/tests/adapter_tests_async/test_async_starlette.py @@ -219,5 +219,5 @@ async def endpoint(req: Request): response = client.get("/slack/install", allow_redirects=False) assert response.status_code == 200 assert response.headers.get("content-type") == "text/html; charset=utf-8" - assert response.headers.get("content-length") == "597" + assert response.headers.get("content-length") == "609" assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/slack_bolt/oauth/test_internals.py b/tests/slack_bolt/oauth/test_internals.py index 6abdceb47..88a62e17d 100644 --- a/tests/slack_bolt/oauth/test_internals.py +++ b/tests/slack_bolt/oauth/test_internals.py @@ -1,4 +1,4 @@ -from slack_bolt.oauth.internals import build_detailed_error +from slack_bolt.oauth.internals import build_detailed_error, _build_default_install_page_html class TestOAuthInternals: @@ -23,3 +23,26 @@ def test_build_detailed_error_others(self): assert result.startswith( "access_denied: This error code is returned from Slack. Refer to the documents for details." ) + + def test_build_detailed_error_others_with_tags(self): + result = build_detailed_error("test") + assert result.startswith( + "<b>test</b>: This error code is returned from Slack. Refer to the documents for details." + ) + + def test_build_default_install_page_html(self): + test_patterns = [ + { + "input": "https://slack.com/oauth/v2/authorize?state=random&client_id=111.222&scope=commands", + "expected": "https://slack.com/oauth/v2/authorize?state=random&client_id=111.222&scope=commands", + }, + { + "input": "test", + "expected": "<b>test</b>", + }, + ] + for pattern in test_patterns: + url = pattern["input"] + result = _build_default_install_page_html(url) + assert url not in result + assert pattern["expected"] in result From f8c1b86a81690eb5b12cca40339102d23de1f7de Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 13 Apr 2023 12:43:26 +0900 Subject: [PATCH 579/865] version 1.17.1 --- docs/api-docs/slack_bolt/oauth/internals.html | 23 +++++++++---------- docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/docs/api-docs/slack_bolt/oauth/internals.html b/docs/api-docs/slack_bolt/oauth/internals.html index 2a64ea8d4..5d0efb4b7 100644 --- a/docs/api-docs/slack_bolt/oauth/internals.html +++ b/docs/api-docs/slack_bolt/oauth/internals.html @@ -26,7 +26,8 @@

    Module slack_bolt.oauth.internals

    Expand source code -
    from logging import Logger
    +
    import html
    +from logging import Logger
     from typing import Optional
     from typing import Union
     
    @@ -60,7 +61,7 @@ 

    Module slack_bolt.oauth.internals

    debug_message = f"Handling an OAuth callback success (request: {request.query})" self._logger.debug(debug_message) - html = self._redirect_uri_page_renderer.render_success_page( + page_content = self._redirect_uri_page_renderer.render_success_page( app_id=installation.app_id, team_id=installation.team_id, is_enterprise_install=installation.is_enterprise_install, @@ -72,7 +73,7 @@

    Module slack_bolt.oauth.internals

    "Content-Type": "text/html; charset=utf-8", "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), }, - body=html, + body=page_content, ) def _build_callback_failure_response( # type: ignore @@ -88,14 +89,13 @@

    Module slack_bolt.oauth.internals

    # Adding a bit more details to the error code to help installers understand what's happening. # This modification in the HTML page works only when developers use this built-in failure handler. detailed_error = build_detailed_error(reason) - html = self._redirect_uri_page_renderer.render_failure_page(detailed_error) return BoltResponse( status=status, headers={ "Content-Type": "text/html; charset=utf-8", "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), }, - body=html, + body=self._redirect_uri_page_renderer.render_failure_page(detailed_error), ) @@ -113,7 +113,7 @@

    Module slack_bolt.oauth.internals

    </head> <body> <h2>Slack App Installation</h2> -<p><a href="{url}"><img alt=""Add to Slack"" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a></p> +<p><a href="{html.escape(url)}"><img alt=""Add to Slack"" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a></p> </body> </html> """ # noqa: E501 @@ -170,7 +170,7 @@

    Module slack_bolt.oauth.internals

    elif reason == "storage_error": return f"{reason}: The app's server encountered an issue. Contact the app developer." else: - return f"{reason}: This error code is returned from Slack. Refer to the documents for details."
    + return f"{html.escape(reason)}: This error code is returned from Slack. Refer to the documents for details."

    @@ -203,7 +203,7 @@

    Functions

    elif reason == "storage_error": return f"{reason}: The app's server encountered an issue. Contact the app developer." else: - return f"{reason}: This error code is returned from Slack. Refer to the documents for details."
    + return f"{html.escape(reason)}: This error code is returned from Slack. Refer to the documents for details."
    @@ -292,7 +292,7 @@

    Classes

    debug_message = f"Handling an OAuth callback success (request: {request.query})" self._logger.debug(debug_message) - html = self._redirect_uri_page_renderer.render_success_page( + page_content = self._redirect_uri_page_renderer.render_success_page( app_id=installation.app_id, team_id=installation.team_id, is_enterprise_install=installation.is_enterprise_install, @@ -304,7 +304,7 @@

    Classes

    "Content-Type": "text/html; charset=utf-8", "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), }, - body=html, + body=page_content, ) def _build_callback_failure_response( # type: ignore @@ -320,14 +320,13 @@

    Classes

    # Adding a bit more details to the error code to help installers understand what's happening. # This modification in the HTML page works only when developers use this built-in failure handler. detailed_error = build_detailed_error(reason) - html = self._redirect_uri_page_renderer.render_failure_page(detailed_error) return BoltResponse( status=status, headers={ "Content-Type": "text/html; charset=utf-8", "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), }, - body=html, + body=self._redirect_uri_page_renderer.render_failure_page(detailed_error), )
    diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index b6ea4a21f..a55652360 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.17.0"
    +__version__ = "1.17.1"
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 6b81098d6..02de47405 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.17.0" +__version__ = "1.17.1" From 7bf508f13b59bd2681d5087f3f581038cd5b7966 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 18 Apr 2023 15:10:38 +0900 Subject: [PATCH 580/865] Improve the default handler when raise_error_for_unhandled_request is true (#885) --- slack_bolt/app/app.py | 30 ++++++++++++++++----------- slack_bolt/app/async_app.py | 30 ++++++++++++++++----------- slack_bolt/error/__init__.py | 3 +++ tests/slack_bolt/error/__init__.py | 0 tests/slack_bolt/error/test_errors.py | 19 +++++++++++++++++ 5 files changed, 58 insertions(+), 24 deletions(-) create mode 100644 tests/slack_bolt/error/__init__.py create mode 100644 tests/slack_bolt/error/test_errors.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index e79cac07c..d01163636 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -511,15 +511,18 @@ def middleware_next(): # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -562,14 +565,17 @@ def middleware_next(): if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) except Exception as error: diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 8e76bf4b6..7dca75b34 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -535,15 +535,18 @@ async def async_middleware_next(): # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - await self._async_listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -589,14 +592,17 @@ async def async_middleware_next(): if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - await self._async_listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) diff --git a/slack_bolt/error/__init__.py b/slack_bolt/error/__init__.py index 0e03032b1..d881fb188 100644 --- a/slack_bolt/error/__init__.py +++ b/slack_bolt/error/__init__.py @@ -23,3 +23,6 @@ def __init__( # type: ignore self.body = request.body if request is not None else {} self.current_response = current_response self.last_global_middleware_name = last_global_middleware_name + + def __str__(self) -> str: + return "unhandled request error" diff --git a/tests/slack_bolt/error/__init__.py b/tests/slack_bolt/error/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/error/test_errors.py b/tests/slack_bolt/error/test_errors.py new file mode 100644 index 000000000..42a143d7c --- /dev/null +++ b/tests/slack_bolt/error/test_errors.py @@ -0,0 +1,19 @@ +from slack_bolt import BoltRequest +from slack_bolt.error import BoltUnhandledRequestError + + +class TestErrors: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_say(self): + request = BoltRequest(body="foo=bar") + exception = BoltUnhandledRequestError( + request=request, + current_response={}, + last_global_middleware_name="last_middleware", + ) + assert str(exception) == "unhandled request error" From 076efb5b0b6db849b074752cec0d406d3c747627 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 18 Apr 2023 15:22:31 +0900 Subject: [PATCH 581/865] version 1.17.2 --- docs/api-docs/slack_bolt/app/app.html | 90 ++++++++++++--------- docs/api-docs/slack_bolt/app/async_app.html | 90 ++++++++++++--------- docs/api-docs/slack_bolt/app/index.html | 60 ++++++++------ docs/api-docs/slack_bolt/async_app.html | 60 ++++++++------ docs/api-docs/slack_bolt/error/index.html | 10 ++- docs/api-docs/slack_bolt/index.html | 60 ++++++++------ docs/api-docs/slack_bolt/version.html | 2 +- setup.py | 2 +- slack_bolt/version.py | 2 +- 9 files changed, 227 insertions(+), 149 deletions(-) diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index 9490358d6..3e01431fc 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -539,15 +539,18 @@

    Module slack_bolt.app.app

    # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -590,14 +593,17 @@

    Module slack_bolt.app.app

    if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) except Exception as error: @@ -1995,15 +2001,18 @@

    Args

    # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -2046,14 +2055,17 @@

    Args

    if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) except Exception as error: @@ -3274,15 +3286,18 @@

    Returns

    # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -3325,14 +3340,17 @@

    Returns

    if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) except Exception as error: diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index f373a32e6..ecb30728e 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -563,15 +563,18 @@

    Module slack_bolt.app.async_app

    # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - await self._async_listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -617,14 +620,17 @@

    Module slack_bolt.app.async_app

    if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - await self._async_listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) @@ -1915,15 +1921,18 @@

    Args

    # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - await self._async_listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -1969,14 +1978,17 @@

    Args

    if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - await self._async_listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) @@ -2952,15 +2964,18 @@

    Returns

    # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - await self._async_listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -3006,14 +3021,17 @@

    Returns

    if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - await self._async_listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) diff --git a/docs/api-docs/slack_bolt/app/index.html b/docs/api-docs/slack_bolt/app/index.html index 4175c54ac..6604ab567 100644 --- a/docs/api-docs/slack_bolt/app/index.html +++ b/docs/api-docs/slack_bolt/app/index.html @@ -588,15 +588,18 @@

    Args

    # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -639,14 +642,17 @@

    Args

    if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) except Exception as error: @@ -1867,15 +1873,18 @@

    Returns

    # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -1918,14 +1927,17 @@

    Returns

    if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) except Exception as error: diff --git a/docs/api-docs/slack_bolt/async_app.html b/docs/api-docs/slack_bolt/async_app.html index e6b693440..902e18dfe 100644 --- a/docs/api-docs/slack_bolt/async_app.html +++ b/docs/api-docs/slack_bolt/async_app.html @@ -718,15 +718,18 @@

    Args

    # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - await self._async_listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -772,14 +775,17 @@

    Args

    if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - await self._async_listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) @@ -1755,15 +1761,18 @@

    Returns

    # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - await self._async_listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -1809,14 +1818,17 @@

    Returns

    if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - await self._async_listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) diff --git a/docs/api-docs/slack_bolt/error/index.html b/docs/api-docs/slack_bolt/error/index.html index 827fecc42..fcc4de3fe 100644 --- a/docs/api-docs/slack_bolt/error/index.html +++ b/docs/api-docs/slack_bolt/error/index.html @@ -51,7 +51,10 @@

    Module slack_bolt.error

    self.request = request self.body = request.body if request is not None else {} self.current_response = current_response - self.last_global_middleware_name = last_global_middleware_name + self.last_global_middleware_name = last_global_middleware_name + + def __str__(self) -> str: + return "unhandled request error"
    @@ -112,7 +115,10 @@

    Subclasses

    self.request = request self.body = request.body if request is not None else {} self.current_response = current_response - self.last_global_middleware_name = last_global_middleware_name + self.last_global_middleware_name = last_global_middleware_name + + def __str__(self) -> str: + return "unhandled request error"

    Ancestors

      diff --git a/docs/api-docs/slack_bolt/index.html b/docs/api-docs/slack_bolt/index.html index fa7283542..3e741d194 100644 --- a/docs/api-docs/slack_bolt/index.html +++ b/docs/api-docs/slack_bolt/index.html @@ -726,15 +726,18 @@

      Args

      # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -777,14 +780,17 @@

      Args

      if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) except Exception as error: @@ -2005,15 +2011,18 @@

      Returns

      # This should not be an intentional handling in usual use cases. resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, last_global_middleware_name=middleware.name, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) return resp @@ -2056,14 +2065,17 @@

      Returns

      if resp is None: resp = BoltResponse(status=404, body={"error": "unhandled request"}) if self._raise_error_for_unhandled_request is True: - self._listener_runner.listener_error_handler.handle( - error=BoltUnhandledRequestError( + try: + raise BoltUnhandledRequestError( request=req, current_response=resp, - ), - request=req, - response=resp, - ) + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp return self._handle_unmatched_requests(req, resp) except Exception as error: diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index a55652360..78c22351c 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

      Module slack_bolt.version

      Expand source code
      """Check the latest version at https://pypi.org/project/slack-bolt/"""
      -__version__ = "1.17.1"
      +__version__ = "1.17.2"
    diff --git a/setup.py b/setup.py index 470188554..e56410465 100755 --- a/setup.py +++ b/setup.py @@ -58,7 +58,7 @@ ), include_package_data=True, # MANIFEST.in install_requires=[ - "slack_sdk>=3.21.1,<4", + "slack_sdk>=3.21.2,<4", ], setup_requires=["pytest-runner==5.2"], tests_require=async_test_dependencies, diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 02de47405..5b4966f0c 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.17.1" +__version__ = "1.17.2" From fea0c9938463b5be2ff13c01d7b794d842b7da12 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 26 Apr 2023 15:05:46 +0900 Subject: [PATCH 582/865] Update codecov build settings --- .github/workflows/codecov.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index ec04d58b2..e5f8e0c9a 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -7,8 +7,9 @@ on: jobs: build: - runs-on: ubuntu-latest - timeout-minutes: 10 + # Avoiding -latest due to https://github.com/actions/setup-python/issues/162 + runs-on: ubuntu-20.04 + timeout-minutes: 15 strategy: matrix: python-version: ["3.11"] From a06fcb5820aa1579fa02bd09c8d6b502733295b5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 26 Apr 2023 14:49:16 +0900 Subject: [PATCH 583/865] Add url, team, user to AuthorizeResult properties --- slack_bolt/authorization/authorize_result.py | 20 ++++- .../test_app_actor_user_token.py | 8 +- .../test_app_installation_store.py | 3 + .../test_app_actor_user_token.py | 3 + .../test_app_installation_store.py | 3 + .../authorization/test_authorize.py | 80 +++++++++++++++++++ .../authorization/test_async_authorize.py | 75 +++++++++++++++++ 7 files changed, 190 insertions(+), 2 deletions(-) diff --git a/slack_bolt/authorization/authorize_result.py b/slack_bolt/authorization/authorize_result.py index 032375519..505245d3f 100644 --- a/slack_bolt/authorization/authorize_result.py +++ b/slack_bolt/authorization/authorize_result.py @@ -8,11 +8,16 @@ class AuthorizeResult(dict): enterprise_id: Optional[str] team_id: Optional[str] + team: Optional[str] # since v1.18 + url: Optional[str] # since v1.18 + bot_id: Optional[str] bot_user_id: Optional[str] bot_token: Optional[str] bot_scopes: Optional[List[str]] # since v1.17 + user_id: Optional[str] + user: Optional[str] # since v1.18 user_token: Optional[str] user_scopes: Optional[List[str]] # since v1.17 @@ -21,6 +26,8 @@ def __init__( *, enterprise_id: Optional[str], team_id: Optional[str], + team: Optional[str] = None, + url: Optional[str] = None, # bot bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, @@ -28,6 +35,7 @@ def __init__( bot_scopes: Optional[Union[List[str], str]] = None, # user user_id: Optional[str] = None, + user: Optional[str] = None, user_token: Optional[str] = None, user_scopes: Optional[Union[List[str], str]] = None, ): @@ -35,16 +43,21 @@ def __init__( Args: enterprise_id: Organization ID (Enterprise Grid) starting with `E` team_id: Workspace ID starting with `T` + team: Workspace name + url: Workspace slack.com URL bot_user_id: Bot user's User ID starting with either `U` or `W` bot_id: Bot ID starting with `B` bot_token: Bot user access token starting with `xoxb-` bot_scopes: The scopes associated with the bot token user_id: The request user ID + user: The request user's name user_token: User access token starting with `xoxp-` user_scopes: The scopes associated wth the user token """ self["enterprise_id"] = self.enterprise_id = enterprise_id self["team_id"] = self.team_id = team_id + self["team"] = self.team = team + self["url"] = self.url = url # bot self["bot_user_id"] = self.bot_user_id = bot_user_id self["bot_id"] = self.bot_id = bot_id @@ -54,6 +67,7 @@ def __init__( self["bot_scopes"] = self.bot_scopes = bot_scopes # type: ignore # user self["user_id"] = self.user_id = user_id + self["user"] = self.user = user self["user_token"] = self.user_token = user_token if user_scopes is not None and isinstance(user_scopes, str): user_scopes = [scope.strip() for scope in user_scopes.split(",")] @@ -76,17 +90,21 @@ def from_auth_test_response( user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None ) - # Since v1.28, user_id can be set when user_token w/ its auth.test response exists + user_name = auth_test_response.get("user") if user_id is None and user_auth_test_response is not None: user_id: Optional[str] = user_auth_test_response.get("user_id") # type:ignore + user_name: Optional[str] = user_auth_test_response.get("user") # type:ignore return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), + team=auth_test_response.get("team"), + url=auth_test_response.get("url"), bot_id=auth_test_response.get("bot_id"), bot_user_id=bot_user_id, bot_scopes=bot_scopes, user_id=user_id, + user=user_name, bot_token=bot_token, user_token=user_token, user_scopes=user_scopes, diff --git a/tests/scenario_tests/test_app_actor_user_token.py b/tests/scenario_tests/test_app_actor_user_token.py index 5c05de43e..15941c79f 100644 --- a/tests/scenario_tests/test_app_actor_user_token.py +++ b/tests/scenario_tests/test_app_actor_user_token.py @@ -169,6 +169,9 @@ def handle_events(context: BoltContext, say: Say): assert context.authorize_result.user_id == "W99999" assert context.authorize_result.user_token == "xoxp-valid-actor-based" assert context.authorize_result.user_scopes == ["search:read", "chat:write"] + assert context.authorize_result.team_id == "T0G9PQBBK" + assert context.authorize_result.team == "Subarachnoid Workspace" + assert context.authorize_result.url == "https://subarachnoid.slack.com/" say("What's up?") response = app.dispatch(self.build_request()) @@ -196,14 +199,17 @@ def handle_events(context: BoltContext, say: Say): assert context.actor_enterprise_id == "E013Y3SHLAY" assert context.actor_team_id == "T111111" assert context.actor_user_id == "W013QGS7BPF" - assert context.authorize_result.bot_id == "BZYBOTHED" assert context.authorize_result.bot_user_id == "W23456789" assert context.authorize_result.bot_token == "xoxb-valid-2" assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user == "bot" assert context.authorize_result.user_id is None assert context.authorize_result.user_token is None assert context.authorize_result.user_scopes is None + assert context.authorize_result.team_id == "T0G9PQBBK" + assert context.authorize_result.team == "Subarachnoid Workspace" + assert context.authorize_result.url == "https://subarachnoid.slack.com/" say("What's up?") response = app.dispatch(self.build_request(team_id="T111111")) diff --git a/tests/scenario_tests/test_app_installation_store.py b/tests/scenario_tests/test_app_installation_store.py index b3b4390e3..1c0b5b07c 100644 --- a/tests/scenario_tests/test_app_installation_store.py +++ b/tests/scenario_tests/test_app_installation_store.py @@ -146,6 +146,9 @@ def handle_app_mention(context: BoltContext, say: Say): assert context.authorize_result.user_id == "W99999" assert context.authorize_result.user_token == "xoxp-valid" assert context.authorize_result.user_scopes == ["search:read"] + assert context.authorize_result.team_id == "T0G9PQBBK" + assert context.authorize_result.team == "Subarachnoid Workspace" + assert context.authorize_result.url == "https://subarachnoid.slack.com/" say("What's up?") response = app.dispatch(self.build_app_mention_request()) diff --git a/tests/scenario_tests_async/test_app_actor_user_token.py b/tests/scenario_tests_async/test_app_actor_user_token.py index f2a7ddd00..f0d3ea2e7 100644 --- a/tests/scenario_tests_async/test_app_actor_user_token.py +++ b/tests/scenario_tests_async/test_app_actor_user_token.py @@ -120,6 +120,9 @@ async def handle_events(context: AsyncBoltContext, say: AsyncSay): assert context.authorize_result.user_id == "W99999" assert context.authorize_result.user_token == "xoxp-valid-actor-based" assert context.authorize_result.user_scopes == ["search:read", "chat:write"] + assert context.authorize_result.team_id == "T0G9PQBBK" + assert context.authorize_result.team == "Subarachnoid Workspace" + assert context.authorize_result.url == "https://subarachnoid.slack.com/" await say("What's up?") request = self.build_request() diff --git a/tests/scenario_tests_async/test_app_installation_store.py b/tests/scenario_tests_async/test_app_installation_store.py index 17bf92255..75e0648de 100644 --- a/tests/scenario_tests_async/test_app_installation_store.py +++ b/tests/scenario_tests_async/test_app_installation_store.py @@ -86,6 +86,9 @@ async def handle_app_mention(context: AsyncBoltContext, say: AsyncSay): assert context.authorize_result.user_id == "W99999" assert context.authorize_result.user_token == "xoxp-valid" assert context.authorize_result.user_scopes == ["search:read"] + assert context.authorize_result.team_id == "T0G9PQBBK" + assert context.authorize_result.team == "Subarachnoid Workspace" + assert context.authorize_result.url == "https://subarachnoid.slack.com/" await say("What's up?") request = self.build_valid_app_mention_request() diff --git a/tests/slack_bolt/authorization/test_authorize.py b/tests/slack_bolt/authorization/test_authorize.py index 32fd43a96..d7c06b226 100644 --- a/tests/slack_bolt/authorization/test_authorize.py +++ b/tests/slack_bolt/authorization/test_authorize.py @@ -48,12 +48,22 @@ def test_installation_store_legacy(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 1) result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 2) def test_installation_store_cached_legacy(self): @@ -71,12 +81,22 @@ def test_installation_store_cached_legacy(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 1) result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 1) # cached def test_installation_store_bot_only(self): @@ -95,12 +115,22 @@ def test_installation_store_bot_only(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 1) result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 2) def test_installation_store_cached_bot_only(self): @@ -120,12 +150,22 @@ def test_installation_store_cached_bot_only(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 1) result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 1) # cached def test_installation_store(self): @@ -138,12 +178,22 @@ def test_installation_store(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 1) result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 2) def test_installation_store_cached(self): @@ -160,12 +210,22 @@ def test_installation_store_cached(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 1) result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 1) # cached def test_fetch_different_user_token(self): @@ -178,6 +238,11 @@ def test_fetch_different_user_token(self): assert result.bot_user_id == "W23456789" assert result.bot_token == "xoxb-valid" assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 1) def test_fetch_different_user_token_with_rotation(self): @@ -209,6 +274,11 @@ def test_fetch_different_user_token_with_rotation(self): assert result.bot_user_id == "W23456789" assert result.bot_token == "xoxb-valid-refreshed" assert result.user_token == "xoxp-valid-refreshed" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 1) def test_remove_latest_user_token_if_it_is_not_relevant(self): @@ -221,6 +291,11 @@ def test_remove_latest_user_token_if_it_is_not_relevant(self): assert result.bot_user_id == "W23456789" assert result.bot_token == "xoxb-valid" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 1) def test_rotate_only_bot_token(self): @@ -252,6 +327,11 @@ def test_rotate_only_bot_token(self): assert result.bot_user_id == "W23456789" assert result.bot_token == "xoxb-valid-refreshed" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" assert_auth_test_count(self, 1) diff --git a/tests/slack_bolt_async/authorization/test_async_authorize.py b/tests/slack_bolt_async/authorization/test_async_authorize.py index 7f2c1c804..4ca41e14a 100644 --- a/tests/slack_bolt_async/authorization/test_async_authorize.py +++ b/tests/slack_bolt_async/authorization/test_async_authorize.py @@ -66,12 +66,22 @@ async def test_installation_store_legacy(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 1) result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 2) @pytest.mark.asyncio @@ -90,12 +100,22 @@ async def test_installation_store_cached_legacy(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 1) result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 1) # cached @pytest.mark.asyncio @@ -115,12 +135,22 @@ async def test_installation_store_bot_only(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 1) result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 2) @pytest.mark.asyncio @@ -141,12 +171,22 @@ async def test_installation_store_cached_bot_only(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 1) result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 1) # cached @pytest.mark.asyncio @@ -161,6 +201,11 @@ async def test_installation_store(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 1) result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") @@ -185,12 +230,22 @@ async def test_installation_store_cached(self): assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 1) result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") assert result.bot_id == "BZYBOTHED" assert result.bot_user_id == "W23456789" assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 1) # cached @pytest.mark.asyncio @@ -204,6 +259,11 @@ async def test_fetch_different_user_token(self): assert result.bot_user_id == "W23456789" assert result.bot_token == "xoxb-valid" assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio @@ -236,6 +296,11 @@ async def test_fetch_different_user_token_with_rotation(self): assert result.bot_user_id == "W23456789" assert result.bot_token == "xoxb-valid-refreshed" assert result.user_token == "xoxp-valid-refreshed" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio @@ -249,6 +314,11 @@ async def test_remove_latest_user_token_if_it_is_not_relevant(self): assert result.bot_user_id == "W23456789" assert result.bot_token == "xoxb-valid" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio @@ -281,6 +351,11 @@ async def test_rotate_only_bot_token(self): assert result.bot_user_id == "W23456789" assert result.bot_token == "xoxb-valid-refreshed" assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" await assert_auth_test_count_async(self, 1) From 99707489a91465eb675e5b5921ef3355bc16d9a3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 26 Apr 2023 16:02:55 +0900 Subject: [PATCH 584/865] Disable Codecov job for now --- .github/workflows/codecov.yml | 85 ++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index e5f8e0c9a..e33303399 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -1,42 +1,43 @@ -name: Run codecov - -on: - push: - branches: [main] - pull_request: - -jobs: - build: - # Avoiding -latest due to https://github.com/actions/setup-python/issues/162 - runs-on: ubuntu-20.04 - timeout-minutes: 15 - strategy: - matrix: - python-version: ["3.11"] - env: - # default: multiprocessing - # threading is more stable on GitHub Actions - BOLT_PYTHON_MOCK_SERVER_MODE: threading - BOLT_PYTHON_CODECOV_RUNNING: "1" - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python setup.py install - pip install -U pip - pip install -e ".[async]" - pip install -e ".[adapter]" - pip install -e ".[testing]" - pip install -e ".[adapter_testing]" - - name: Run all tests for codecov - run: | - pytest --cov=./slack_bolt/ --cov-report=xml - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - fail_ci_if_error: true - verbose: true +# TODO: This CI job hangs as of April 2023 +#name: Run codecov +# +#on: +# push: +# branches: [main] +# pull_request: +# +#jobs: +# build: +# # Avoiding -latest due to https://github.com/actions/setup-python/issues/162 +# runs-on: ubuntu-20.04 +# timeout-minutes: 10 +# strategy: +# matrix: +# python-version: ["3.11"] +# env: +# # default: multiprocessing +# # threading is more stable on GitHub Actions +# BOLT_PYTHON_MOCK_SERVER_MODE: threading +# BOLT_PYTHON_CODECOV_RUNNING: "1" +# steps: +# - uses: actions/checkout@v3 +# - name: Set up Python ${{ matrix.python-version }} +# uses: actions/setup-python@v4 +# with: +# python-version: ${{ matrix.python-version }} +# - name: Install dependencies +# run: | +# python setup.py install +# pip install -U pip +# pip install -e ".[async]" +# pip install -e ".[adapter]" +# pip install -e ".[testing]" +# pip install -e ".[adapter_testing]" +# - name: Run all tests for codecov +# run: | +# pytest --cov=./slack_bolt/ --cov-report=xml +# - name: Upload coverage to Codecov +# uses: codecov/codecov-action@v3 +# with: +# fail_ci_if_error: true +# verbose: true From 515684e061eb095a2e1e88c64bc1b810ac85190a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 26 Apr 2023 16:18:01 +0900 Subject: [PATCH 585/865] verison 1.18.0 --- .../authorization/authorize_result.html | 69 +++++++++++++++++-- .../slack_bolt/authorization/index.html | 49 ++++++++++++- docs/api-docs/slack_bolt/version.html | 2 +- slack_bolt/version.py | 2 +- 4 files changed, 113 insertions(+), 9 deletions(-) diff --git a/docs/api-docs/slack_bolt/authorization/authorize_result.html b/docs/api-docs/slack_bolt/authorization/authorize_result.html index 185aa8cec..8cef1d5c1 100644 --- a/docs/api-docs/slack_bolt/authorization/authorize_result.html +++ b/docs/api-docs/slack_bolt/authorization/authorize_result.html @@ -36,11 +36,16 @@

    Module slack_bolt.authorization.authorize_result< enterprise_id: Optional[str] team_id: Optional[str] + team: Optional[str] # since v1.18 + url: Optional[str] # since v1.18 + bot_id: Optional[str] bot_user_id: Optional[str] bot_token: Optional[str] bot_scopes: Optional[List[str]] # since v1.17 + user_id: Optional[str] + user: Optional[str] # since v1.18 user_token: Optional[str] user_scopes: Optional[List[str]] # since v1.17 @@ -49,6 +54,8 @@

    Module slack_bolt.authorization.authorize_result< *, enterprise_id: Optional[str], team_id: Optional[str], + team: Optional[str] = None, + url: Optional[str] = None, # bot bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, @@ -56,6 +63,7 @@

    Module slack_bolt.authorization.authorize_result< bot_scopes: Optional[Union[List[str], str]] = None, # user user_id: Optional[str] = None, + user: Optional[str] = None, user_token: Optional[str] = None, user_scopes: Optional[Union[List[str], str]] = None, ): @@ -63,16 +71,21 @@

    Module slack_bolt.authorization.authorize_result< Args: enterprise_id: Organization ID (Enterprise Grid) starting with `E` team_id: Workspace ID starting with `T` + team: Workspace name + url: Workspace slack.com URL bot_user_id: Bot user's User ID starting with either `U` or `W` bot_id: Bot ID starting with `B` bot_token: Bot user access token starting with `xoxb-` bot_scopes: The scopes associated with the bot token user_id: The request user ID + user: The request user's name user_token: User access token starting with `xoxp-` user_scopes: The scopes associated wth the user token """ self["enterprise_id"] = self.enterprise_id = enterprise_id self["team_id"] = self.team_id = team_id + self["team"] = self.team = team + self["url"] = self.url = url # bot self["bot_user_id"] = self.bot_user_id = bot_user_id self["bot_id"] = self.bot_id = bot_id @@ -82,6 +95,7 @@

    Module slack_bolt.authorization.authorize_result< self["bot_scopes"] = self.bot_scopes = bot_scopes # type: ignore # user self["user_id"] = self.user_id = user_id + self["user"] = self.user = user self["user_token"] = self.user_token = user_token if user_scopes is not None and isinstance(user_scopes, str): user_scopes = [scope.strip() for scope in user_scopes.split(",")] @@ -104,17 +118,21 @@

    Module slack_bolt.authorization.authorize_result< user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None ) - # Since v1.28, user_id can be set when user_token w/ its auth.test response exists + user_name = auth_test_response.get("user") if user_id is None and user_auth_test_response is not None: user_id: Optional[str] = user_auth_test_response.get("user_id") # type:ignore + user_name: Optional[str] = user_auth_test_response.get("user") # type:ignore return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), + team=auth_test_response.get("team"), + url=auth_test_response.get("url"), bot_id=auth_test_response.get("bot_id"), bot_user_id=bot_user_id, bot_scopes=bot_scopes, user_id=user_id, + user=user_name, bot_token=bot_token, user_token=user_token, user_scopes=user_scopes, @@ -132,7 +150,7 @@

    Classes

    class AuthorizeResult -(*, enterprise_id: Optional[str], team_id: Optional[str], bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, bot_scopes: Union[List[str], str, ForwardRef(None)] = None, user_id: Optional[str] = None, user_token: Optional[str] = None, user_scopes: Union[List[str], str, ForwardRef(None)] = None) +(*, enterprise_id: Optional[str], team_id: Optional[str], team: Optional[str] = None, url: Optional[str] = None, bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, bot_scopes: Union[List[str], str, ForwardRef(None)] = None, user_id: Optional[str] = None, user: Optional[str] = None, user_token: Optional[str] = None, user_scopes: Union[List[str], str, ForwardRef(None)] = None)

    Authorize function call result

    @@ -142,6 +160,10 @@

    Args

    Organization ID (Enterprise Grid) starting with E
    team_id
    Workspace ID starting with T
    +
    team
    +
    Workspace name
    +
    url
    +
    Workspace slack.com URL
    bot_user_id
    Bot user's User ID starting with either U or W
    bot_id
    @@ -152,6 +174,8 @@

    Args

    The scopes associated with the bot token
    user_id
    The request user ID
    +
    user
    +
    The request user's name
    user_token
    User access token starting with xoxp-
    user_scopes
    @@ -166,11 +190,16 @@

    Args

    enterprise_id: Optional[str] team_id: Optional[str] + team: Optional[str] # since v1.18 + url: Optional[str] # since v1.18 + bot_id: Optional[str] bot_user_id: Optional[str] bot_token: Optional[str] bot_scopes: Optional[List[str]] # since v1.17 + user_id: Optional[str] + user: Optional[str] # since v1.18 user_token: Optional[str] user_scopes: Optional[List[str]] # since v1.17 @@ -179,6 +208,8 @@

    Args

    *, enterprise_id: Optional[str], team_id: Optional[str], + team: Optional[str] = None, + url: Optional[str] = None, # bot bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, @@ -186,6 +217,7 @@

    Args

    bot_scopes: Optional[Union[List[str], str]] = None, # user user_id: Optional[str] = None, + user: Optional[str] = None, user_token: Optional[str] = None, user_scopes: Optional[Union[List[str], str]] = None, ): @@ -193,16 +225,21 @@

    Args

    Args: enterprise_id: Organization ID (Enterprise Grid) starting with `E` team_id: Workspace ID starting with `T` + team: Workspace name + url: Workspace slack.com URL bot_user_id: Bot user's User ID starting with either `U` or `W` bot_id: Bot ID starting with `B` bot_token: Bot user access token starting with `xoxb-` bot_scopes: The scopes associated with the bot token user_id: The request user ID + user: The request user's name user_token: User access token starting with `xoxp-` user_scopes: The scopes associated wth the user token """ self["enterprise_id"] = self.enterprise_id = enterprise_id self["team_id"] = self.team_id = team_id + self["team"] = self.team = team + self["url"] = self.url = url # bot self["bot_user_id"] = self.bot_user_id = bot_user_id self["bot_id"] = self.bot_id = bot_id @@ -212,6 +249,7 @@

    Args

    self["bot_scopes"] = self.bot_scopes = bot_scopes # type: ignore # user self["user_id"] = self.user_id = user_id + self["user"] = self.user = user self["user_token"] = self.user_token = user_token if user_scopes is not None and isinstance(user_scopes, str): user_scopes = [scope.strip() for scope in user_scopes.split(",")] @@ -234,17 +272,21 @@

    Args

    user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None ) - # Since v1.28, user_id can be set when user_token w/ its auth.test response exists + user_name = auth_test_response.get("user") if user_id is None and user_auth_test_response is not None: user_id: Optional[str] = user_auth_test_response.get("user_id") # type:ignore + user_name: Optional[str] = user_auth_test_response.get("user") # type:ignore return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), + team=auth_test_response.get("team"), + url=auth_test_response.get("url"), bot_id=auth_test_response.get("bot_id"), bot_user_id=bot_user_id, bot_scopes=bot_scopes, user_id=user_id, + user=user_name, bot_token=bot_token, user_token=user_token, user_scopes=user_scopes, @@ -276,10 +318,22 @@

    Class variables

    +
    var team : Optional[str]
    +
    +
    +
    var team_id : Optional[str]
    +
    var url : Optional[str]
    +
    +
    +
    +
    var user : Optional[str]
    +
    +
    +
    var user_id : Optional[str]
    @@ -321,17 +375,21 @@

    Static methods

    user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None ) - # Since v1.28, user_id can be set when user_token w/ its auth.test response exists + user_name = auth_test_response.get("user") if user_id is None and user_auth_test_response is not None: user_id: Optional[str] = user_auth_test_response.get("user_id") # type:ignore + user_name: Optional[str] = user_auth_test_response.get("user") # type:ignore return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), + team=auth_test_response.get("team"), + url=auth_test_response.get("url"), bot_id=auth_test_response.get("bot_id"), bot_user_id=bot_user_id, bot_scopes=bot_scopes, user_id=user_id, + user=user_name, bot_token=bot_token, user_token=user_token, user_scopes=user_scopes, @@ -365,7 +423,10 @@

    bot_user_id
  • enterprise_id
  • from_auth_test_response
  • +
  • team
  • team_id
  • +
  • url
  • +
  • user
  • user_id
  • user_scopes
  • user_token
  • diff --git a/docs/api-docs/slack_bolt/authorization/index.html b/docs/api-docs/slack_bolt/authorization/index.html index 86a00bfda..8850b423c 100644 --- a/docs/api-docs/slack_bolt/authorization/index.html +++ b/docs/api-docs/slack_bolt/authorization/index.html @@ -76,7 +76,7 @@

    Classes

    class AuthorizeResult -(*, enterprise_id: Optional[str], team_id: Optional[str], bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, bot_scopes: Union[List[str], str, ForwardRef(None)] = None, user_id: Optional[str] = None, user_token: Optional[str] = None, user_scopes: Union[List[str], str, ForwardRef(None)] = None) +(*, enterprise_id: Optional[str], team_id: Optional[str], team: Optional[str] = None, url: Optional[str] = None, bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, bot_token: Optional[str] = None, bot_scopes: Union[List[str], str, ForwardRef(None)] = None, user_id: Optional[str] = None, user: Optional[str] = None, user_token: Optional[str] = None, user_scopes: Union[List[str], str, ForwardRef(None)] = None)

    Authorize function call result

    @@ -86,6 +86,10 @@

    Args

    Organization ID (Enterprise Grid) starting with E
    team_id
    Workspace ID starting with T
    +
    team
    +
    Workspace name
    +
    url
    +
    Workspace slack.com URL
    bot_user_id
    Bot user's User ID starting with either U or W
    bot_id
    @@ -96,6 +100,8 @@

    Args

    The scopes associated with the bot token
    user_id
    The request user ID
    +
    user
    +
    The request user's name
    user_token
    User access token starting with xoxp-
    user_scopes
    @@ -110,11 +116,16 @@

    Args

    enterprise_id: Optional[str] team_id: Optional[str] + team: Optional[str] # since v1.18 + url: Optional[str] # since v1.18 + bot_id: Optional[str] bot_user_id: Optional[str] bot_token: Optional[str] bot_scopes: Optional[List[str]] # since v1.17 + user_id: Optional[str] + user: Optional[str] # since v1.18 user_token: Optional[str] user_scopes: Optional[List[str]] # since v1.17 @@ -123,6 +134,8 @@

    Args

    *, enterprise_id: Optional[str], team_id: Optional[str], + team: Optional[str] = None, + url: Optional[str] = None, # bot bot_user_id: Optional[str] = None, bot_id: Optional[str] = None, @@ -130,6 +143,7 @@

    Args

    bot_scopes: Optional[Union[List[str], str]] = None, # user user_id: Optional[str] = None, + user: Optional[str] = None, user_token: Optional[str] = None, user_scopes: Optional[Union[List[str], str]] = None, ): @@ -137,16 +151,21 @@

    Args

    Args: enterprise_id: Organization ID (Enterprise Grid) starting with `E` team_id: Workspace ID starting with `T` + team: Workspace name + url: Workspace slack.com URL bot_user_id: Bot user's User ID starting with either `U` or `W` bot_id: Bot ID starting with `B` bot_token: Bot user access token starting with `xoxb-` bot_scopes: The scopes associated with the bot token user_id: The request user ID + user: The request user's name user_token: User access token starting with `xoxp-` user_scopes: The scopes associated wth the user token """ self["enterprise_id"] = self.enterprise_id = enterprise_id self["team_id"] = self.team_id = team_id + self["team"] = self.team = team + self["url"] = self.url = url # bot self["bot_user_id"] = self.bot_user_id = bot_user_id self["bot_id"] = self.bot_id = bot_id @@ -156,6 +175,7 @@

    Args

    self["bot_scopes"] = self.bot_scopes = bot_scopes # type: ignore # user self["user_id"] = self.user_id = user_id + self["user"] = self.user = user self["user_token"] = self.user_token = user_token if user_scopes is not None and isinstance(user_scopes, str): user_scopes = [scope.strip() for scope in user_scopes.split(",")] @@ -178,17 +198,21 @@

    Args

    user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None ) - # Since v1.28, user_id can be set when user_token w/ its auth.test response exists + user_name = auth_test_response.get("user") if user_id is None and user_auth_test_response is not None: user_id: Optional[str] = user_auth_test_response.get("user_id") # type:ignore + user_name: Optional[str] = user_auth_test_response.get("user") # type:ignore return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), + team=auth_test_response.get("team"), + url=auth_test_response.get("url"), bot_id=auth_test_response.get("bot_id"), bot_user_id=bot_user_id, bot_scopes=bot_scopes, user_id=user_id, + user=user_name, bot_token=bot_token, user_token=user_token, user_scopes=user_scopes, @@ -220,10 +244,22 @@

    Class variables

    +
    var team : Optional[str]
    +
    +
    +
    var team_id : Optional[str]
    +
    var url : Optional[str]
    +
    +
    +
    +
    var user : Optional[str]
    +
    +
    +
    var user_id : Optional[str]
    @@ -265,17 +301,21 @@

    Static methods

    user_id: Optional[str] = ( # type:ignore auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None ) - # Since v1.28, user_id can be set when user_token w/ its auth.test response exists + user_name = auth_test_response.get("user") if user_id is None and user_auth_test_response is not None: user_id: Optional[str] = user_auth_test_response.get("user_id") # type:ignore + user_name: Optional[str] = user_auth_test_response.get("user") # type:ignore return AuthorizeResult( enterprise_id=auth_test_response.get("enterprise_id"), team_id=auth_test_response.get("team_id"), + team=auth_test_response.get("team"), + url=auth_test_response.get("url"), bot_id=auth_test_response.get("bot_id"), bot_user_id=bot_user_id, bot_scopes=bot_scopes, user_id=user_id, + user=user_name, bot_token=bot_token, user_token=user_token, user_scopes=user_scopes, @@ -318,7 +358,10 @@

    bot_user_id
  • enterprise_id
  • from_auth_test_response
  • +
  • team
  • team_id
  • +
  • url
  • +
  • user
  • user_id
  • user_scopes
  • user_token
  • diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index 78c22351c..eccade944 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.17.2"
    +__version__ = "1.18.0"

    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 5b4966f0c..dfa1bca7a 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.17.2" +__version__ = "1.18.0" From 9f0602fcef0becace9403841bb899b7f26111f1f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 27 Apr 2023 07:18:38 +0900 Subject: [PATCH 586/865] Tweak CI settings --- .github/workflows/codecov.yml | 51 +++++++++++++++++------------------ 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index e33303399..9fa9e3953 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -1,30 +1,29 @@ # TODO: This CI job hangs as of April 2023 -#name: Run codecov -# -#on: -# push: -# branches: [main] -# pull_request: -# -#jobs: -# build: -# # Avoiding -latest due to https://github.com/actions/setup-python/issues/162 -# runs-on: ubuntu-20.04 -# timeout-minutes: 10 -# strategy: -# matrix: -# python-version: ["3.11"] -# env: -# # default: multiprocessing -# # threading is more stable on GitHub Actions -# BOLT_PYTHON_MOCK_SERVER_MODE: threading -# BOLT_PYTHON_CODECOV_RUNNING: "1" -# steps: -# - uses: actions/checkout@v3 -# - name: Set up Python ${{ matrix.python-version }} -# uses: actions/setup-python@v4 -# with: -# python-version: ${{ matrix.python-version }} +name: Run codecov + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + python-version: ["3.11"] + env: + # default: multiprocessing + # threading is more stable on GitHub Actions + BOLT_PYTHON_MOCK_SERVER_MODE: threading + BOLT_PYTHON_CODECOV_RUNNING: "1" + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} # - name: Install dependencies # run: | # python setup.py install From 4da09fb6128c84351d0fced2f187bb854f3abb91 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 1 May 2023 21:05:33 +0900 Subject: [PATCH 587/865] Add metadata to respond method --- slack_bolt/context/respond/async_respond.py | 4 +++- slack_bolt/context/respond/internals.py | 3 +++ slack_bolt/context/respond/respond.py | 4 +++- tests/slack_bolt/context/test_respond.py | 10 ++++++++++ tests/slack_bolt/context/test_respond_internals.py | 8 ++++++++ tests/slack_bolt_async/context/test_async_respond.py | 11 +++++++++++ 6 files changed, 38 insertions(+), 2 deletions(-) diff --git a/slack_bolt/context/respond/async_respond.py b/slack_bolt/context/respond/async_respond.py index 151525ee3..66dd19374 100644 --- a/slack_bolt/context/respond/async_respond.py +++ b/slack_bolt/context/respond/async_respond.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, Sequence +from typing import Optional, Union, Sequence, Dict, Any from ssl import SSLContext from slack_sdk.models.attachments import Attachment @@ -35,6 +35,7 @@ async def __call__( unfurl_links: Optional[bool] = None, unfurl_media: Optional[bool] = None, thread_ts: Optional[str] = None, + metadata: Dict[str, Any] = None, ) -> WebhookResponse: if self.response_url is not None: client = AsyncWebhookClient( @@ -54,6 +55,7 @@ async def __call__( unfurl_links=unfurl_links, unfurl_media=unfurl_media, thread_ts=thread_ts, + metadata=metadata, ) 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 f32976924..caf328690 100644 --- a/slack_bolt/context/respond/internals.py +++ b/slack_bolt/context/respond/internals.py @@ -16,6 +16,7 @@ def _build_message( unfurl_links: Optional[bool] = None, unfurl_media: Optional[bool] = None, thread_ts: Optional[str] = None, + metadata: Dict[str, Any] = None, ) -> Dict[str, Any]: message = {"text": text} if blocks is not None and len(blocks) > 0: @@ -34,4 +35,6 @@ def _build_message( message["unfurl_media"] = unfurl_media if thread_ts is not None: message["thread_ts"] = thread_ts + if metadata is not None: + message["metadata"] = metadata return message diff --git a/slack_bolt/context/respond/respond.py b/slack_bolt/context/respond/respond.py index 59ef11870..ed77fd7f5 100644 --- a/slack_bolt/context/respond/respond.py +++ b/slack_bolt/context/respond/respond.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, Sequence +from typing import Optional, Union, Sequence, Any, Dict from ssl import SSLContext from slack_sdk.models.attachments import Attachment @@ -35,6 +35,7 @@ def __call__( unfurl_links: Optional[bool] = None, unfurl_media: Optional[bool] = None, thread_ts: Optional[str] = None, + metadata: Dict[str, Any] = None, ) -> WebhookResponse: if self.response_url is not None: client = WebhookClient( @@ -55,6 +56,7 @@ def __call__( unfurl_links=unfurl_links, unfurl_media=unfurl_media, thread_ts=thread_ts, + metadata=metadata, ) return client.send_dict(message) elif isinstance(text_or_whole_response, dict): diff --git a/tests/slack_bolt/context/test_respond.py b/tests/slack_bolt/context/test_respond.py index f108664f3..88e98dec8 100644 --- a/tests/slack_bolt/context/test_respond.py +++ b/tests/slack_bolt/context/test_respond.py @@ -29,3 +29,13 @@ def test_unfurl_options(self): respond = Respond(response_url=response_url) response = respond(text="Hi there!", unfurl_media=True, unfurl_links=True) assert response.status_code == 200 + + def test_metadata(self): + response_url = "http://localhost:8888" + respond = Respond(response_url=response_url) + response = respond( + text="Hi there!", + response_type="in_channel", + metadata={"event_type": "foo", "event_payload": {"foo": "bar"}}, + ) + assert response.status_code == 200 diff --git a/tests/slack_bolt/context/test_respond_internals.py b/tests/slack_bolt/context/test_respond_internals.py index facb293da..7d497e205 100644 --- a/tests/slack_bolt/context/test_respond_internals.py +++ b/tests/slack_bolt/context/test_respond_internals.py @@ -49,3 +49,11 @@ def test_build_message_unfurl_options(self): assert message is not None assert message.get("unfurl_links") is True assert message.get("unfurl_media") is True + + def test_metadata(self): + message = _build_message( + text="Hi there!", response_type="in_channel", metadata={"event_type": "foo", "event_payload": {"foo": "bar"}} + ) + assert message is not None + assert message.get("metadata").get("event_type") == "foo" + assert message.get("metadata").get("event_payload") == {"foo": "bar"} diff --git a/tests/slack_bolt_async/context/test_async_respond.py b/tests/slack_bolt_async/context/test_async_respond.py index 18ae114e8..9332ecac4 100644 --- a/tests/slack_bolt_async/context/test_async_respond.py +++ b/tests/slack_bolt_async/context/test_async_respond.py @@ -38,3 +38,14 @@ async def test_respond_unfurl_options(self): respond = AsyncRespond(response_url=response_url) response = await respond(text="Hi there!", unfurl_media=True, unfurl_links=True) assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_metadata(self): + response_url = "http://localhost:8888" + respond = AsyncRespond(response_url=response_url) + response = await respond( + text="Hi there!", + response_type="in_channel", + metadata={"event_type": "foo", "event_payload": {"foo": "bar"}}, + ) + assert response.status_code == 200 From c4161a927674908e54027a4109909e4b1fe1254e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 1 May 2023 21:14:06 +0900 Subject: [PATCH 588/865] Fix --- slack_bolt/context/respond/internals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/context/respond/internals.py b/slack_bolt/context/respond/internals.py index caf328690..463dd2f76 100644 --- a/slack_bolt/context/respond/internals.py +++ b/slack_bolt/context/respond/internals.py @@ -16,7 +16,7 @@ def _build_message( unfurl_links: Optional[bool] = None, unfurl_media: Optional[bool] = None, thread_ts: Optional[str] = None, - metadata: Dict[str, Any] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: message = {"text": text} if blocks is not None and len(blocks) > 0: From 9814103168a28c9c4897a09868f9111959c0dea0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 1 May 2023 21:14:55 +0900 Subject: [PATCH 589/865] Fix --- slack_bolt/context/respond/async_respond.py | 2 +- slack_bolt/context/respond/respond.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/slack_bolt/context/respond/async_respond.py b/slack_bolt/context/respond/async_respond.py index 66dd19374..f5939cba9 100644 --- a/slack_bolt/context/respond/async_respond.py +++ b/slack_bolt/context/respond/async_respond.py @@ -35,7 +35,7 @@ async def __call__( unfurl_links: Optional[bool] = None, unfurl_media: Optional[bool] = None, thread_ts: Optional[str] = None, - metadata: Dict[str, Any] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> WebhookResponse: if self.response_url is not None: client = AsyncWebhookClient( diff --git a/slack_bolt/context/respond/respond.py b/slack_bolt/context/respond/respond.py index ed77fd7f5..0f16f6851 100644 --- a/slack_bolt/context/respond/respond.py +++ b/slack_bolt/context/respond/respond.py @@ -35,7 +35,7 @@ def __call__( unfurl_links: Optional[bool] = None, unfurl_media: Optional[bool] = None, thread_ts: Optional[str] = None, - metadata: Dict[str, Any] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> WebhookResponse: if self.response_url is not None: client = WebhookClient( From 1350c111f5602d071bcc8b34e80e5c2ff901904f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 25 May 2023 13:40:18 +0900 Subject: [PATCH 590/865] Add code comments for modal view handling ref: https://github.com/slackapi/python-slack-sdk/issues/1373#issuecomment-1562249340 --- docs/_basic/ja_listening_modals.md | 3 +++ docs/_basic/listening_modals.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/docs/_basic/ja_listening_modals.md b/docs/_basic/ja_listening_modals.md index d23ddcf89..12cdae37a 100644 --- a/docs/_basic/ja_listening_modals.md +++ b/docs/_basic/ja_listening_modals.md @@ -21,6 +21,9 @@ order: 12 # モーダル送信でのビューの更新 @app.view("view_1") def handle_submission(ack, body): + # build_new_view() method はモーダルビューを返します + # モーダルの構築には Block Kit Builder を試してみてください: + # https://app.slack.com/block-kit-builder/#%7B%22type%22:%22modal%22,%22callback_id%22:%22view_1%22,%22title%22:%7B%22type%22:%22plain_text%22,%22text%22:%22My%20App%22,%22emoji%22:true%7D,%22blocks%22:%5B%5D%7D ack(response_action="update", view=build_new_view(body)) ``` この例と同様に、モーダルでの送信リクエストに対して、エラーを表示するためのオプションもあります。 diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md index 4080b260b..8d0916735 100644 --- a/docs/_basic/listening_modals.md +++ b/docs/_basic/listening_modals.md @@ -21,6 +21,9 @@ To update a view in response to a `view_submission` event, you may pass a `respo # Update the view on submission @app.view("view_1") def handle_submission(ack, body): + # The build_new_view() method returns a modal view + # To build a modal view, we recommend using Block Kit Builder: + # https://app.slack.com/block-kit-builder/#%7B%22type%22:%22modal%22,%22callback_id%22:%22view_1%22,%22title%22:%7B%22type%22:%22plain_text%22,%22text%22:%22My%20App%22,%22emoji%22:true%7D,%22blocks%22:%5B%5D%7D ack(response_action="update", view=build_new_view(body)) ``` Similarly, there are options for [displaying errors](https://api.slack.com/surfaces/modals/using#displaying_errors) in response to view submissions. From 426cecdfb17cd43461a976176017ae2372121837 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 12 Jun 2023 10:12:50 +0900 Subject: [PATCH 591/865] Correct docs --- docs/_basic/ja_listening_actions.md | 2 +- docs/_basic/listening_actions.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_basic/ja_listening_actions.md b/docs/_basic/ja_listening_actions.md index b468fdd42..da4c6fc11 100644 --- a/docs/_basic/ja_listening_actions.md +++ b/docs/_basic/ja_listening_actions.md @@ -32,7 +32,7 @@ def update_message(ack):
    -制約付きのオブジェクトを使用すると、`callback_id`、`block_id`、および `action_id` をそれぞれ、または任意に組み合わせてリッスンできます。オブジェクト内の制約は、`str` 型または `re.Pattern` 型で指定できます。 +制約付きのオブジェクトを使用すると、`block_id` と `action_id` をそれぞれ、または任意に組み合わせてリッスンできます。オブジェクト内の制約は、`str` 型または `re.Pattern` 型で指定できます。
    diff --git a/docs/_basic/listening_actions.md b/docs/_basic/listening_actions.md index 23312a8e3..bd5209b42 100644 --- a/docs/_basic/listening_actions.md +++ b/docs/_basic/listening_actions.md @@ -32,7 +32,7 @@ def update_message(ack):
    -You can use a constraints object to listen to `callback_id`s, `block_id`s, and `action_id`s (or any combination of them). Constraints in the object can be of type `str` or `re.Pattern`. +You can use a constraints object to listen to `block_id`s and `action_id`s (or any combination of them). Constraints in the object can be of type `str` or `re.Pattern`.
    From b24e4cbfbb082560eb5391b60b918ff9fed358cb Mon Sep 17 00:00:00 2001 From: Anton Butsyk Date: Thu, 15 Jun 2023 05:12:01 +0300 Subject: [PATCH 592/865] Add port parameter to web_app (#917) --- slack_bolt/app/async_app.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 7dca75b34..c70fc2e54 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -470,7 +470,7 @@ def server( ) return self._server - def web_app(self, path: str = "/slack/events") -> web.Application: + def web_app(self, path: str = "/slack/events", port: int = 3000) -> web.Application: """Returns a `web.Application` instance for aiohttp-devtools users. from slack_bolt.async_app import AsyncApp @@ -488,8 +488,9 @@ def app_factory(): Args: path: The path to receive incoming requests from Slack + port: The port to listen on (Default: 3000) """ - return self.server(path=path).web_app + return self.server(path=path, port=port).web_app def start(self, port: int = 3000, path: str = "/slack/events", host: Optional[str] = None) -> None: """Start a web server using AIOHTTP. From 3e5f012767d37eaa01fb0ea55bd6ae364ecf320b Mon Sep 17 00:00:00 2001 From: Vignesh Iyer <39982819+vgnshiyer@users.noreply.github.com> Date: Wed, 14 Jun 2023 22:48:45 -0700 Subject: [PATCH 593/865] Fix #892 by improving test code (#918) --- .github/workflows/codecov.yml | 32 +++++++++---------- .../socket_mode/test_async_aiohttp.py | 4 +-- .../socket_mode/test_async_lazy_listeners.py | 4 +-- .../socket_mode/test_async_websockets.py | 4 +-- tests/adapter_tests_async/test_async_sanic.py | 4 +-- tests/scenario_tests_async/test_app.py | 4 +-- .../test_app_actor_user_token.py | 4 +-- .../scenario_tests_async/test_app_bot_only.py | 4 +-- .../test_app_custom_authorize.py | 4 +-- .../scenario_tests_async/test_app_dispatch.py | 4 +-- .../test_app_installation_store.py | 4 +-- .../test_app_using_methods_in_class.py | 4 +-- .../test_attachment_actions.py | 4 +-- tests/scenario_tests_async/test_authorize.py | 4 +-- .../test_block_actions.py | 4 +-- .../test_block_actions_respond.py | 4 +-- .../test_block_suggestion.py | 4 +-- tests/scenario_tests_async/test_dialogs.py | 4 +-- .../test_error_handler.py | 4 +-- tests/scenario_tests_async/test_events.py | 4 +-- .../test_events_ignore_self.py | 4 +-- .../test_events_org_apps.py | 4 +-- .../test_events_request_verification.py | 4 +-- .../test_events_shared_channels.py | 4 +-- .../test_events_socket_mode.py | 4 +-- .../test_events_token_revocations.py | 4 +-- .../test_events_url_verification.py | 4 +-- .../test_installation_store_authorize.py | 4 +-- tests/scenario_tests_async/test_lazy.py | 4 +-- .../test_listener_middleware.py | 4 +-- tests/scenario_tests_async/test_message.py | 4 +-- .../scenario_tests_async/test_message_bot.py | 4 +-- .../test_message_changed.py | 4 +-- .../test_message_deleted.py | 4 +-- .../test_message_file_share.py | 4 +-- .../test_message_thread_broadcast.py | 4 +-- tests/scenario_tests_async/test_middleware.py | 4 +-- tests/scenario_tests_async/test_shortcut.py | 4 +-- .../test_slash_command.py | 4 +-- tests/scenario_tests_async/test_ssl_check.py | 4 +-- .../scenario_tests_async/test_view_closed.py | 4 +-- .../test_view_submission.py | 4 +-- .../test_web_client_customization.py | 4 +-- .../test_workflow_steps.py | 4 +-- .../test_workflow_steps_decorator_simple.py | 4 +-- ...test_workflow_steps_decorator_with_args.py | 4 +-- .../authorization/test_async_authorize.py | 4 +-- .../context/test_async_respond.py | 3 +- .../context/test_async_say.py | 3 +- .../test_single_team_authorization.py | 4 +-- .../test_request_verification.py | 3 +- .../oauth/test_async_oauth_flow.py | 3 +- .../oauth/test_async_oauth_flow_sqlite3.py | 3 +- tests/utils.py | 11 ++++++- 54 files changed, 130 insertions(+), 116 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 9fa9e3953..6d1ab0e52 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -24,19 +24,19 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} -# - name: Install dependencies -# run: | -# python setup.py install -# pip install -U pip -# pip install -e ".[async]" -# pip install -e ".[adapter]" -# pip install -e ".[testing]" -# pip install -e ".[adapter_testing]" -# - name: Run all tests for codecov -# run: | -# pytest --cov=./slack_bolt/ --cov-report=xml -# - name: Upload coverage to Codecov -# uses: codecov/codecov-action@v3 -# with: -# fail_ci_if_error: true -# verbose: true + - name: Install dependencies + run: | + python setup.py install + pip install -U pip + pip install -e ".[async]" + pip install -e ".[adapter]" + pip install -e ".[testing]" + pip install -e ".[adapter_testing]" + - name: Run all tests for codecov + run: | + pytest --cov=./slack_bolt/ --cov-report=xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: true + verbose: true diff --git a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py index 1877514e0..fa2e7dc6e 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py +++ b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py @@ -9,7 +9,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop from ...adapter_tests.socket_mode.mock_socket_mode_server import ( start_socket_mode_server, stop_socket_mode_server_async, @@ -29,7 +29,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py index 28d24c399..21fef7f02 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py +++ b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py @@ -9,7 +9,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop from ...adapter_tests.socket_mode.mock_socket_mode_server import ( start_socket_mode_server, stop_socket_mode_server_async, @@ -29,7 +29,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/adapter_tests_async/socket_mode/test_async_websockets.py b/tests/adapter_tests_async/socket_mode/test_async_websockets.py index b8574e623..9e6db75ee 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_websockets.py +++ b/tests/adapter_tests_async/socket_mode/test_async_websockets.py @@ -9,7 +9,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop from ...adapter_tests.socket_mode.mock_socket_mode_server import ( start_socket_mode_server, stop_socket_mode_server_async, @@ -29,7 +29,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py index b4a75431f..6a472704c 100644 --- a/tests/adapter_tests_async/test_async_sanic.py +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -17,7 +17,7 @@ cleanup_mock_web_api_server, assert_auth_test_count, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestSanic: @@ -39,7 +39,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_app.py b/tests/scenario_tests_async/test_app.py index 3c6e6d064..c0cf0a7ab 100644 --- a/tests/scenario_tests_async/test_app.py +++ b/tests/scenario_tests_async/test_app.py @@ -19,7 +19,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncApp: @@ -32,7 +32,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_app_actor_user_token.py b/tests/scenario_tests_async/test_app_actor_user_token.py index f0d3ea2e7..b01179200 100644 --- a/tests/scenario_tests_async/test_app_actor_user_token.py +++ b/tests/scenario_tests_async/test_app_actor_user_token.py @@ -23,7 +23,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestApp: @@ -41,7 +41,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_app_bot_only.py b/tests/scenario_tests_async/test_app_bot_only.py index e557a6588..74fb29e8a 100644 --- a/tests/scenario_tests_async/test_app_bot_only.py +++ b/tests/scenario_tests_async/test_app_bot_only.py @@ -22,7 +22,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAppBotOnly: @@ -40,7 +40,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_app_custom_authorize.py b/tests/scenario_tests_async/test_app_custom_authorize.py index 43fab3f6c..8db019150 100644 --- a/tests/scenario_tests_async/test_app_custom_authorize.py +++ b/tests/scenario_tests_async/test_app_custom_authorize.py @@ -26,7 +26,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAppCustomAuthorize: @@ -44,7 +44,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_app_dispatch.py b/tests/scenario_tests_async/test_app_dispatch.py index 34396a2d1..f81ef004d 100644 --- a/tests/scenario_tests_async/test_app_dispatch.py +++ b/tests/scenario_tests_async/test_app_dispatch.py @@ -9,7 +9,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncAppDispatch: @@ -23,7 +23,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_app_installation_store.py b/tests/scenario_tests_async/test_app_installation_store.py index 75e0648de..7a56c81e9 100644 --- a/tests/scenario_tests_async/test_app_installation_store.py +++ b/tests/scenario_tests_async/test_app_installation_store.py @@ -23,7 +23,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestApp: @@ -41,7 +41,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) 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 d188884e7..ce9ffbf1e 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 @@ -18,7 +18,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAppUsingMethodsInClass: @@ -36,7 +36,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_attachment_actions.py b/tests/scenario_tests_async/test_attachment_actions.py index 628e546ab..405b8d8b5 100644 --- a/tests/scenario_tests_async/test_attachment_actions.py +++ b/tests/scenario_tests_async/test_attachment_actions.py @@ -14,7 +14,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncAttachmentActions: @@ -32,7 +32,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_authorize.py b/tests/scenario_tests_async/test_authorize.py index dec9ebc2c..40edaba8e 100644 --- a/tests/scenario_tests_async/test_authorize.py +++ b/tests/scenario_tests_async/test_authorize.py @@ -17,7 +17,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop valid_token = "xoxb-valid" valid_user_token = "xoxp-valid" @@ -66,7 +66,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_block_actions.py b/tests/scenario_tests_async/test_block_actions.py index bfd168f36..cdbb09c9c 100644 --- a/tests/scenario_tests_async/test_block_actions.py +++ b/tests/scenario_tests_async/test_block_actions.py @@ -15,7 +15,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncBlockActions: @@ -33,7 +33,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_block_actions_respond.py b/tests/scenario_tests_async/test_block_actions_respond.py index 87e670fe7..18aef7e10 100644 --- a/tests/scenario_tests_async/test_block_actions_respond.py +++ b/tests/scenario_tests_async/test_block_actions_respond.py @@ -10,7 +10,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncBlockActionsRespond: @@ -27,7 +27,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_block_suggestion.py b/tests/scenario_tests_async/test_block_suggestion.py index 970ae0662..bead06ee8 100644 --- a/tests/scenario_tests_async/test_block_suggestion.py +++ b/tests/scenario_tests_async/test_block_suggestion.py @@ -15,7 +15,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncBlockSuggestion: @@ -33,7 +33,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_dialogs.py b/tests/scenario_tests_async/test_dialogs.py index 740b29546..e5c3a36ef 100644 --- a/tests/scenario_tests_async/test_dialogs.py +++ b/tests/scenario_tests_async/test_dialogs.py @@ -14,7 +14,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncAttachmentActions: @@ -32,7 +32,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_error_handler.py b/tests/scenario_tests_async/test_error_handler.py index 9f9b01c41..056e63d91 100644 --- a/tests/scenario_tests_async/test_error_handler.py +++ b/tests/scenario_tests_async/test_error_handler.py @@ -15,7 +15,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncErrorHandler: @@ -33,7 +33,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_events.py b/tests/scenario_tests_async/test_events.py index 1aac39b26..8220db402 100644 --- a/tests/scenario_tests_async/test_events.py +++ b/tests/scenario_tests_async/test_events.py @@ -18,7 +18,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncEvents: @@ -36,7 +36,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_events_ignore_self.py b/tests/scenario_tests_async/test_events_ignore_self.py index b84589806..83011dfe1 100644 --- a/tests/scenario_tests_async/test_events_ignore_self.py +++ b/tests/scenario_tests_async/test_events_ignore_self.py @@ -10,7 +10,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncEventsIgnoreSelf: @@ -26,7 +26,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_events_org_apps.py b/tests/scenario_tests_async/test_events_org_apps.py index 586eed9ec..890abe053 100644 --- a/tests/scenario_tests_async/test_events_org_apps.py +++ b/tests/scenario_tests_async/test_events_org_apps.py @@ -18,7 +18,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop valid_token = "xoxb-valid" @@ -60,7 +60,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_events_request_verification.py b/tests/scenario_tests_async/test_events_request_verification.py index 2ed570f14..f05f09122 100644 --- a/tests/scenario_tests_async/test_events_request_verification.py +++ b/tests/scenario_tests_async/test_events_request_verification.py @@ -13,7 +13,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncEventsRequestVerification: @@ -28,7 +28,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_events_shared_channels.py b/tests/scenario_tests_async/test_events_shared_channels.py index 8dfb87a4c..4023034e2 100644 --- a/tests/scenario_tests_async/test_events_shared_channels.py +++ b/tests/scenario_tests_async/test_events_shared_channels.py @@ -16,7 +16,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop valid_token = "xoxb-valid" @@ -43,7 +43,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_events_socket_mode.py b/tests/scenario_tests_async/test_events_socket_mode.py index 5ddd8b10c..05a19106d 100644 --- a/tests/scenario_tests_async/test_events_socket_mode.py +++ b/tests/scenario_tests_async/test_events_socket_mode.py @@ -12,7 +12,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncEvents: @@ -28,7 +28,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_events_token_revocations.py b/tests/scenario_tests_async/test_events_token_revocations.py index 5f7bb80a9..a4ded33ee 100644 --- a/tests/scenario_tests_async/test_events_token_revocations.py +++ b/tests/scenario_tests_async/test_events_token_revocations.py @@ -18,7 +18,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop valid_token = "xoxb-valid" @@ -54,7 +54,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_events_url_verification.py b/tests/scenario_tests_async/test_events_url_verification.py index 0c62397e9..91c142c56 100644 --- a/tests/scenario_tests_async/test_events_url_verification.py +++ b/tests/scenario_tests_async/test_events_url_verification.py @@ -13,7 +13,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncEventsUrlVerification: @@ -28,7 +28,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_installation_store_authorize.py b/tests/scenario_tests_async/test_installation_store_authorize.py index 8c2249cc6..0b206910e 100644 --- a/tests/scenario_tests_async/test_installation_store_authorize.py +++ b/tests/scenario_tests_async/test_installation_store_authorize.py @@ -19,7 +19,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop valid_token = "xoxb-valid" valid_user_token = "xoxp-valid" @@ -69,7 +69,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_lazy.py b/tests/scenario_tests_async/test_lazy.py index 50ae02795..55c6041c4 100644 --- a/tests/scenario_tests_async/test_lazy.py +++ b/tests/scenario_tests_async/test_lazy.py @@ -13,7 +13,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncLazy: @@ -31,7 +31,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_listener_middleware.py b/tests/scenario_tests_async/test_listener_middleware.py index bf3914468..29fc085b3 100644 --- a/tests/scenario_tests_async/test_listener_middleware.py +++ b/tests/scenario_tests_async/test_listener_middleware.py @@ -13,7 +13,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncListenerMiddleware: @@ -31,7 +31,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_message.py b/tests/scenario_tests_async/test_message.py index c11c3e8c8..2c8e9bcec 100644 --- a/tests/scenario_tests_async/test_message.py +++ b/tests/scenario_tests_async/test_message.py @@ -15,7 +15,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncMessage: @@ -33,7 +33,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_message_bot.py b/tests/scenario_tests_async/test_message_bot.py index 0dfe8c07e..9aed50efb 100644 --- a/tests/scenario_tests_async/test_message_bot.py +++ b/tests/scenario_tests_async/test_message_bot.py @@ -13,7 +13,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncMessage: @@ -31,7 +31,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_message_changed.py b/tests/scenario_tests_async/test_message_changed.py index 58ba7f597..5be887ac6 100644 --- a/tests/scenario_tests_async/test_message_changed.py +++ b/tests/scenario_tests_async/test_message_changed.py @@ -12,7 +12,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncMessageChanged: @@ -30,7 +30,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_message_deleted.py b/tests/scenario_tests_async/test_message_deleted.py index db444258c..9a80b26bd 100644 --- a/tests/scenario_tests_async/test_message_deleted.py +++ b/tests/scenario_tests_async/test_message_deleted.py @@ -12,7 +12,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncMessageDeleted: @@ -30,7 +30,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_message_file_share.py b/tests/scenario_tests_async/test_message_file_share.py index 4130c6fb7..cfe162b5d 100644 --- a/tests/scenario_tests_async/test_message_file_share.py +++ b/tests/scenario_tests_async/test_message_file_share.py @@ -13,7 +13,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncMessageFileShare: @@ -31,7 +31,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_message_thread_broadcast.py b/tests/scenario_tests_async/test_message_thread_broadcast.py index 84ae3ea93..22245862b 100644 --- a/tests/scenario_tests_async/test_message_thread_broadcast.py +++ b/tests/scenario_tests_async/test_message_thread_broadcast.py @@ -13,7 +13,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncMessageThreadBroadcast: @@ -31,7 +31,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_middleware.py b/tests/scenario_tests_async/test_middleware.py index 10311087e..30e1fc6dd 100644 --- a/tests/scenario_tests_async/test_middleware.py +++ b/tests/scenario_tests_async/test_middleware.py @@ -13,7 +13,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop # Note that async middleware system does not support instance methods n a class. @@ -32,7 +32,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_shortcut.py b/tests/scenario_tests_async/test_shortcut.py index f4f1d6d87..7c1c3df07 100644 --- a/tests/scenario_tests_async/test_shortcut.py +++ b/tests/scenario_tests_async/test_shortcut.py @@ -14,7 +14,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncShortcut: @@ -32,7 +32,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_slash_command.py b/tests/scenario_tests_async/test_slash_command.py index 42e427db0..279cd4b06 100644 --- a/tests/scenario_tests_async/test_slash_command.py +++ b/tests/scenario_tests_async/test_slash_command.py @@ -13,7 +13,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncSlashCommand: @@ -31,7 +31,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_ssl_check.py b/tests/scenario_tests_async/test_ssl_check.py index 651255903..c70d5144d 100644 --- a/tests/scenario_tests_async/test_ssl_check.py +++ b/tests/scenario_tests_async/test_ssl_check.py @@ -11,7 +11,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncSSLCheck: @@ -29,7 +29,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_view_closed.py b/tests/scenario_tests_async/test_view_closed.py index 53c164c53..5fe7ebc88 100644 --- a/tests/scenario_tests_async/test_view_closed.py +++ b/tests/scenario_tests_async/test_view_closed.py @@ -14,7 +14,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncViewClosed: @@ -32,7 +32,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_view_submission.py b/tests/scenario_tests_async/test_view_submission.py index 21ddd3220..e5423c8fd 100644 --- a/tests/scenario_tests_async/test_view_submission.py +++ b/tests/scenario_tests_async/test_view_submission.py @@ -14,7 +14,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop body = { @@ -204,7 +204,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_web_client_customization.py b/tests/scenario_tests_async/test_web_client_customization.py index 6f810b471..9dad00492 100644 --- a/tests/scenario_tests_async/test_web_client_customization.py +++ b/tests/scenario_tests_async/test_web_client_customization.py @@ -16,7 +16,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestWebClientCustomization: @@ -34,7 +34,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_workflow_steps.py b/tests/scenario_tests_async/test_workflow_steps.py index 2108fc519..4cc2a03fe 100644 --- a/tests/scenario_tests_async/test_workflow_steps.py +++ b/tests/scenario_tests_async/test_workflow_steps.py @@ -21,7 +21,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncWorkflowSteps: @@ -39,7 +39,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py b/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py index 40ef93dc4..88c5aefa6 100644 --- a/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py +++ b/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py @@ -20,7 +20,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncWorkflowStepsDecorator: @@ -41,7 +41,7 @@ def event_loop(self): self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) self.app.step(copy_review_step) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py b/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py index 37fc868f3..622c79602 100644 --- a/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py +++ b/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py @@ -21,7 +21,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncWorkflowStepsDecorator: @@ -42,7 +42,7 @@ def event_loop(self): self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) self.app.step(copy_review_step) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/slack_bolt_async/authorization/test_async_authorize.py b/tests/slack_bolt_async/authorization/test_async_authorize.py index 4ca41e14a..ed57d2d24 100644 --- a/tests/slack_bolt_async/authorization/test_async_authorize.py +++ b/tests/slack_bolt_async/authorization/test_async_authorize.py @@ -22,7 +22,7 @@ cleanup_mock_web_api_server, assert_auth_test_count_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop class TestAsyncAuthorize: @@ -36,7 +36,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/slack_bolt_async/context/test_async_respond.py b/tests/slack_bolt_async/context/test_async_respond.py index 9332ecac4..7523c5235 100644 --- a/tests/slack_bolt_async/context/test_async_respond.py +++ b/tests/slack_bolt_async/context/test_async_respond.py @@ -2,6 +2,7 @@ import pytest +from tests.utils import get_event_loop from slack_bolt.context.respond.async_respond import AsyncRespond from tests.mock_web_api_server import ( setup_mock_web_api_server, @@ -13,7 +14,7 @@ class TestAsyncRespond: @pytest.fixture def event_loop(self): setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/slack_bolt_async/context/test_async_say.py b/tests/slack_bolt_async/context/test_async_say.py index d18a96a80..422db2f19 100644 --- a/tests/slack_bolt_async/context/test_async_say.py +++ b/tests/slack_bolt_async/context/test_async_say.py @@ -4,6 +4,7 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse +from tests.utils import get_event_loop from slack_bolt.context.say.async_say import AsyncSay from tests.mock_web_api_server import ( setup_mock_web_api_server, @@ -19,7 +20,7 @@ def event_loop(self): mock_api_server_base_url = "http://localhost:8888" self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py index fcb34db2f..68112f2c0 100644 --- a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py +++ b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py @@ -13,7 +13,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop async def next(): @@ -28,7 +28,7 @@ def event_loop(self): old_os_env = remove_os_env_temporarily() try: setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py b/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py index 07f55337d..1c30cb091 100644 --- a/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py +++ b/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py @@ -2,6 +2,7 @@ from time import time import pytest +from tests.utils import get_event_loop from slack_sdk.signature import SignatureVerifier from slack_bolt.middleware.request_verification.async_request_verification import ( @@ -34,7 +35,7 @@ def build_headers(self, timestamp: str, body: str): @pytest.fixture def event_loop(self): - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index 43ee7b3da..0491b1747 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -4,6 +4,7 @@ from urllib.parse import quote import pytest +from tests.utils import get_event_loop from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore @@ -35,7 +36,7 @@ class TestAsyncOAuthFlow: @pytest.fixture def event_loop(self): setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py index c9190ff55..182fef764 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py @@ -3,6 +3,7 @@ import pytest from slack_sdk.web.async_client import AsyncWebClient +from tests.utils import get_event_loop from slack_bolt import BoltResponse from slack_bolt.oauth.async_callback_options import ( AsyncFailureArgs, @@ -23,7 +24,7 @@ class TestAsyncOAuthFlowSQLite3: @pytest.fixture def event_loop(self): setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() + loop = get_event_loop() yield loop loop.close() cleanup_mock_web_api_server(self) diff --git a/tests/utils.py b/tests/utils.py index e6c75903d..d56ecf900 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,5 @@ import os - +import asyncio def remove_os_env_temporarily() -> dict: old_env = os.environ.copy() @@ -27,3 +27,12 @@ def get_mock_server_mode() -> str: return "threading" else: return mode + +def get_event_loop(): + try: + return asyncio.get_event_loop() + except RuntimeError as ex: + if "There is no current event loop in thread" in str(ex): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop From 17e37661c144b75522c8a7911238be01c2707804 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 6 Jul 2023 19:24:04 -0400 Subject: [PATCH 594/865] Remove beta documentation (#929) --- docs/_config.yml | 3 - docs/_future/concepts.md | 19 -- docs/_future/deploy_your_app.md | 16 -- docs/_future/getting_started_future.md | 145 -------------- .../_future/listening_responding_functions.md | 105 ---------- docs/_future/manifest.md | 181 ------------------ docs/_future/manifest_functions.md | 92 --------- docs/_future/manifest_workflows.md | 159 --------------- docs/_future/setup_existing_app.md | 122 ------------ docs/_includes/sidebar.html | 24 --- docs/_layouts/future.html | 47 ----- docs/assets/style.css | 35 +--- docs/scripts/future_search_hash.js | 17 -- 13 files changed, 1 insertion(+), 964 deletions(-) delete mode 100644 docs/_future/concepts.md delete mode 100644 docs/_future/deploy_your_app.md delete mode 100644 docs/_future/getting_started_future.md delete mode 100644 docs/_future/listening_responding_functions.md delete mode 100644 docs/_future/manifest.md delete mode 100644 docs/_future/manifest_functions.md delete mode 100644 docs/_future/manifest_workflows.md delete mode 100644 docs/_future/setup_existing_app.md delete mode 100644 docs/_layouts/future.html delete mode 100644 docs/scripts/future_search_hash.js diff --git a/docs/_config.yml b/docs/_config.yml index 1ed696b75..a8d7fa15c 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -18,9 +18,6 @@ collections: tutorials: output: true permalink: /tutorials/:slug - future: - output: true - permalink: /future/:slug defaults: - scope: diff --git a/docs/_future/concepts.md b/docs/_future/concepts.md deleted file mode 100644 index 74c83aed1..000000000 --- a/docs/_future/concepts.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: Slack developer beta -lang: en -slug: concepts -order: 0 -layout: future -permalink: /future/concepts ---- -# Slack developer beta concepts BETA - -
    - -This page contains all the concepts that are necessary to allow you to use the next-gen Slack features in Python. - -

    - -Our next-generation platform is currently in beta. Your feedback is most welcome - all feedback will help shape the future platform experience! -

    -
    diff --git a/docs/_future/deploy_your_app.md b/docs/_future/deploy_your_app.md deleted file mode 100644 index ed114a163..000000000 --- a/docs/_future/deploy_your_app.md +++ /dev/null @@ -1,16 +0,0 @@ ---- - -title: Deploy your app -lang: en -slug: deploy-your-app -order: 6 -layout: tutorial -permalink: /tutorial/deploy-your-app ---- -# Deploy your app BETA - -
    -Instructions for deploying your next-generation Bolt Python application to third-party infrastructure are coming soon! Stay tuned. - -

    Our next-generation platform is currently in beta. Your feedback is most welcome - all feedback will help shape the future platform experience!

    -
    diff --git a/docs/_future/getting_started_future.md b/docs/_future/getting_started_future.md deleted file mode 100644 index d33658314..000000000 --- a/docs/_future/getting_started_future.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -title: Getting started -order: 1 -slug: getting-started-future -lang: en -layout: tutorial -permalink: /tutorial/getting-started-future ---- -## Getting started BETA - -
    -This guide will cover how to get started with your next-gen platform using Bolt for Python, by setting up the Slack CLI and installing the required dependencies. - -Find out about the next-generation platform on Slack's official introduction page. -
    - ---- - -### Limitations - -Bolt for Python supports app development using next-gen platform features like Functions, Workflows and tools such as the Slack CLI alongside all current generally available Slack Platform features. - -#### We do not yet support - -- Deployment to secure and managed Slack infrastructure. -- Datastores API Datastores functionality. - -> 💡 If you'd like to deploy your app with Slack infrastructure, consider building your next-generation application with the Deno Slack API. You can get started with that here. - ---- - -### Setting up {#setting-up} - -#### Slack CLI {#setting-up-cli} - -To build a next-generation app with Bolt for Python, you'll need to get the Slack CLI. - -Install the Slack CLI by following this Quickstart. Since we won't be using Deno to build our next-generation app, you can skip any instructions related to installing Deno or creating an app using a Deno template. Once you've logged into the CLI using `slack login` and verified your login using `slack auth list`, you can proceed with the instructions in this guide. - -#### Dependencies {#setting-up-dependencies} - -Once the CLI is set up, make sure your machine has the most recent version of Python installed. You can install Python through a package manager (such as Homebrew for macOS) or directly from the website. - ---- - -### Create a new app {#create-app} - -Before you start developing with Bolt, you'll want to create a Slack app. - -To create the app, you'll run the following command: - -```bash -slack create my-app -t slack-samples/bolt-python-starter-template -b future -``` - -This command creates an app through the CLI by cloning a specified template. In this case, the template is the Bolt for Python Starter Template on the `future` branch. This starter template includes a "Hello World" example that demonstrates how to use built-in and custom Functions, Triggers and Workflows. - -Once the app is successfully created, you should see a message like this: - -```text -✨ my-app successfully created - -🧭 Explore your project's README.md for documentation and code samples, and at any time run slack help to display a list of available commands - -🧑‍🚀 Follow the steps below to try out your new project - -1️⃣ Change into your project directory with: cd my-app - -2️⃣ Develop locally and see changes in real-time with: slack run - -3️⃣ When you're ready to deploy for production use: slack deploy - -🔔 If you leave the workspace, you won’t be able to manage any apps you’ve deployed to it. Apps you deploy will belong to the workspace even if you leave the workspace -``` - ---- - -### Set up your trigger {#setup-trigger} - -As mentioned, this app comes with pre-existing functionality - it uses Functions, Workflows and a Link Trigger that will allow users in Slack to initiate the functionality provided by the app. Let's run a command to initialize that Link Trigger via the CLI. - -First, make sure you're in the project directory in your command line: `cd my-app` - -Then, run the following command to create a Trigger: - -```bash -slack triggers create --trigger-def "triggers/sample-trigger.json" -``` - -The above command will create a Link Trigger for the selected workspace. Make sure to select the workspace you want. Once the trigger is successfully created, you should see an output like this: - -```bash -⚡ Trigger created - Trigger ID: [ID] - Trigger Type: shortcut - Trigger Name: Sample Trigger - URL: https://slack.com/shortcuts/[ID]/[Some ID] -``` - -The provided URL can be pasted into Slack; Slack will unfurl it into a button that users can interact with to initiate your app's functionality! Copy this URL and save it somewhere; you'll need it for later. - ---- - -### Run your app {#run-your-app} - -Now that your app and Trigger are successfully created, let's try running it! - -```bash -# install the required project dependencies -pip install -r requirements.txt - -# start a local development server -slack run -``` - -Executing `pip install -r requirements.txt` installs all the project requirements to your machine. - -Executing `slack run` starts a local development server, syncing changes to your workspace's development version of your app. - -You'll be prompted to select a workspace to install the app to—select the development instance of your workspace (you'll know it's the development version because the name has the string `(dev)` appended). - -> 💡 If you don't see the workspace you'd like to use in the list, you can `CTRL + C` out of the `slack run` command and run `slack auth login`. This will allow you to authenticate in your desired workspace to have it show up in the list for `slack run`. - -You'll see an output in your Terminal to indicate your app is running, similar to what you would see with any other Bolt for Python app. You can search for the `⚡️ Bolt app is running! ⚡️` message to make sure that your app has successfully started up. - -### Trigger your app's workflow {#trigger-workflow} - -With your app running, access your workspace and paste the URL from the Trigger you created in the [previous step](/bolt-python/tutorial/getting-started-future#setup-trigger) into a message in a public channel. - -> 💡 App Triggers are automatically saved as a channel bookmark under "Workflows" for easy access. - -Send the message and click the "Run" button that appears. A modal will appear prompting you to enter information to greet someone in your Slack workspace. Fill out the requested information. - -![Hello World modal](https://slack.dev/bolt-js/assets/hello-world-modal.png "Hello World modal") - -Then, submit the form. In the specified channel submitted in the form, you should receive a message from the app tagging the submitted user. The message will also contain a randomly generated greeting and the message you wrote in the form. - -The full app flow can be seen here: -![Hello World app](https://slack.dev/bolt-js/assets/hello-world-demo.gif "Hello World app") - ---- - -### Next steps {#next-steps} - -Now we have a working instance of a next-generation app in your workspace and you've seen it in action! You can explore on your own and dive into the code yourself here or continue your learning journey by diving into [App Manifests](/bolt-python/future/concepts#manifests) or looking into adding more [Functions](/bolt-python/future/concepts#functions), [Workflows](/bolt-python/future/concepts#manifest-workflows), and [Triggers](#setup-trigger) to your app! diff --git a/docs/_future/listening_responding_functions.md b/docs/_future/listening_responding_functions.md deleted file mode 100644 index b7c13c692..000000000 --- a/docs/_future/listening_responding_functions.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: Listening & responding to functions -lang: en -slug: functions -order: 5 -layout: future ---- - -
    - -Your app can use the `function()` method to listen to incoming function requests. The method requires a function `callback_id` of type `str`. This `callback_id` must also be defined in your [Function](/bolt-python/future/concepts#manifest-functions) definition. Functions must eventually be completed with the `complete()` function to inform Slack that your app has processed the function request. `complete()` requires **one of two** keyword arguments: `outputs` or `error`. There are two ways to complete a Function with `complete()`: - -* `outputs` of type `dict` completes your function **successfully** and provides a dictionary containing the outputs of your function as defined in the app's manifest. -* `error` of type `str` completes your function **unsuccessfully** and provides a message containing information regarding why your function was not successful. - -
    - -
    -Refer to the module document to learn the available listener arguments. -```python -# The sample function simply outputs an input -@app.function("sample_function") -def sample_func(event: dict, complete: Complete): - try: - message = event["inputs"]["message"] - complete( - outputs={ - "updatedMsg": f":wave: You submitted the following message: \n\n>{message}" - } - ) - except Exception as e: - complete(error=f"Cannot submit the message: {e}") - raise e -``` -
    - -
    - -

    Function Interactivity

    -
    - -
    - -The `function()` method returns a `SlackFunction` decorator object. This object can be used by your app to set up interactive listeners such as [actions](/bolt-python/concepts#action-respond) and [views](/bolt-python/concepts#view_submissions). These listeners listen to events created during the handling of your `function` event. Additionally, they will only be called when a user interacts with a block element that has the following attributes: - -* It was created during the handling of a `function` event. -* The `action_id` matches the interactive listeners `action_id`. - -These listeners behave similarly to the ones assigned directly to your app. The notable difference is that `complete()` must be called once your function is completed. - -
    - -```python -# Your listener will be called when your function "sample_function" is triggered from a workflow -# When triggered a message containing a button with an action_id "approve_button" is posted -@app.function("sample_function") -def sample_func(event: dict, complete: Complete): - try: - client.chat_postMessage( - channel="a-channel-id", - text="A new button appears", - blocks=[ - { - "type": "actions", - "block_id": "approve-button", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": "Click", - }, - "action_id": "sample_action", - "style": "primary", - }, - ], - }, - ], - ) - except Exception as e: - complete(error=f"Cannot post the message: {e}") - raise e - -# Your listener will be called when a block element -# - Created by your "sample_func" -# - With the action_id "sample_action" -# is triggered -@sample_func.action("sample_action") -def update_message(ack, body, client, complete): - try: - ack() - if "container" in body and "message_ts" in body["container"]: - client.reactions_add( - name="white_check_mark", - channel=body["channel"]["id"], - timestamp=body["container"]["message_ts"], - ) - complete() - except Exception as e: - logger.error(e) - complete(error=f"Cannot react to message: {e}") - raise e -``` - -
    diff --git a/docs/_future/manifest.md b/docs/_future/manifest.md deleted file mode 100644 index 26b77f28a..000000000 --- a/docs/_future/manifest.md +++ /dev/null @@ -1,181 +0,0 @@ ---- -title: Manifest -lang: en -slug: manifest -order: 2 -layout: future ---- - - -
    - -Your project should contain a `manifest.json` file that defines your app's manifest. This is where you'll configure your application name and scopes, declare the functions your app will use, and more. -Refer to the App Manifest and Manifest Property documentation to learn -about the available manifest configurations. - -Notably, the App Manifest informs Slack of the definitions for: - -* [Functions](/bolt-python/future/concepts#manifest-functions) -* [Workflows](/bolt-python/future/concepts#manifest-workflows) - -`manifest.json` is located at the top level of our example projects, along with a `triggers` folder containing -`*_trigger.json` files that define the triggers -for your app. - -
    -.
    -├── ...
    -├── manifest.json             # app manifest definition
    -├── triggers                  # folder with trigger files
    -│   ├── sample_trigger.json   # trigger definition
    -│   └── ...
    -└── ...
    -
    - -
    - -#### Linting, prediction & validation - -Syntax and formatting checks are required to efficiently edit your `manifest.json`. Multiple IDEs are able to support -this, namely: Visual Studio Code, Pycharm, Sublime Text (via LSP-json) and many more. - -To get **manifest prediction & validation** in your IDE, include the following line in your `manifest.json` file: - -```json -{ - "$schema": "https://raw.githubusercontent.com/slackapi/manifest-schema/main/manifest.schema.json", - ... -} -``` - -Using the Slack CLI you can validate your `manifest.json` against the Slack API with: - -```bash -slack manifest validate -``` - -
    - -
    -Refer to our template project to view a full version of manifest.json. -```json -{ - "$schema": "https://raw.githubusercontent.com/slackapi/manifest-schema/main/manifest.schema.json", - "_metadata": { - "major_version": 2, - }, - "display_information": { - "name": "Bolt Template App TEST" - }, - "features": { - "app_home": { - "home_tab_enabled": false, - }, - "bot_user": { - "display_name": "Bolt Template App TEST", - "always_online": false - } - }, - "oauth_config": { - "scopes": { - "bot": [ - "chat:write", - ] - } - }, - "settings": { - "socket_mode_enabled": true, - }, - "functions": {}, - "types": {}, - "workflows": {}, - "outgoing_domains": [] -} -``` -
    - -
    - - -

    Common Manifest Types

    -
    - -
    -
    - - - - - - - - - - - - - - - - - -
    parameters
    object
    propertiespropertiesdefines the properties
    requiredlist[string]defines the properties required by the function
    - - - - - - - - - - - - - - - - - -
    properties
    dictionary
    keystringdefines the property name
    valuepropertydefines the property
    - - - - - - - - - - - - - - - - - -
    property
    object
    typestringdefines the property type
    descriptionstringdefines the property description
    -
    - -```json -"$comment": "sample parameters object" -"*_parameters":{ - "properties": { - "property_0_name": { - "type": "string", - "description": "this is my first property" - }, - "property_1_name": { - "type": "integer", - "description": "this is my second property" - } - }, - "required": [ - "property_0_name" - ] -} -``` - -
    -
    diff --git a/docs/_future/manifest_functions.md b/docs/_future/manifest_functions.md deleted file mode 100644 index 7c1f7aa0b..000000000 --- a/docs/_future/manifest_functions.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: Manifest functions -order: 3 -slug: manifest-functions -lang: en -layout: future ---- - -
    -Your app can [invoke Functions](/bolt-python/future/concepts#functions) defined and created by you (Custom Functions). In order for this to work, Slack must know they exist. Define them in your [App Manifest](/bolt-python/concepts#manifest) also known as `manifest.json` in your project. The next time you `slack run` your app will inform Slack they exist. - - - - - - - - - - - - - - - - - -
    functions
    dictionary
    keystringdefines the function's callback_id
    valuefunctiondefines the function
    - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    function
    object
    titlestringdefines the title
    descriptionstringdefines the description
    input_parametersparametersdefines the inputs
    output_parametersparametersdefines the outputs
    - -
    - -
    -Refer to our template project to view a full version of manifest.json. -```json - "functions": { - "sample_function": { - "title": "Sample function", - "description": "A sample function", - "input_parameters": { - "properties": { - "message": { - "type": "string", - "description": "Message to be posted" - } - }, - "required": [ - "message" - ] - }, - "output_parameters": { - "properties": { - "updatedMsg": { - "type": "string", - "description": "Updated message to be posted" - } - }, - "required": [ - "updatedMsg" - ] - } - } - } -``` -
    diff --git a/docs/_future/manifest_workflows.md b/docs/_future/manifest_workflows.md deleted file mode 100644 index 76aa17db5..000000000 --- a/docs/_future/manifest_workflows.md +++ /dev/null @@ -1,159 +0,0 @@ ---- -title: Manifest workflows -order: 4 -slug: manifest-workflows -lang: en -layout: future ---- - -
    - -Your app can use Functions by referencing them in Workflows. Your Custom Functions and the Built-in Functions can be used as steps in Workflow definitions. - -Workflows are invoked by Triggers. You will need to set up a Trigger in order to use your defined workflows. Triggers, Workflows, and Functions work together in the following way: - -Trigger → Workflow → Workflow Step → Function - -Your App Manifest, found at `manifest.json`, is where you will define your workflows. - - - - - - - - - - - - - - - - - -
    workflows
    dictionary
    keystringdefines the workflow's id
    valueworkflowdefines the function
    - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    workflow
    object
    titlestringdefines the title
    descriptionstringdefines the description
    input_parametersparametersdefines the inputs
    stepslist[parameters]defines the steps
    - - - - - - - - - - - - - - - - - - - - - - -
    step
    object
    idstringdefines the order of the steps
    function_idstringidentifies the function to evoke
    inputsdict[string:string]defines the inputs to provide to the function
    - -
    - -
    -Refer to our template project to view a full version of manifest.json. -```json - "workflows": { - "sample_workflow": { - "title": "Sample workflow", - "description": "A sample workflow", - "input_parameters": { - "properties": { - "channel": { - "type": "slack#/types/channel_id" - } - }, - "required": [ - "channel" - ] - }, - "steps": [ - { - "id": "0", - "function_id": "#/functions/sample_function", - "inputs": { - "message": "{{inputs.channel}}" - } - }, - { - "id": "1", - "function_id": "slack#/functions/send_message", - "inputs": { - "channel_id": "{{inputs.channel}}", - "message": "{{steps.0.updatedMsg}}" - } - } - ] - } - } -``` -
    - -
    - - -

    Built-in functions

    -
    - -
    -Slack provides built-in functions that can be used by a Workflow to accomplish simple tasks. You can add these functions to your workflow steps in order to use them. - -- Send message -- Open a form -- Create channel - -Refer to the built-in functions document to learn about the available built-in functions. -
    - -```json - "$comment": "A step to post the user name to a channel" - "steps": [ - { - "id": "0", - "function_id": "slack#/functions/send_message", - "inputs": { - "channel_id": "{{inputs.channel}}", - "message": "{{inputs.user_name}}" - } - } - ] -``` - -
    diff --git a/docs/_future/setup_existing_app.md b/docs/_future/setup_existing_app.md deleted file mode 100644 index 2e93253bf..000000000 --- a/docs/_future/setup_existing_app.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -title: Setup an existing app -order: 7 -slug: setup-existing-app -lang: en -layout: tutorial -permalink: /tutorial/setup-existing-app ---- - -## Setup an existing app BETA - -
    -If you would like to setup an existing Slack app written with the beta tools from the next-generation platform, this guide is for you! -
    - -To get started with a new Bolt for Python application take a look at this [Getting Started guide](/bolt-python/tutorial/getting-started-future) instead. - ---- - -### Prerequisites {#prerequisites} - -Before we get started, make sure you've followed the [Setting Up step](/bolt-python/tutorial/getting-started-future#setting-up) of the [Getting Started guide](/bolt-python/tutorial/getting-started-future) to install required dependencies. - ---- - -### Set up your app to work with the Slack CLI {#setup-with-cli} - -Update your project's version of Bolt (sometimes found in requirements.txt) to the latest `*.dev*` distribution and reinstall your dependencies: `pip install -r requirements.txt` - -```text -# with pip -pip install slack-bolt==*.dev* - -# in requirements.txt -slack-bolt==*.dev* -``` - -Then, add a `slack.json` file to your local project root containing the contents of our [template slack.json](https://github.com/slack-samples/bolt-python-starter-template/blob/future/slack.json). - ---- - -### Add your manifest {#manifest-in-code} - -Head to [your app's App Config Page](https://api.slack.com/apps) and navigate to Features > App Manifest. Download a copy of your app manifest in the JSON file format. - -Add this `manifest.json` to your project root. This represents your project's existing configuration. To get **manifest prediction & validation** in your IDE, include the following line in your `manifest.json` file: - -```json -{ - "$schema": "https://raw.githubusercontent.com/slackapi/manifest-schema/main/manifest.schema.json", - ... -} -``` - -To learn more about the `manifest.json` take a look at the [Manifest concept](/bolt-python/future/concepts#manifest). - ---- - -Now let's run the Slack CLI command `slack manifest` to generate your manifest. It should contain at least these settings: - -```bash -{ - "_metadata": { - "major_version": 2 - }, - "oauth_config": { - "token_management_enabled": true - }, - "settings": { - "interactivity": { - "is_enabled": true - } - }, - "org_deploy_enabled": true -} -``` - -You can also run this command to validate your App's configuration with the Slack API: - -```bash -slack manifest validate -``` - ---- - -### Run your app! {#tada} - -Run the Slack CLI command `slack run` to start your app in local development. - -The CLI will create and install a new development app for you with its own App ID, allowing you to keep your testing changes separate from your production App). - -Now you're ready to start adding [Functions](/bolt-python/future/concepts#functions) and [Workflows](/bolt-python/future/concepts#manifest-workflows) to your app! - ---- - -### Updating your app configuration {#update-app} - -You have probably made changes to your app’s manifest (adding a Function or a Workflow, for example). To sync your production app’s configuration with the changes you’ve made locally in your manifest: - -1. Authenticate the Slack CLI with your desired production workspace using `slack login`. -2. In your project, head over to `./slack/apps.json` and make sure an entry exists for your workspace with the current `app_id` and `team_id` of the workspace. - - ```bash - { - "apps": { - "": { - "name": "", - "app_id": "A041G4M3U00", - "team_id": "T038J6TH5PF" - } - }, - "default": "" - } - ``` - -3. Run `slack install` and select your app. Select your workspace from the list prompt to install. - ---- - -### Conclusion {#conclusion} - -Congratulations on migrating your app to the next-generation Slack Platform! 🎉 You can continue your journey by learning about [Manifests](/bolt-python/future/concepts#manifest) or looking into adding [Functions](/bolt-python/future/concepts#functions) and [Workflows](/bolt-python/future/concepts#manifest-workflows) to your app! diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html index 5e9fa141d..970cf3869 100644 --- a/docs/_includes/sidebar.html +++ b/docs/_includes/sidebar.html @@ -41,30 +41,6 @@ {% endfor %} - -
    diff --git a/docs/api-docs/slack_bolt/context/respond/respond.html b/docs/api-docs/slack_bolt/context/respond/respond.html index 75d6b2bdf..8e6f551dd 100644 --- a/docs/api-docs/slack_bolt/context/respond/respond.html +++ b/docs/api-docs/slack_bolt/context/respond/respond.html @@ -26,7 +26,7 @@

    Module slack_bolt.context.respond.respond

    Expand source code -
    from typing import Optional, Union, Sequence
    +
    from typing import Optional, Union, Sequence, Any, Dict
     from ssl import SSLContext
     
     from slack_sdk.models.attachments import Attachment
    @@ -63,6 +63,7 @@ 

    Module slack_bolt.context.respond.respond

    unfurl_links: Optional[bool] = None, unfurl_media: Optional[bool] = None, thread_ts: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> WebhookResponse: if self.response_url is not None: client = WebhookClient( @@ -83,6 +84,7 @@

    Module slack_bolt.context.respond.respond

    unfurl_links=unfurl_links, unfurl_media=unfurl_media, thread_ts=thread_ts, + metadata=metadata, ) return client.send_dict(message) elif isinstance(text_or_whole_response, dict): @@ -140,6 +142,7 @@

    Classes

    unfurl_links: Optional[bool] = None, unfurl_media: Optional[bool] = None, thread_ts: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> WebhookResponse: if self.response_url is not None: client = WebhookClient( @@ -160,6 +163,7 @@

    Classes

    unfurl_links=unfurl_links, unfurl_media=unfurl_media, thread_ts=thread_ts, + metadata=metadata, ) return client.send_dict(message) elif isinstance(text_or_whole_response, dict): diff --git a/docs/api-docs/slack_bolt/index.html b/docs/api-docs/slack_bolt/index.html index 3e741d194..2578ab1f2 100644 --- a/docs/api-docs/slack_bolt/index.html +++ b/docs/api-docs/slack_bolt/index.html @@ -3543,7 +3543,7 @@

    Inherited members

    class BoltRequest -(*, body: Union[str, dict], query: Union[str, Dict[str, str], Dict[str, Sequence[str]], ForwardRef(None)] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, str]] = None, mode: str = 'http') +(*, body: Union[str, dict], query: Union[str, Dict[str, str], Dict[str, Sequence[str]], ForwardRef(None)] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, Any]] = None, mode: str = 'http')

    Request to a Bolt app.

    @@ -3581,7 +3581,7 @@

    Args

    body: Union[str, dict], query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, - context: Optional[Dict[str, str]] = None, + context: Optional[Dict[str, Any]] = None, mode: str = "http", # either "http" or "socket_mode" ): """Request to a Bolt app. @@ -4132,6 +4132,7 @@

    Returns

    unfurl_links: Optional[bool] = None, unfurl_media: Optional[bool] = None, thread_ts: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> WebhookResponse: if self.response_url is not None: client = WebhookClient( @@ -4152,6 +4153,7 @@

    Returns

    unfurl_links=unfurl_links, unfurl_media=unfurl_media, thread_ts=thread_ts, + metadata=metadata, ) return client.send_dict(message) elif isinstance(text_or_whole_response, dict): diff --git a/docs/api-docs/slack_bolt/listener_matcher/builtins.html b/docs/api-docs/slack_bolt/listener_matcher/builtins.html index 40ff38194..b4d38bcdf 100644 --- a/docs/api-docs/slack_bolt/listener_matcher/builtins.html +++ b/docs/api-docs/slack_bolt/listener_matcher/builtins.html @@ -320,7 +320,7 @@

    Module slack_bolt.listener_matcher.builtins

    return workflow_step_edit(constraints["callback_id"], asyncio) raise BoltError(f"type: {action_type} is unsupported") - elif "action_id" in constraints: + elif "action_id" in constraints or "block_id" in constraints: # The default value is "block_actions" return block_action(constraints, asyncio) @@ -341,8 +341,11 @@

    Module slack_bolt.listener_matcher.builtins

    elif isinstance(constraints, dict): # block_id matching is optional block_id: Optional[Union[str, Pattern]] = constraints.get("block_id") + action_id: Optional[Union[str, Pattern]] = constraints.get("action_id") + if block_id is None and action_id is None: + return False block_id_matched = block_id is None or _matches(block_id, action.get("block_id")) - action_id_matched = _matches(constraints["action_id"], action["action_id"]) + action_id_matched = action_id is None or _matches(action_id, action.get("action_id")) return block_id_matched and action_id_matched @@ -608,7 +611,7 @@

    Functions

    return workflow_step_edit(constraints["callback_id"], asyncio) raise BoltError(f"type: {action_type} is unsupported") - elif "action_id" in constraints: + elif "action_id" in constraints or "block_id" in constraints: # The default value is "block_actions" return block_action(constraints, asyncio) diff --git a/docs/api-docs/slack_bolt/request/async_request.html b/docs/api-docs/slack_bolt/request/async_request.html index 3fc5d0f53..4d50490f2 100644 --- a/docs/api-docs/slack_bolt/request/async_request.html +++ b/docs/api-docs/slack_bolt/request/async_request.html @@ -57,7 +57,7 @@

    Module slack_bolt.request.async_request

    body: Union[str, dict], query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, - context: Optional[Dict[str, str]] = None, + context: Optional[Dict[str, Any]] = None, mode: str = "http", # either "http" or "socket_mode" ): """Request to a Bolt app. @@ -122,7 +122,7 @@

    Classes

    class AsyncBoltRequest -(*, body: Union[str, dict], query: Union[str, Dict[str, str], Dict[str, Sequence[str]], ForwardRef(None)] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, str]] = None, mode: str = 'http') +(*, body: Union[str, dict], query: Union[str, Dict[str, str], Dict[str, Sequence[str]], ForwardRef(None)] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, Any]] = None, mode: str = 'http')

    Request to a Bolt app.

    @@ -160,7 +160,7 @@

    Args

    body: Union[str, dict], query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, - context: Optional[Dict[str, str]] = None, + context: Optional[Dict[str, Any]] = None, mode: str = "http", # either "http" or "socket_mode" ): """Request to a Bolt app. diff --git a/docs/api-docs/slack_bolt/request/index.html b/docs/api-docs/slack_bolt/request/index.html index 8a13f1d2b..7b55dba26 100644 --- a/docs/api-docs/slack_bolt/request/index.html +++ b/docs/api-docs/slack_bolt/request/index.html @@ -76,7 +76,7 @@

    Classes

    class BoltRequest -(*, body: Union[str, dict], query: Union[str, Dict[str, str], Dict[str, Sequence[str]], ForwardRef(None)] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, str]] = None, mode: str = 'http') +(*, body: Union[str, dict], query: Union[str, Dict[str, str], Dict[str, Sequence[str]], ForwardRef(None)] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, Any]] = None, mode: str = 'http')

    Request to a Bolt app.

    @@ -114,7 +114,7 @@

    Args

    body: Union[str, dict], query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, - context: Optional[Dict[str, str]] = None, + context: Optional[Dict[str, Any]] = None, mode: str = "http", # either "http" or "socket_mode" ): """Request to a Bolt app. diff --git a/docs/api-docs/slack_bolt/request/request.html b/docs/api-docs/slack_bolt/request/request.html index eccb7c695..59a5a7acb 100644 --- a/docs/api-docs/slack_bolt/request/request.html +++ b/docs/api-docs/slack_bolt/request/request.html @@ -57,7 +57,7 @@

    Module slack_bolt.request.request

    body: Union[str, dict], query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, - context: Optional[Dict[str, str]] = None, + context: Optional[Dict[str, Any]] = None, mode: str = "http", # either "http" or "socket_mode" ): """Request to a Bolt app. @@ -121,7 +121,7 @@

    Classes

    class BoltRequest -(*, body: Union[str, dict], query: Union[str, Dict[str, str], Dict[str, Sequence[str]], ForwardRef(None)] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, str]] = None, mode: str = 'http') +(*, body: Union[str, dict], query: Union[str, Dict[str, str], Dict[str, Sequence[str]], ForwardRef(None)] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, context: Optional[Dict[str, Any]] = None, mode: str = 'http')

    Request to a Bolt app.

    @@ -159,7 +159,7 @@

    Args

    body: Union[str, dict], query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None, headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, - context: Optional[Dict[str, str]] = None, + context: Optional[Dict[str, Any]] = None, mode: str = "http", # either "http" or "socket_mode" ): """Request to a Bolt app. diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index eccade944..19a0d5d64 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

    Module slack_bolt.version

    Expand source code
    """Check the latest version at https://pypi.org/project/slack-bolt/"""
    -__version__ = "1.18.0"
    +__version__ = "1.18.1"
    diff --git a/setup.py b/setup.py index e56410465..474bd8da0 100755 --- a/setup.py +++ b/setup.py @@ -58,7 +58,7 @@ ), include_package_data=True, # MANIFEST.in install_requires=[ - "slack_sdk>=3.21.2,<4", + "slack_sdk>=3.25.0,<4", ], setup_requires=["pytest-runner==5.2"], tests_require=async_test_dependencies, @@ -109,6 +109,7 @@ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", diff --git a/slack_bolt/version.py b/slack_bolt/version.py index dfa1bca7a..2dc804083 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.18.0" +__version__ = "1.18.1" From 9209e7bc5c7f44d8910657ba4134232a8b6a1935 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 21 Nov 2023 17:25:17 -0500 Subject: [PATCH 601/865] Improve socket mode tests (#991) --- .../socket_mode/mock_socket_mode_server.py | 19 +++++++++++++++++-- .../socket_mode/test_interactions_builtin.py | 1 - .../test_interactions_websocket_client.py | 1 - .../socket_mode/test_lazy_listeners.py | 1 - .../socket_mode/test_async_aiohttp.py | 1 - .../socket_mode/test_async_lazy_listeners.py | 1 - .../socket_mode/test_async_websockets.py | 1 - 7 files changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py index 13c139d63..577698188 100644 --- a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py +++ b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py @@ -1,9 +1,11 @@ +import json import logging import sys import threading import time +import requests from multiprocessing.context import Process -from typing import List, Optional +from typing import List from unittest import TestCase from tests.utils import get_mock_server_mode @@ -22,6 +24,11 @@ def start_thread_socket_mode_server(test: TestCase, port: int): def _start_thread_socket_mode_server(): logger = logging.getLogger(__name__) app: Flask = Flask(__name__) + + @app.route("/state") + def state(): + return json.dumps({"success": True}), 200, {"ContentType": "application/json"} + sockets: Sockets = Sockets(app) envelopes_to_consume: List[str] = list(socket_mode_envelopes) @@ -81,12 +88,20 @@ def start_socket_mode_server(test, port: int): test.sm_thread = threading.Thread(target=start_thread_socket_mode_server(test, port)) test.sm_thread.daemon = True test.sm_thread.start() - time.sleep(2) # wait for the server + wait_for_socket_mode_server(port, 2) # wait for the server else: test.sm_process = Process(target=start_process_socket_mode_server, kwargs={"port": port}) test.sm_process.start() +def wait_for_socket_mode_server(port: int, secs: int): + start_time = time.time() + while (time.time() - start_time) < secs: + response = requests.get(url=f"http://localhost:{port}/state") + if response.ok: + break + + def stop_socket_mode_server(test): if get_mock_server_mode() == "threading": print(test) diff --git a/tests/adapter_tests/socket_mode/test_interactions_builtin.py b/tests/adapter_tests/socket_mode/test_interactions_builtin.py index 4730ece81..99aac3171 100644 --- a/tests/adapter_tests/socket_mode/test_interactions_builtin.py +++ b/tests/adapter_tests/socket_mode/test_interactions_builtin.py @@ -27,7 +27,6 @@ def setup_method(self): base_url="http://localhost:8888", ) start_socket_mode_server(self, 3011) - time.sleep(2) # wait for the server def teardown_method(self): cleanup_mock_web_api_server(self) diff --git a/tests/adapter_tests/socket_mode/test_interactions_websocket_client.py b/tests/adapter_tests/socket_mode/test_interactions_websocket_client.py index 39d461570..ccaa89d3e 100644 --- a/tests/adapter_tests/socket_mode/test_interactions_websocket_client.py +++ b/tests/adapter_tests/socket_mode/test_interactions_websocket_client.py @@ -27,7 +27,6 @@ def setup_method(self): base_url="http://localhost:8888", ) start_socket_mode_server(self, 3012) - time.sleep(2) # wait for the server def teardown_method(self): cleanup_mock_web_api_server(self) diff --git a/tests/adapter_tests/socket_mode/test_lazy_listeners.py b/tests/adapter_tests/socket_mode/test_lazy_listeners.py index 441dda3f5..5acd288f4 100644 --- a/tests/adapter_tests/socket_mode/test_lazy_listeners.py +++ b/tests/adapter_tests/socket_mode/test_lazy_listeners.py @@ -27,7 +27,6 @@ def setup_method(self): base_url="http://localhost:8888", ) start_socket_mode_server(self, 3011) - time.sleep(2) # wait for the server def teardown_method(self): cleanup_mock_web_api_server(self) diff --git a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py index fa2e7dc6e..6e8ce40de 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py +++ b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py @@ -39,7 +39,6 @@ def event_loop(self): @pytest.mark.asyncio async def test_events(self): start_socket_mode_server(self, 3021) - await asyncio.sleep(1) # wait for the server app = AsyncApp(client=self.web_client) diff --git a/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py index 21fef7f02..6709d588e 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py +++ b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py @@ -39,7 +39,6 @@ def event_loop(self): @pytest.mark.asyncio async def test_lazy_listeners(self): start_socket_mode_server(self, 3021) - await asyncio.sleep(1) # wait for the server app = AsyncApp(client=self.web_client) diff --git a/tests/adapter_tests_async/socket_mode/test_async_websockets.py b/tests/adapter_tests_async/socket_mode/test_async_websockets.py index 9e6db75ee..19167e635 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_websockets.py +++ b/tests/adapter_tests_async/socket_mode/test_async_websockets.py @@ -39,7 +39,6 @@ def event_loop(self): @pytest.mark.asyncio async def test_events(self): start_socket_mode_server(self, 3022) - await asyncio.sleep(1) # wait for the server app = AsyncApp(client=self.web_client) From 90467fb94a3277b77f2a8174fbc4ecd6d6fff6e5 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 29 Nov 2023 14:06:22 -0500 Subject: [PATCH 602/865] Configuring with pyproject.toml (#996) * move configuration to pyproject.toml Co-authored-by: Kazuhiro Sera --- .github/maintainers_guide.md | 4 +- .github/workflows/codecov.yml | 10 +- .github/workflows/tests.yml | 10 +- pyproject.toml | 55 ++++++++++- pytest.ini | 9 -- requirements.txt | 1 + requirements/adapter.txt | 22 +++++ requirements/adapter_testing.txt | 8 ++ requirements/async.txt | 4 + requirements/testing.txt | 6 ++ requirements/testing_without_asyncio.txt | 9 ++ scripts/build_pypi_package.sh | 6 +- scripts/deploy_to_pypi_org.sh | 4 +- scripts/deploy_to_test_pypi_org.sh | 4 +- scripts/install_all_and_run_tests.sh | 24 ++--- scripts/run_pytype.sh | 5 +- scripts/run_tests.sh | 4 +- setup.cfg | 5 - setup.py | 118 ----------------------- 19 files changed, 139 insertions(+), 169 deletions(-) delete mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 requirements/adapter.txt create mode 100644 requirements/adapter_testing.txt create mode 100644 requirements/async.txt create mode 100644 requirements/testing.txt create mode 100644 requirements/testing_without_asyncio.txt delete mode 100644 setup.cfg delete mode 100755 setup.py diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index 6d3ab7ff1..97c91372c 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -71,8 +71,8 @@ If you make changes to `slack_bolt/adapter/*`, please verify if it surely works ```bash # Install all optional dependencies -$ pip install -e ".[adapter]" -$ pip install -e ".[adapter_testing]" +$ pip install -r requirements/adapter.txt +$ pip install -r requirements/adapter_testing.txt # Set required env variables $ export SLACK_SIGNING_SECRET=*** diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 6d1ab0e52..4839962ef 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -26,12 +26,12 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python setup.py install pip install -U pip - pip install -e ".[async]" - pip install -e ".[adapter]" - pip install -e ".[testing]" - pip install -e ".[adapter_testing]" + pip install . + pip install -r requirements/async.txt + pip install -r requirements/adapter.txt + pip install -r requirements/testing.txt + pip install -r requirements/adapter_testing.txt - name: Run all tests for codecov run: | pytest --cov=./slack_bolt/ --cov-report=xml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 11a38d743..fcdc053b8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,17 +25,17 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python setup.py install pip install -U pip - pip install -e ".[testing_without_asyncio]" + pip install -r requirements.txt + pip install -r requirements/testing_without_asyncio.txt - name: Run tests without aiohttp run: | pytest tests/slack_bolt/ pytest tests/scenario_tests/ - name: Run tests for Socket Mode adapters run: | - pip install -e ".[adapter]" - pip install -e ".[adapter_testing]" + pip install -r requirements/adapter.txt + pip install -r requirements/adapter_testing.txt pytest tests/adapter_tests/socket_mode/ - name: Run tests for HTTP Mode adapters (AWS) run: | @@ -74,7 +74,7 @@ jobs: pytest tests/adapter_tests/tornado/ - name: Run tests for HTTP Mode adapters (asyncio-based libraries) run: | - pip install -e ".[async]" + pip install -r requirements/async.txt # Falcon supports Python 3.11 since its v3.1.1 pip install "falcon>=3.1.1,<4" pytest tests/adapter_tests_async/ diff --git a/pyproject.toml b/pyproject.toml index 0cda914cd..0292636ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,53 @@ -# black project prefers pyproject.toml -# that's why we have this file in addition to other setting files +[build-system] +requires = ["setuptools", "pytest-runner==5.2", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "slack_bolt" +dynamic = ["version", "readme", "dependencies"] +description = "The Bolt Framework for Python" +license = { text = "MIT" } +authors = [{ name = "Slack Technologies, LLC", email = "opensource@slack.com" }] +classifiers = [ + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +requires-python = ">=3.6" + + +[project.urls] +homepage = "https://github.com/slackapi/bolt-python" + +[tool.setuptools.packages.find] +include = ["slack_bolt*"] + +[tool.setuptools.dynamic] +version = { attr = "slack_bolt.version.__version__" } +readme = { file = ["README.md"], content-type = "text/markdown" } +dependencies = { file = ["requirements.txt"] } + +[tool.distutils.bdist_wheel] +universal = true + [tool.black] -line-length = 125 \ No newline at end of file +line-length = 125 + +[tool.pytest.ini_options] +testpaths = ["tests"] +log_file = "logs/pytest.log" +log_file_level = "DEBUG" +log_format = "%(asctime)s %(levelname)s %(message)s" +log_date_format = "%Y-%m-%d %H:%M:%S" +filterwarnings = [ + "ignore:\"@coroutine\" decorator is deprecated since Python 3.8, use \"async def\" instead:DeprecationWarning", + "ignore:The loop argument is deprecated since Python 3.8, and scheduled for removal in Python 3.10.:DeprecationWarning", +] +asyncio_mode = "auto" diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 27f7ad257..000000000 --- a/pytest.ini +++ /dev/null @@ -1,9 +0,0 @@ -[pytest] -log_file = logs/pytest.log -log_file_level = DEBUG -log_format = %(asctime)s %(levelname)s %(message)s -log_date_format = %Y-%m-%d %H:%M:%S -filterwarnings = - ignore:"@coroutine" decorator is deprecated since Python 3.8, use "async def" instead:DeprecationWarning - ignore:The loop argument is deprecated since Python 3.8, and scheduled for removal in Python 3.10.:DeprecationWarning -asyncio_mode = auto \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..4d4e8fe15 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +slack_sdk>=3.25.0,<4 diff --git a/requirements/adapter.txt b/requirements/adapter.txt new file mode 100644 index 000000000..81bbd5f12 --- /dev/null +++ b/requirements/adapter.txt @@ -0,0 +1,22 @@ +# pip install -r requirements/adapter.txt +# NOTE: any of async ones requires pip install -r requirements/async.txt too +# used only under slack_bolt/adapter +boto3<=2 +bottle>=0.12,<1 +chalice<=1.27.3; python_version=="3.6" +chalice>=1.28,<2; python_version>"3.6" +CherryPy>=18,<19 +Django>=3,<5 +falcon>=2,<4; python_version<"3.11" +falcon>=3.1.1,<4; python_version>="3.11" +fastapi>=0.70.0,<1 +Flask>=1,<3 +Werkzeug>=2,<3 +pyramid>=1,<3 +sanic>=20,<21; python_version=="3.6" +sanic>=22,<23; python_version>"3.6" +starlette>=0.14,<1 +tornado>=6,<7 +uvicorn<1 # The oldest version can vary among Python runtime versions +gunicorn>=20,<21 +websocket_client>=1.2.3,<2 # Socket Mode 3rd party implementation diff --git a/requirements/adapter_testing.txt b/requirements/adapter_testing.txt new file mode 100644 index 000000000..f2978a463 --- /dev/null +++ b/requirements/adapter_testing.txt @@ -0,0 +1,8 @@ +# pip install -r requirements/adapter_testing.txt +moto>=3,<4 # For AWS tests +docker>=5,<6 # Used by moto +boddle>=0.2,<0.3 # For Bottle app tests +Flask>=1,<2 # TODO: Flask-Sockets is not yet compatible with Flask 2.x +Werkzeug>=1,<2 # TODO: Flask-Sockets is not yet compatible with Flask 2.x +sanic-testing>=0.7; python_version>"3.6" +requests>=2,<3 # For Starlette's TestClient diff --git a/requirements/async.txt b/requirements/async.txt new file mode 100644 index 000000000..221ec4ae9 --- /dev/null +++ b/requirements/async.txt @@ -0,0 +1,4 @@ +# pip install -r requirements/async.txt +aiohttp>=3,<4 +websockets>=8,<10; python_version=="3.6" +websockets>=10,<11; python_version>"3.6" diff --git a/requirements/testing.txt b/requirements/testing.txt new file mode 100644 index 000000000..6fbcc045d --- /dev/null +++ b/requirements/testing.txt @@ -0,0 +1,6 @@ +# pip install -r requirements/testing.txt +-r testing_without_asyncio.txt + +pytest-asyncio>=0.16.0; python_version=="3.6" +pytest-asyncio>=0.18.2,<1; python_version>"3.6" +aiohttp>=3,<4 diff --git a/requirements/testing_without_asyncio.txt b/requirements/testing_without_asyncio.txt new file mode 100644 index 000000000..0f971986c --- /dev/null +++ b/requirements/testing_without_asyncio.txt @@ -0,0 +1,9 @@ +# pip install -r requirements/testing_without_asyncio.txt +pytest>=6.2.5,<7 +pytest-cov>=3,<4 +Flask-Sockets>=0.2,<1 # TODO: This module is not yet Flask 2.x compatible +Werkzeug>=1,<2 # TODO: Flask-Sockets is not yet compatible with Flask 2.x +itsdangerous==2.0.1 # TODO: Flask-Sockets is not yet compatible with Flask 2.x +Jinja2==3.0.3 # https://github.com/pallets/flask/issues/4494 +black==22.8.0 # Until we drop Python 3.6 support, we have to stay with this version +click<=8.0.4 # black is affected by https://github.com/pallets/click/issues/2225 diff --git a/scripts/build_pypi_package.sh b/scripts/build_pypi_package.sh index 4fbf1b704..79c6db9f2 100755 --- a/scripts/build_pypi_package.sh +++ b/scripts/build_pypi_package.sh @@ -5,7 +5,7 @@ cd ${script_dir}/.. rm -rf ./slack_bolt.egg-info pip install -U pip && \ - pip install twine wheel && \ + pip install twine build && \ rm -rf dist/ build/ slack_bolt.egg-info/ && \ - python setup.py sdist bdist_wheel && \ - twine check dist/* \ No newline at end of file + python -m build --sdist --wheel && \ + twine check dist/* diff --git a/scripts/deploy_to_pypi_org.sh b/scripts/deploy_to_pypi_org.sh index 5ac4c9907..a3cf431fa 100755 --- a/scripts/deploy_to_pypi_org.sh +++ b/scripts/deploy_to_pypi_org.sh @@ -5,8 +5,8 @@ cd ${script_dir}/.. rm -rf ./slack_bolt.egg-info pip install -U pip && \ - pip install twine wheel && \ + pip install twine build && \ rm -rf dist/ build/ slack_bolt.egg-info/ && \ - python setup.py sdist bdist_wheel && \ + python -m build --sdist --wheel && \ twine check dist/* && \ twine upload dist/* diff --git a/scripts/deploy_to_test_pypi_org.sh b/scripts/deploy_to_test_pypi_org.sh index d6a105f77..b2cc65a12 100644 --- a/scripts/deploy_to_test_pypi_org.sh +++ b/scripts/deploy_to_test_pypi_org.sh @@ -5,8 +5,8 @@ cd ${script_dir}/.. rm -rf ./slack_bolt.egg-info pip install -U pip && \ - pip install twine wheel && \ + pip install twine build && \ rm -rf dist/ build/ slack_bolt.egg-info/ && \ - python setup.py sdist bdist_wheel && \ + python -m build --sdist --wheel && \ twine check dist/* && \ twine upload --repository testpypi dist/* \ No newline at end of file diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index cda15a058..c5da9647e 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -10,26 +10,28 @@ rm -rf ./slack_bolt.egg-info pip uninstall python-lambda test_target="$1" +python_version=`python --version | awk '{print $2}'` -pip install -e . +if [ ${python_version:0:3} == "3.6" ] +then + pip install -r requirements.txt +else + pip install -e . +fi if [[ $test_target != "" ]] then - # To fix: Using legacy 'setup.py install' for greenlet, since package 'wheel' is not installed. - pip install -U wheel && \ - pip install -e ".[testing]" && \ - pip install -e ".[adapter]" && \ - pip install -e ".[adapter_testing]" && \ + pip install -r requirements/testing.txt && \ + pip install -r requirements/adapter.txt && \ + pip install -r requirements/adapter_testing.txt && \ # To avoid errors due to the old versions of click forced by Chalice pip install -U pip click && \ black slack_bolt/ tests/ && \ pytest $1 else - # To fix: Using legacy 'setup.py install' for greenlet, since package 'wheel' is not installed. - pip install -U wheel && \ - pip install -e ".[testing]" && \ - pip install -e ".[adapter]" && \ - pip install -e ".[adapter_testing]" && \ + pip install -r requirements/testing.txt && \ + pip install -r requirements/adapter.txt && \ + pip install -r requirements/adapter_testing.txt && \ # To avoid errors due to the old versions of click forced by Chalice pip install -U pip click && \ black slack_bolt/ tests/ && \ diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh index 3f6a91e31..4c06a8905 100755 --- a/scripts/run_pytype.sh +++ b/scripts/run_pytype.sh @@ -3,7 +3,8 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ - pip install -e ".[async]" && \ - pip install -e ".[adapter]" && \ + pip install . + pip install -r requirements/async.txt && \ + pip install -r requirements/adapter.txt && \ pip install "pytype==2022.12.15" && \ pytype slack_bolt/ diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index c380e7684..2507a8e3b 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -19,8 +19,8 @@ else # pytype's behavior can be different in older Python versions black slack_bolt/ tests/ \ && pytest -vv \ - && pip install -e ".[adapter]" \ - && pip install -e ".[adapter_testing]" \ + && pip install -r requirements/adapter.txt \ + && pip install -r requirements/adapter_testing.txt \ && pip install -U pip setuptools wheel \ && pip install -U pytype \ && pytype slack_bolt/ diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5841ac1a4..000000000 --- a/setup.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[bdist_wheel] -universal = 1 - -[aliases] -test=pytest diff --git a/setup.py b/setup.py deleted file mode 100755 index 474bd8da0..000000000 --- a/setup.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -import setuptools - -here = os.path.abspath(os.path.dirname(__file__)) - -__version__ = None -exec(open(f"{here}/slack_bolt/version.py").read()) - -with open(f"{here}/README.md", "r") as fh: - long_description = fh.read() - -test_dependencies = [ - "pytest>=6.2.5,<7", - "pytest-cov>=3,<4", - "Flask-Sockets>=0.2,<1", # TODO: This module is not yet Flask 2.x compatible - "Werkzeug>=1,<2", # TODO: Flask-Sockets is not yet compatible with Flask 2.x - "itsdangerous==2.0.1", # TODO: Flask-Sockets is not yet compatible with Flask 2.x - "Jinja2==3.0.3", # https://github.com/pallets/flask/issues/4494 - "black==22.8.0", # Until we drop Python 3.6 support, we have to stay with this version - "click<=8.0.4", # black is affected by https://github.com/pallets/click/issues/2225 -] - -adapter_test_dependencies = [ - "moto>=3,<4", # For AWS tests - "docker>=5,<6", # Used by moto - "boddle>=0.2,<0.3", # For Bottle app tests - "Flask>=1,<2", # TODO: Flask-Sockets is not yet compatible with Flask 2.x - "Werkzeug>=1,<2", # TODO: Flask-Sockets is not yet compatible with Flask 2.x - "sanic-testing>=0.7" if sys.version_info.minor > 6 else "", - "requests>=2,<3", # For Starlette's TestClient -] - -async_test_dependencies = test_dependencies + [ - "pytest-asyncio>=0.18.2,<1", # for async - "aiohttp>=3,<4", # for async -] - -setuptools.setup( - name="slack_bolt", - version=__version__, - license="MIT", - author="Slack Technologies, LLC", - author_email="opensource@slack.com", - description="The Bolt Framework for Python", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/slackapi/bolt-python", - packages=setuptools.find_packages( - exclude=[ - "examples", - "integration_tests", - "tests", - "tests.*", - ] - ), - include_package_data=True, # MANIFEST.in - install_requires=[ - "slack_sdk>=3.25.0,<4", - ], - setup_requires=["pytest-runner==5.2"], - tests_require=async_test_dependencies, - test_suite="tests", - extras_require={ - # pip install -e ".[async]" - "async": [ - # async features heavily depends on aiohttp - "aiohttp>=3,<4", - # Socket Mode 3rd party implementation - "websockets>=10,<11" if sys.version_info.minor > 6 else "websockets>=8,<10", - ], - # pip install -e ".[adapter]" - # NOTE: any of async ones requires pip install -e ".[async]" too - "adapter": [ - # used only under src/slack_bolt/adapter - "boto3<=2", - "bottle>=0.12,<1", - "chalice>=1.28,<2" if sys.version_info.minor > 6 else "chalice<=1.27.3", - "CherryPy>=18,<19", - "Django>=3,<5", - "falcon>=3.1.1,<4" if sys.version_info.minor >= 11 else "falcon>=2,<4", - "fastapi>=0.70.0,<1", - "Flask>=1,<3", - "Werkzeug>=2,<3", - "pyramid>=1,<3", - "sanic>=22,<23" if sys.version_info.minor > 6 else "sanic>=20,<21", - "starlette>=0.14,<1", - "tornado>=6,<7", - # server - "uvicorn<1", # The oldest version can vary among Python runtime versions - "gunicorn>=20,<21", - # Socket Mode 3rd party implementation - # Note: 1.2.2 has a regression (https://github.com/websocket-client/websocket-client/issues/769) - "websocket_client>=1.2.3,<2", - ], - # pip install -e ".[testing_without_asyncio]" - "testing_without_asyncio": test_dependencies, - # pip install -e ".[testing]" - "testing": async_test_dependencies, - # pip install -e ".[adapter_testing]" - "adapter_testing": adapter_test_dependencies, - }, - classifiers=[ - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: Implementation :: CPython", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - python_requires=">=3.6", -) From 5e63905ef3161dfe523a4415cc3d6aa807f31763 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 1 Dec 2023 18:17:05 -0500 Subject: [PATCH 603/865] Maintain metadata (#999) --- pyproject.toml | 5 ++--- setup.cfg | 5 +++++ 2 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 setup.cfg diff --git a/pyproject.toml b/pyproject.toml index 0292636ef..ebeeab478 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,9 @@ build-backend = "setuptools.build_meta" [project] name = "slack_bolt" -dynamic = ["version", "readme", "dependencies"] +dynamic = ["version", "readme", "dependencies", "authors"] description = "The Bolt Framework for Python" license = { text = "MIT" } -authors = [{ name = "Slack Technologies, LLC", email = "opensource@slack.com" }] classifiers = [ "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", @@ -24,7 +23,7 @@ requires-python = ">=3.6" [project.urls] -homepage = "https://github.com/slackapi/bolt-python" +Documentation = "https://slack.dev/bolt-python" [tool.setuptools.packages.find] include = ["slack_bolt*"] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..3b05a6fa3 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +; Legacy package configuration, prefer pyproject.toml over setup.cfg +[metadata] +url=https://github.com/slackapi/bolt-python +author=Slack Technologies, LLC +author_email=opensource@slack.com From f150cfdab3bc5d503d7d59d89367b0c0e407bb2e Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 10 Jan 2024 17:17:46 -0500 Subject: [PATCH 604/865] remove unused multiprocessing test mode (#1011) * remove BOLT_PYTHON_MOCK_SERVER_MODE --------- Co-authored-by: Kazuhiro Sera --- .github/workflows/codecov.yml | 3 - .github/workflows/tests.yml | 4 - .../socket_mode/mock_socket_mode_server.py | 78 ++----------- .../socket_mode/mock_web_api_server.py | 107 +---------------- .../socket_mode/test_interactions_builtin.py | 1 - tests/mock_web_api_server.py | 109 +----------------- tests/utils.py | 15 --- 7 files changed, 21 insertions(+), 296 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 4839962ef..ea48ce6bc 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -14,9 +14,6 @@ jobs: matrix: python-version: ["3.11"] env: - # default: multiprocessing - # threading is more stable on GitHub Actions - BOLT_PYTHON_MOCK_SERVER_MODE: threading BOLT_PYTHON_CODECOV_RUNNING: "1" steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fcdc053b8..25d4ecc6a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,10 +13,6 @@ jobs: strategy: matrix: python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] - env: - # default: multiprocessing - # threading is more stable on GitHub Actions - BOLT_PYTHON_MOCK_SERVER_MODE: threading steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py index 577698188..5d64d0783 100644 --- a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py +++ b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py @@ -1,15 +1,11 @@ import json import logging -import sys import threading import time import requests -from multiprocessing.context import Process from typing import List from unittest import TestCase -from tests.utils import get_mock_server_mode - 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}""", @@ -56,42 +52,11 @@ def link(ws): return _start_thread_socket_mode_server -def start_process_socket_mode_server(port: int): - 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) - server.serve_forever(stop_timeout=1) - - def start_socket_mode_server(test, port: int): - if get_mock_server_mode() == "threading": - test.sm_thread = threading.Thread(target=start_thread_socket_mode_server(test, port)) - test.sm_thread.daemon = True - test.sm_thread.start() - wait_for_socket_mode_server(port, 2) # wait for the server - else: - test.sm_process = Process(target=start_process_socket_mode_server, kwargs={"port": port}) - test.sm_process.start() + test.sm_thread = threading.Thread(target=start_thread_socket_mode_server(test, port)) + test.sm_thread.daemon = True + test.sm_thread.start() + wait_for_socket_mode_server(port, 4) # wait for the server def wait_for_socket_mode_server(port: int, secs: int): @@ -100,39 +65,14 @@ def wait_for_socket_mode_server(port: int, secs: int): response = requests.get(url=f"http://localhost:{port}/state") if response.ok: break + time.sleep(0.01) def stop_socket_mode_server(test): - if get_mock_server_mode() == "threading": - print(test) - test.server.stop() - test.server.close() - else: - # terminate the process - test.sm_process.terminate() - test.sm_process.join() - # Python 3.6 does not have these methods - if sys.version_info.major == 3 and sys.version_info.minor > 6: - # cleanup the process's resources - test.sm_process.kill() - test.sm_process.close() - - test.sm_process = None + test.server.stop() + test.server.close() async def stop_socket_mode_server_async(test: TestCase): - if get_mock_server_mode() == "threading": - test.server.stop() - test.server.close() - else: - # terminate the process - test.sm_process.terminate() - test.sm_process.join() - - # Python 3.6 does not have these methods - if sys.version_info.major == 3 and sys.version_info.minor > 6: - # cleanup the process's resources - test.sm_process.kill() - test.sm_process.close() - - test.sm_process = None + test.server.stop() + test.server.close() diff --git a/tests/adapter_tests/socket_mode/mock_web_api_server.py b/tests/adapter_tests/socket_mode/mock_web_api_server.py index 1b964c711..22136e633 100644 --- a/tests/adapter_tests/socket_mode/mock_web_api_server.py +++ b/tests/adapter_tests/socket_mode/mock_web_api_server.py @@ -1,18 +1,13 @@ import json import logging import re -import sys import threading import time from http import HTTPStatus from http.server import HTTPServer, SimpleHTTPRequestHandler -from multiprocessing.context import Process from typing import Type from unittest import TestCase from urllib.parse import urlparse, parse_qs -from urllib.request import Request, urlopen - -from tests.utils import get_mock_server_mode class MockHandler(SimpleHTTPRequestHandler): @@ -120,59 +115,6 @@ def do_POST(self): self._handle() -# -# multiprocessing -# - - -class MockServerProcessTarget: - def __init__(self, handler: Type[SimpleHTTPRequestHandler] = MockHandler): - self.handler = handler - - def run(self): - self.handler.received_requests = {} - self.server = HTTPServer(("localhost", 8888), self.handler) - try: - self.server.serve_forever(0.05) - finally: - self.server.server_close() - - def stop(self): - self.handler.received_requests = {} - self.server.shutdown() - self.join() - - -class MonitorThread(threading.Thread): - def __init__(self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler): - threading.Thread.__init__(self, daemon=True) - self.handler = handler - self.test = test - self.test.mock_received_requests = None - self.is_running = True - - def run(self) -> None: - while self.is_running: - try: - req = Request(f"{self.test.server_url}/received_requests.json") - resp = urlopen(req, timeout=1) - self.test.mock_received_requests = json.loads(resp.read().decode("utf-8")) - except Exception as e: - # skip logging for the initial request - if self.test.mock_received_requests is not None: - logging.getLogger(__name__).exception(e) - time.sleep(0.01) - - def stop(self): - self.is_running = False - self.join() - - -# -# threading -# - - class MockServerThread(threading.Thread): def __init__(self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler): threading.Thread.__init__(self) @@ -197,52 +139,15 @@ def stop(self): def setup_mock_web_api_server(test: TestCase): - if get_mock_server_mode() == "threading": - test.server_started = threading.Event() - test.thread = MockServerThread(test) - test.thread.start() - test.server_started.wait() - else: - # start a mock server as another process - target = MockServerProcessTarget() - test.server_url = "http://localhost:8888" - test.host, test.port = "localhost", 8888 - test.process = Process(target=target.run, daemon=True) - test.process.start() - time.sleep(0.1) - - # start a thread in the current process - # this thread fetches mock_received_requests from the remote process - test.monitor_thread = MonitorThread(test) - test.monitor_thread.start() - count = 0 - # wait until the first successful data retrieval - while test.mock_received_requests is None: - time.sleep(0.01) - count += 1 - if count >= 100: - raise Exception("The mock server is not yet running!") + test.server_started = threading.Event() + test.thread = MockServerThread(test) + test.thread.start() + test.server_started.wait() def cleanup_mock_web_api_server(test: TestCase): - if get_mock_server_mode() == "threading": - test.thread.stop() - test.thread = None - else: - # stop the thread to fetch mock_received_requests from the remote process - test.monitor_thread.stop() - - # terminate the process - test.process.terminate() - test.process.join() - - # Python 3.6 does not have these methods - if sys.version_info.major == 3 and sys.version_info.minor > 6: - # cleanup the process's resources - test.process.kill() - test.process.close() - - test.process = None + test.thread.stop() + test.thread = None def assert_auth_test_count(test: TestCase, expected_count: int): diff --git a/tests/adapter_tests/socket_mode/test_interactions_builtin.py b/tests/adapter_tests/socket_mode/test_interactions_builtin.py index 99aac3171..2ecd52554 100644 --- a/tests/adapter_tests/socket_mode/test_interactions_builtin.py +++ b/tests/adapter_tests/socket_mode/test_interactions_builtin.py @@ -34,7 +34,6 @@ def teardown_method(self): stop_socket_mode_server(self) def test_interactions(self): - app = App(client=self.web_client) result = {"shortcut": False, "command": False} diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index 403b993b2..24d310994 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -1,7 +1,6 @@ import asyncio import json import logging -import sys import threading import time from http import HTTPStatus @@ -10,11 +9,6 @@ from unittest import TestCase from urllib.parse import urlparse, parse_qs, ParseResult -from multiprocessing import Process -from urllib.request import urlopen, Request - -from tests.utils import get_mock_server_mode - class MockHandler(SimpleHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -234,59 +228,6 @@ def _parse_request_body(self, parsed_path: str, content_len: int) -> Optional[di return request_body -# -# multiprocessing -# - - -class MockServerProcessTarget: - def __init__(self, handler: Type[SimpleHTTPRequestHandler] = MockHandler): - self.handler = handler - - def run(self): - self.handler.received_requests = {} - self.server = HTTPServer(("localhost", 8888), self.handler) - try: - self.server.serve_forever(0.05) - finally: - self.server.server_close() - - def stop(self): - self.handler.received_requests = {} - self.server.shutdown() - self.join() - - -class MonitorThread(threading.Thread): - def __init__(self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler): - threading.Thread.__init__(self, daemon=True) - self.handler = handler - self.test = test - self.test.mock_received_requests = None - self.is_running = True - - def run(self) -> None: - while self.is_running: - try: - req = Request(f"{self.test.server_url}/received_requests.json") - resp = urlopen(req, timeout=1) - self.test.mock_received_requests = json.loads(resp.read().decode("utf-8")) - except Exception as e: - # skip logging for the initial request - if self.test.mock_received_requests is not None: - logging.getLogger(__name__).exception(e) - time.sleep(0.01) - - def stop(self): - self.is_running = False - self.join() - - -# -# threading -# - - class MockServerThread(threading.Thread): def __init__(self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler): threading.Thread.__init__(self) @@ -313,53 +254,15 @@ def stop(self): def setup_mock_web_api_server(test: TestCase): - if get_mock_server_mode() == "threading": - test.server_started = threading.Event() - test.thread = MockServerThread(test) - test.thread.start() - test.server_started.wait() - else: - # start a mock server as another process - target = MockServerProcessTarget() - test.server_url = "http://localhost:8888" - test.host, test.port = "localhost", 8888 - test.process = Process(target=target.run, daemon=True) - test.process.start() - - time.sleep(0.1) - - # start a thread in the current process - # this thread fetches mock_received_requests from the remote process - test.monitor_thread = MonitorThread(test) - test.monitor_thread.start() - count = 0 - # wait until the first successful data retrieval - while test.mock_received_requests is None: - time.sleep(0.01) - count += 1 - if count >= 100: - raise Exception("The mock server is not yet running!") + test.server_started = threading.Event() + test.thread = MockServerThread(test) + test.thread.start() + test.server_started.wait() def cleanup_mock_web_api_server(test: TestCase): - if get_mock_server_mode() == "threading": - test.thread.stop() - test.thread = None - else: - # stop the thread to fetch mock_received_requests from the remote process - test.monitor_thread.stop() - - # terminate the process - test.process.terminate() - test.process.join() - - # Python 3.6 does not have these methods - if sys.version_info.major == 3 and sys.version_info.minor > 6: - # cleanup the process's resources - test.process.kill() - test.process.close() - - test.process = None + test.thread.stop() + test.thread = None def assert_auth_test_count(test: TestCase, expected_count: int): diff --git a/tests/utils.py b/tests/utils.py index 733a7c4de..eb9759c5d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -15,21 +15,6 @@ def restore_os_env(old_env: dict) -> None: os.environ.update(old_env) -def get_mock_server_mode() -> str: - """Returns a str representing the mode. - - :return: threading/multiprocessing - """ - mode = os.environ.get("BOLT_PYTHON_MOCK_SERVER_MODE") - if mode is None: - # We used to use "multiprocessing"" for macOS until Big Sur 11.1 - # Since 11.1, the "multiprocessing" mode started failing a lot... - # Therefore, we switched the default mode back to "threading". - return "threading" - else: - return mode - - def get_event_loop(): try: return asyncio.get_event_loop() From 4b086cf5fa1331244c4de859e17ff9a245b0fee5 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 16 Jan 2024 19:13:51 -0500 Subject: [PATCH 605/865] Replace Flask-Sockets with aiohttp for testing (#1012) --- .github/workflows/codecov.yml | 1 - .github/workflows/tests.yml | 13 +- requirements/adapter_testing.txt | 5 +- requirements/async.txt | 3 +- requirements/testing.txt | 6 +- requirements/testing_without_asyncio.txt | 6 +- .../socket_mode/mock_socket_mode_server.py | 125 +++++++++++------- .../socket_mode/test_async_aiohttp.py | 4 +- .../socket_mode/test_async_lazy_listeners.py | 4 +- .../socket_mode/test_async_websockets.py | 4 +- 10 files changed, 97 insertions(+), 74 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index ea48ce6bc..6265aff35 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -25,7 +25,6 @@ jobs: run: | pip install -U pip pip install . - pip install -r requirements/async.txt pip install -r requirements/adapter.txt pip install -r requirements/testing.txt pip install -r requirements/adapter_testing.txt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 25d4ecc6a..35647154a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + - name: Install synchronous dependencies run: | pip install -U pip pip install -r requirements.txt @@ -28,11 +28,10 @@ jobs: run: | pytest tests/slack_bolt/ pytest tests/scenario_tests/ - - name: Run tests for Socket Mode adapters + - name: Install adapter dependencies run: | pip install -r requirements/adapter.txt pip install -r requirements/adapter_testing.txt - pytest tests/adapter_tests/socket_mode/ - name: Run tests for HTTP Mode adapters (AWS) run: | pytest tests/adapter_tests/aws/ @@ -68,9 +67,15 @@ jobs: - name: Run tests for HTTP Mode adapters (Tornado) run: | pytest tests/adapter_tests/tornado/ - - name: Run tests for HTTP Mode adapters (asyncio-based libraries) + - name: Install async dependencies run: | pip install -r requirements/async.txt + - name: Run tests for Socket Mode adapters + run: | + # Requires async test dependencies + pytest tests/adapter_tests/socket_mode/ + - name: Run tests for HTTP Mode adapters (asyncio-based libraries) + run: | # Falcon supports Python 3.11 since its v3.1.1 pip install "falcon>=3.1.1,<4" pytest tests/adapter_tests_async/ diff --git a/requirements/adapter_testing.txt b/requirements/adapter_testing.txt index f2978a463..27d4d9fa2 100644 --- a/requirements/adapter_testing.txt +++ b/requirements/adapter_testing.txt @@ -1,8 +1,5 @@ # pip install -r requirements/adapter_testing.txt -moto>=3,<4 # For AWS tests +moto>=3,<5 # For AWS tests docker>=5,<6 # Used by moto boddle>=0.2,<0.3 # For Bottle app tests -Flask>=1,<2 # TODO: Flask-Sockets is not yet compatible with Flask 2.x -Werkzeug>=1,<2 # TODO: Flask-Sockets is not yet compatible with Flask 2.x sanic-testing>=0.7; python_version>"3.6" -requests>=2,<3 # For Starlette's TestClient diff --git a/requirements/async.txt b/requirements/async.txt index 221ec4ae9..f27c3baf6 100644 --- a/requirements/async.txt +++ b/requirements/async.txt @@ -1,4 +1,3 @@ # pip install -r requirements/async.txt aiohttp>=3,<4 -websockets>=8,<10; python_version=="3.6" -websockets>=10,<11; python_version>"3.6" +websockets<11 diff --git a/requirements/testing.txt b/requirements/testing.txt index 6fbcc045d..7cd7d353a 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -1,6 +1,4 @@ # pip install -r requirements/testing.txt -r testing_without_asyncio.txt - -pytest-asyncio>=0.16.0; python_version=="3.6" -pytest-asyncio>=0.18.2,<1; python_version>"3.6" -aiohttp>=3,<4 +-r async.txt +pytest-asyncio<1; diff --git a/requirements/testing_without_asyncio.txt b/requirements/testing_without_asyncio.txt index 0f971986c..5421347a0 100644 --- a/requirements/testing_without_asyncio.txt +++ b/requirements/testing_without_asyncio.txt @@ -1,9 +1,5 @@ # pip install -r requirements/testing_without_asyncio.txt pytest>=6.2.5,<7 -pytest-cov>=3,<4 -Flask-Sockets>=0.2,<1 # TODO: This module is not yet Flask 2.x compatible -Werkzeug>=1,<2 # TODO: Flask-Sockets is not yet compatible with Flask 2.x -itsdangerous==2.0.1 # TODO: Flask-Sockets is not yet compatible with Flask 2.x -Jinja2==3.0.3 # https://github.com/pallets/flask/issues/4494 +pytest-cov>=3,<5 black==22.8.0 # Until we drop Python 3.6 support, we have to stay with this version click<=8.0.4 # black is affected by https://github.com/pallets/click/issues/2225 diff --git a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py index 5d64d0783..997657368 100644 --- a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py +++ b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py @@ -1,10 +1,12 @@ -import json +import asyncio import logging import threading import time -import requests -from typing import List from unittest import TestCase +from urllib.error import URLError +from urllib.request import urlopen + +from aiohttp import WSMsgType, web 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}""", @@ -12,67 +14,94 @@ """{"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_thread_socket_mode_server(test: TestCase, port: int): - def _start_thread_socket_mode_server(): - logger = logging.getLogger(__name__) - app: Flask = Flask(__name__) + logger = logging.getLogger(__name__) + state = {} + + def reset_server_state(): + state.update( + envelopes_to_consume=list(socket_mode_envelopes), + ) + + test.reset_server_state = reset_server_state + + async def health(request: web.Request): + wr = web.Response() + await wr.prepare(request) + wr.set_status(200) + return wr + + async def link(request: web.Request): + ws = web.WebSocketResponse() + await ws.prepare(request) + + async for msg in ws: + if msg.type != WSMsgType.TEXT: + continue - @app.route("/state") - def state(): - return json.dumps({"success": True}), 200, {"ContentType": "application/json"} + if state["envelopes_to_consume"]: + e = state["envelopes_to_consume"].pop(0) + logger.debug(f"Send an envelope: {e}") + await ws.send_str(e) - sockets: Sockets = Sockets(app) + message = msg.data + logger.debug(f"Server received a message: {message}") - envelopes_to_consume: List[str] = list(socket_mode_envelopes) + await ws.send_str(message) - @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) + return ws - logger.debug(f"Server received a message: {message}") - ws.send(message) + app = web.Application() + app.add_routes( + [ + web.get("/link", link), + web.get("/health", health), + ] + ) + runner = web.AppRunner(app) - from gevent import pywsgi - from geventwebsocket.handler import WebSocketHandler + def run_server(): + reset_server_state() - server = pywsgi.WSGIServer(("", port), app, handler_class=WebSocketHandler) - test.server = server - server.serve_forever(stop_timeout=1) + test.loop = asyncio.new_event_loop() + asyncio.set_event_loop(test.loop) + test.loop.run_until_complete(runner.setup()) + site = web.TCPSite(runner, "127.0.0.1", port, reuse_port=True) + test.loop.run_until_complete(site.start()) - return _start_thread_socket_mode_server + # run until it's stopped from the main thread + test.loop.run_forever() + + test.loop.run_until_complete(runner.cleanup()) + test.loop.close() + + return run_server def start_socket_mode_server(test, port: int): test.sm_thread = threading.Thread(target=start_thread_socket_mode_server(test, port)) test.sm_thread.daemon = True test.sm_thread.start() - wait_for_socket_mode_server(port, 4) # wait for the server + wait_for_socket_mode_server(port, 4) -def wait_for_socket_mode_server(port: int, secs: int): +def wait_for_socket_mode_server(port: int, timeout: int): start_time = time.time() - while (time.time() - start_time) < secs: - response = requests.get(url=f"http://localhost:{port}/state") - if response.ok: - break - time.sleep(0.01) - - -def stop_socket_mode_server(test): - test.server.stop() - test.server.close() - - -async def stop_socket_mode_server_async(test: TestCase): - test.server.stop() - test.server.close() + while (time.time() - start_time) < timeout: + try: + urlopen(f"http://127.0.0.1:{port}/health") + return + except URLError: + time.sleep(0.01) + + +def stop_socket_mode_server(test: TestCase): + # An event loop runs in a thread and executes all callbacks and Tasks in + # its thread. While a Task is running in the event loop, no other Tasks + # can run in the same thread. When a Task executes an await expression, the + # running Task gets suspended, and the event loop executes the next Task. + # To schedule a callback from another OS thread, the loop.call_soon_threadsafe() method should be used. + # https://docs.python.org/3/library/asyncio-dev.html#asyncio-multithreading + test.loop.call_soon_threadsafe(test.loop.stop) + test.sm_thread.join(timeout=5) diff --git a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py index 6e8ce40de..1720f7ec6 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py +++ b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py @@ -12,7 +12,7 @@ from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop from ...adapter_tests.socket_mode.mock_socket_mode_server import ( start_socket_mode_server, - stop_socket_mode_server_async, + stop_socket_mode_server, ) @@ -71,4 +71,4 @@ async def command_handler(ack): assert result["command"] is True finally: await handler.client.close() - await stop_socket_mode_server_async(self) + stop_socket_mode_server(self) diff --git a/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py index 6709d588e..11268c6a1 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py +++ b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py @@ -12,7 +12,7 @@ from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop from ...adapter_tests.socket_mode.mock_socket_mode_server import ( start_socket_mode_server, - stop_socket_mode_server_async, + stop_socket_mode_server, ) @@ -80,4 +80,4 @@ async def lazy_func(body): finally: await handler.client.close() - await stop_socket_mode_server_async(self) + stop_socket_mode_server(self) diff --git a/tests/adapter_tests_async/socket_mode/test_async_websockets.py b/tests/adapter_tests_async/socket_mode/test_async_websockets.py index 19167e635..db2680fc6 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_websockets.py +++ b/tests/adapter_tests_async/socket_mode/test_async_websockets.py @@ -12,7 +12,7 @@ from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop from ...adapter_tests.socket_mode.mock_socket_mode_server import ( start_socket_mode_server, - stop_socket_mode_server_async, + stop_socket_mode_server, ) @@ -71,4 +71,4 @@ async def command_handler(ack): assert result["command"] is True finally: await handler.client.close() - await stop_socket_mode_server_async(self) + stop_socket_mode_server(self) From 69e033f4415fb192480df4b455bb9f25982f7737 Mon Sep 17 00:00:00 2001 From: Jun Ohtani Date: Mon, 29 Jan 2024 17:25:09 +0900 Subject: [PATCH 606/865] Fix type in ja_listening_events.md (#1022) --- docs/_basic/ja_listening_events.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_basic/ja_listening_events.md b/docs/_basic/ja_listening_events.md index 0954c2ce7..193b1b83f 100644 --- a/docs/_basic/ja_listening_events.md +++ b/docs/_basic/ja_listening_events.md @@ -35,7 +35,7 @@ def ask_for_introduction(event, say):
    `message()` リスナーは `event("message")` と等価の機能を提供します。 -`subtype` という追加のキーを指定して、イベントのサブタイプでフィルタリングすることもできます。よく使われるサブタイプには、`bot_message` や `message_replied` があります。詳しくは[メッセージイベントページ](https://api.slack.com/events/message#message_subtypes)を参照してください。サブタイプなしのイベントだけにフルターするために明に `None` を指定することもできます。 +`subtype` という追加のキーを指定して、イベントのサブタイプでフィルタリングすることもできます。よく使われるサブタイプには、`bot_message` や `message_replied` があります。詳しくは[メッセージイベントページ](https://api.slack.com/events/message#message_subtypes)を参照してください。サブタイプなしのイベントだけにフィルターするために明に `None` を指定することもできます。
    From 91f9ef1e7a9d9fab86129b23b8549ca71228175d Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 7 Feb 2024 11:19:28 -0500 Subject: [PATCH 607/865] feat: improve test speed (#1017) * Speed up the tests * make all tests pass * Update tests/mock_web_api_server.py Co-authored-by: Kazuhiro Sera * health endpoint not required * Update tests/mock_web_api_server.py Co-authored-by: Kazuhiro Sera * Move responses out of class * Improve naming * More time saving --------- Co-authored-by: Kazuhiro Sera --- tests/adapter_tests/aws/test_aws_chalice.py | 5 +- tests/adapter_tests/aws/test_aws_lambda.py | 3 +- tests/adapter_tests_async/test_async_asgi.py | 2 +- tests/mock_web_api_server.py | 259 +++++++++--------- tests/scenario_tests/test_app.py | 15 +- .../test_app_actor_user_token.py | 19 +- tests/scenario_tests/test_app_bot_only.py | 34 +-- .../test_app_custom_authorize.py | 19 +- .../test_app_installation_store.py | 16 +- .../test_app_using_methods_in_class.py | 12 +- tests/scenario_tests/test_authorize.py | 8 +- tests/scenario_tests/test_events.py | 30 +- .../scenario_tests/test_events_ignore_self.py | 20 +- tests/scenario_tests/test_events_org_apps.py | 20 +- .../test_events_request_verification.py | 9 +- .../test_events_shared_channels.py | 30 +- .../scenario_tests/test_events_socket_mode.py | 26 +- .../test_events_token_revocations.py | 14 +- .../test_events_url_verification.py | 12 +- tests/scenario_tests/test_lazy.py | 14 +- tests/scenario_tests/test_message.py | 26 +- tests/scenario_tests/test_message_bot.py | 8 +- .../scenario_tests/test_message_file_share.py | 8 +- .../test_message_thread_broadcast.py | 8 +- tests/scenario_tests/test_workflow_steps.py | 18 +- .../test_workflow_steps_decorator_simple.py | 20 +- ...test_workflow_steps_decorator_with_args.py | 20 +- .../authorization/test_authorize.py | 12 +- 28 files changed, 307 insertions(+), 380 deletions(-) diff --git a/tests/adapter_tests/aws/test_aws_chalice.py b/tests/adapter_tests/aws/test_aws_chalice.py index 58213d04c..4a5744333 100644 --- a/tests/adapter_tests/aws/test_aws_chalice.py +++ b/tests/adapter_tests/aws/test_aws_chalice.py @@ -21,6 +21,7 @@ from slack_bolt.app import App from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( + assert_received_request_count, setup_mock_web_api_server, cleanup_mock_web_api_server, assert_auth_test_count, @@ -263,7 +264,7 @@ def say_it(say): response: Response = slack_handler.handle(request) assert response.status_code == 200 assert_auth_test_count(self, 1) - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, "/chat.postMessage", 1) def test_lazy_listeners_cli(self): with mock.patch.dict(os.environ, {"AWS_CHALICE_CLI_MODE": "true"}): @@ -316,7 +317,7 @@ def events() -> Response: assert response.status_code == 200, f"Failed request: {response.body}" assert_auth_test_count(self, 1) - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, "/chat.postMessage", 1) @mock.patch( "slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner.boto3", diff --git a/tests/adapter_tests/aws/test_aws_lambda.py b/tests/adapter_tests/aws/test_aws_lambda.py index 3159f9731..3096dcaab 100644 --- a/tests/adapter_tests/aws/test_aws_lambda.py +++ b/tests/adapter_tests/aws/test_aws_lambda.py @@ -13,6 +13,7 @@ from slack_bolt.app import App from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( + assert_received_request_count, setup_mock_web_api_server, cleanup_mock_web_api_server, assert_auth_test_count, @@ -279,7 +280,7 @@ def say_it(say): response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 assert_auth_test_count(self, 1) - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, "/chat.postMessage", 1) @mock_lambda def test_oauth(self): diff --git a/tests/adapter_tests_async/test_async_asgi.py b/tests/adapter_tests_async/test_async_asgi.py index 1483d646f..d17cf069b 100644 --- a/tests/adapter_tests_async/test_async_asgi.py +++ b/tests/adapter_tests_async/test_async_asgi.py @@ -221,4 +221,4 @@ async def test_url_verification(self): assert response.status_code == 200 assert response.headers.get("content-type") == "application/json;charset=utf-8" - assert_auth_test_count(self, 1) + assert_auth_test_count(self, 0) diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index 24d310994..ce42ddb1f 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -1,6 +1,7 @@ import asyncio import json import logging +from queue import Queue import threading import time from http import HTTPStatus @@ -9,6 +10,84 @@ from unittest import TestCase from urllib.parse import urlparse, parse_qs, ParseResult +INVALID_AUTH = json.dumps( + { + "ok": False, + "error": "invalid_auth", + } +) + +OK_FALSE_RESPONSE = json.dumps( + { + "ok": False, + } +) + +OAUTH_V2_ACCESS_RESPONSE = json.dumps( + { + "ok": True, + "access_token": "xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy", + "token_type": "bot", + "scope": "chat:write,commands", + "bot_user_id": "U0KRQLJ9H", + "app_id": "A0KRD7HC3", + "team": {"name": "Slack Softball Team", "id": "T9TK3CUKW"}, + "enterprise": {"name": "slack-sports", "id": "E12345678"}, + "authed_user": {"id": "U1234", "scope": "chat:write", "access_token": "xoxp-1234", "token_type": "user"}, + } +) + +OAUTH_V2_ACCESS_BOT_REFRESH_RESPONSE = json.dumps( + { + "ok": True, + "app_id": "A0KRD7HC3", + "access_token": "xoxb-valid-refreshed", + "expires_in": 43200, + "refresh_token": "xoxe-1-valid-bot-refreshed", + "token_type": "bot", + "scope": "chat:write,commands", + "bot_user_id": "U0KRQLJ9H", + "team": {"name": "Slack Softball Team", "id": "T9TK3CUKW"}, + "enterprise": {"name": "slack-sports", "id": "E12345678"}, + } +) + +OAUTH_V2_ACCESS_USER_REFRESH_RESPONSE = json.dumps( + { + "ok": True, + "app_id": "A0KRD7HC3", + "access_token": "xoxp-valid-refreshed", + "expires_in": 43200, + "refresh_token": "xoxe-1-valid-user-refreshed", + "token_type": "user", + "scope": "search:read", + "team": {"name": "Slack Softball Team", "id": "T9TK3CUKW"}, + "enterprise": {"name": "slack-sports", "id": "E12345678"}, + } +) +BOT_AUTH_TEST_RESPONSE = json.dumps( + { + "ok": True, + "url": "https://subarachnoid.slack.com/", + "team": "Subarachnoid Workspace", + "user": "bot", + "team_id": "T0G9PQBBK", + "user_id": "W23456789", + "bot_id": "BZYBOTHED", + } +) + +USER_AUTH_TEST_RESPONSE = json.dumps( + { + "ok": True, + "url": "https://subarachnoid.slack.com/", + "team": "Subarachnoid Workspace", + "user": "some-user", + "team_id": "T0G9PQBBK", + "user_id": "W99999", + } +) + class MockHandler(SimpleHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -22,120 +101,24 @@ def is_valid_token(self): def is_valid_user_token(self): return "Authorization" in self.headers and str(self.headers["Authorization"]).startswith("Bearer xoxp-") - def set_common_headers(self): + def set_common_headers(self, content_length: int = 0): self.send_header("content-type", "application/json;charset=utf-8") - self.send_header("connection", "close") + self.send_header("content-length", str(content_length)) self.end_headers() - invalid_auth = { - "ok": False, - "error": "invalid_auth", - } - - oauth_v2_access_response = """ -{ - "ok": true, - "access_token": "xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy", - "token_type": "bot", - "scope": "chat:write,commands", - "bot_user_id": "U0KRQLJ9H", - "app_id": "A0KRD7HC3", - "team": { - "name": "Slack Softball Team", - "id": "T9TK3CUKW" - }, - "enterprise": { - "name": "slack-sports", - "id": "E12345678" - }, - "authed_user": { - "id": "U1234", - "scope": "chat:write", - "access_token": "xoxp-1234", - "token_type": "user" - } -} -""" - oauth_v2_access_bot_refresh_response = """ - { - "ok": true, - "app_id": "A0KRD7HC3", - "access_token": "xoxb-valid-refreshed", - "expires_in": 43200, - "refresh_token": "xoxe-1-valid-bot-refreshed", - "token_type": "bot", - "scope": "chat:write,commands", - "bot_user_id": "U0KRQLJ9H", - "team": { - "name": "Slack Softball Team", - "id": "T9TK3CUKW" - }, - "enterprise": { - "name": "slack-sports", - "id": "E12345678" - } - } -""" - oauth_v2_access_user_refresh_response = """ - { - "ok": true, - "app_id": "A0KRD7HC3", - "access_token": "xoxp-valid-refreshed", - "expires_in": 43200, - "refresh_token": "xoxe-1-valid-user-refreshed", - "token_type": "user", - "scope": "search:read", - "team": { - "name": "Slack Softball Team", - "id": "T9TK3CUKW" - }, - "enterprise": { - "name": "slack-sports", - "id": "E12345678" - } - } - """ - bot_auth_test_response = """ -{ - "ok": true, - "url": "https://subarachnoid.slack.com/", - "team": "Subarachnoid Workspace", - "user": "bot", - "team_id": "T0G9PQBBK", - "user_id": "W23456789", - "bot_id": "BZYBOTHED" -} -""" - - user_auth_test_response = """ -{ - "ok": true, - "url": "https://subarachnoid.slack.com/", - "team": "Subarachnoid Workspace", - "user": "some-user", - "team_id": "T0G9PQBBK", - "user_id": "W99999" -} -""" - def _handle(self): parsed_path: ParseResult = urlparse(self.path) path = parsed_path.path + self.server.queue.put(path) self.received_requests[path] = self.received_requests.get(path, 0) + 1 try: if path == "/webhook": self.send_response(200) - self.set_common_headers() + self.set_common_headers(len("OK")) self.wfile.write("OK".encode("utf-8")) return - if path == "/received_requests.json": - self.send_response(200) - self.set_common_headers() - self.wfile.write(json.dumps(self.received_requests).encode("utf-8")) - return - - body = {"ok": True} + body = """{"ok": true}""" if path == "/oauth.v2.access": if self.headers.get("authorization") is not None: request_body = self._parse_request_body( @@ -149,34 +132,32 @@ def _handle(self): if refresh_token is not None: if "bot-valid" in refresh_token: self.send_response(200) - self.set_common_headers() - body = self.oauth_v2_access_bot_refresh_response - self.wfile.write(body.encode("utf-8")) + self.set_common_headers(len(OAUTH_V2_ACCESS_BOT_REFRESH_RESPONSE)) + self.wfile.write(OAUTH_V2_ACCESS_BOT_REFRESH_RESPONSE.encode("utf-8")) return if "user-valid" in refresh_token: self.send_response(200) - self.set_common_headers() - body = self.oauth_v2_access_user_refresh_response - self.wfile.write(body.encode("utf-8")) + self.set_common_headers(len(OAUTH_V2_ACCESS_USER_REFRESH_RESPONSE)) + self.wfile.write(OAUTH_V2_ACCESS_USER_REFRESH_RESPONSE.encode("utf-8")) return elif request_body.get("code") is not None: self.send_response(200) - self.set_common_headers() - self.wfile.write(self.oauth_v2_access_response.encode("utf-8")) + self.set_common_headers(len(OAUTH_V2_ACCESS_RESPONSE)) + self.wfile.write(OAUTH_V2_ACCESS_RESPONSE.encode("utf-8")) return if self.is_valid_user_token(): if path == "/auth.test": self.send_response(200) - self.set_common_headers() - self.wfile.write(self.user_auth_test_response.encode("utf-8")) + self.set_common_headers(len(USER_AUTH_TEST_RESPONSE)) + self.wfile.write(USER_AUTH_TEST_RESPONSE.encode("utf-8")) return if self.is_valid_token(): if path == "/auth.test": self.send_response(200) - self.set_common_headers() - self.wfile.write(self.bot_auth_test_response.encode("utf-8")) + self.set_common_headers(len(BOT_AUTH_TEST_RESPONSE)) + self.wfile.write(BOT_AUTH_TEST_RESPONSE.encode("utf-8")) return request_body = self._parse_request_body( @@ -189,16 +170,15 @@ def _handle(self): pattern = str(header).split("xoxb-", 1)[1] if pattern.isnumeric(): self.send_response(int(pattern)) - self.set_common_headers() - self.wfile.write("""{"ok":false}""".encode("utf-8")) + self.set_common_headers(len(OK_FALSE_RESPONSE)) + self.wfile.write(OK_FALSE_RESPONSE.encode("utf-8")) return else: - body = self.invalid_auth + body = INVALID_AUTH self.send_response(HTTPStatus.OK) - self.set_common_headers() - self.wfile.write(json.dumps(body).encode("utf-8")) - self.wfile.close() + self.set_common_headers(len(body)) + self.wfile.write(body.encode("utf-8")) except Exception as e: self.logger.error(str(e), exc_info=True) @@ -229,13 +209,15 @@ def _parse_request_body(self, parsed_path: str, content_len: int) -> Optional[di class MockServerThread(threading.Thread): - def __init__(self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler): + def __init__(self, queue: Queue, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler): threading.Thread.__init__(self) self.handler = handler self.test = test + self.queue = queue def run(self): self.server = HTTPServer(("localhost", 8888), self.handler) + self.server.queue = self.queue self.test.mock_received_requests = self.handler.received_requests self.test.server_url = "http://localhost:8888" self.test.host, self.test.port = self.server.socket.getsockname() @@ -249,13 +231,28 @@ def run(self): def stop(self): self.handler.received_requests = {} + with self.server.queue.mutex: + del self.server.queue self.server.shutdown() self.join() +class ReceivedRequests: + def __init__(self, queue: Queue): + self.queue = queue + self.received_requests = {} + + def get(self, key: str, default: Optional[int] = None) -> Optional[int]: + while not self.queue.empty(): + path = self.queue.get() + self.received_requests[path] = self.received_requests.get(path, 0) + 1 + return self.received_requests.get(key, default) + + def setup_mock_web_api_server(test: TestCase): test.server_started = threading.Event() - test.thread = MockServerThread(test) + test.received_requests = ReceivedRequests(Queue()) + test.thread = MockServerThread(test.received_requests.queue, test) test.thread.start() test.server_started.wait() @@ -265,24 +262,26 @@ def cleanup_mock_web_api_server(test: TestCase): test.thread = None -def assert_auth_test_count(test: TestCase, expected_count: int): - time.sleep(0.1) - retry_count = 0 +def assert_received_request_count(test: TestCase, path: str, min_count: int, timeout: float = 1): + start_time = time.time() error = None - while retry_count < 3: + while time.time() - start_time < timeout: try: - test.mock_received_requests.get("/auth.test", 0) == expected_count - break + assert test.received_requests.get(path, 0) == min_count + return except Exception as e: error = e - retry_count += 1 - # waiting for mock_received_requests updates - time.sleep(0.1) + # waiting for some requests to be received + time.sleep(0.05) if error is not None: raise error +def assert_auth_test_count(test: TestCase, expected_count: int): + assert_received_request_count(test, "/auth.test", expected_count, 0.5) + + async def assert_auth_test_count_async(test: TestCase, expected_count: int): await asyncio.sleep(0.1) retry_count = 0 diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index e7c105ea3..9bbad6d9a 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -8,15 +8,12 @@ from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore -from slack_bolt import App, Say, BoltRequest, BoltContext +from slack_bolt import App, BoltContext, BoltRequest, Say from slack_bolt.authorization import AuthorizeResult from slack_bolt.error import BoltError from slack_bolt.oauth import OAuthFlow from slack_bolt.oauth.oauth_settings import OAuthSettings -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server from tests.utils import remove_os_env_temporarily, restore_os_env @@ -77,7 +74,7 @@ class TestExecutor(Executor): def test_valid_single_auth(self): app = App(signing_secret="valid", client=self.web_client) - assert app != None + assert app is not None def test_token_absence(self): with pytest.raises(BoltError): @@ -97,7 +94,7 @@ def test_token_verification_enabled_False(self): token_verification_enabled=False, ) - assert self.mock_received_requests.get("/auth.test") is None + assert self.received_requests.get("/auth.test") is None # -------------------------- # multi teams auth @@ -108,7 +105,7 @@ def test_valid_multi_auth(self): signing_secret="valid", oauth_settings=OAuthSettings(client_id="111.222", client_secret="valid"), ) - assert app != None + assert app is not None def test_valid_multi_auth_oauth_flow(self): oauth_flow = OAuthFlow( @@ -120,7 +117,7 @@ def test_valid_multi_auth_oauth_flow(self): ) ) app = App(signing_secret="valid", oauth_flow=oauth_flow) - assert app != None + assert app is not None def test_valid_multi_auth_client_id_absence(self): with pytest.raises(BoltError): diff --git a/tests/scenario_tests/test_app_actor_user_token.py b/tests/scenario_tests/test_app_actor_user_token.py index 15941c79f..024c29e59 100644 --- a/tests/scenario_tests/test_app_actor_user_token.py +++ b/tests/scenario_tests/test_app_actor_user_token.py @@ -1,20 +1,21 @@ import datetime import json import logging -from time import time, sleep +from time import time from typing import Optional from slack_sdk import WebClient from slack_sdk.oauth import InstallationStore -from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store import Bot, Installation from slack_sdk.signature import SignatureVerifier -from slack_bolt import App, BoltRequest, Say, BoltContext +from slack_bolt import App, BoltContext, BoltRequest, Say from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -176,9 +177,8 @@ def handle_events(context: BoltContext, say: Say): response = app.dispatch(self.build_request()) assert response.status == 200 - assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_auth_test_count(self, 2) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_authorize_result_no_user_token(self): app = App( @@ -215,5 +215,4 @@ def handle_events(context: BoltContext, say: Say): response = app.dispatch(self.build_request(team_id="T111111")) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) diff --git a/tests/scenario_tests/test_app_bot_only.py b/tests/scenario_tests/test_app_bot_only.py index c85590e39..9a4c932b9 100644 --- a/tests/scenario_tests/test_app_bot_only.py +++ b/tests/scenario_tests/test_app_bot_only.py @@ -1,12 +1,12 @@ import datetime import json import logging -from time import time, sleep +from time import time from typing import Optional from slack_sdk import WebClient from slack_sdk.oauth import InstallationStore -from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store import Bot, Installation from slack_sdk.oauth.state_store import FileOAuthStateStore from slack_sdk.signature import SignatureVerifier @@ -14,9 +14,10 @@ from slack_bolt.oauth import OAuthFlow from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -172,9 +173,8 @@ def test_installation_store_bot_only_default(self): app.event("app_mention")(self.handle_app_mention) response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 - assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_auth_test_count(self, 2) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_installation_store_bot_only_false(self): app = App( @@ -188,9 +188,8 @@ def test_installation_store_bot_only_false(self): app.event("app_mention")(self.handle_app_mention) response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 - assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_auth_test_count(self, 2) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_installation_store_bot_only(self): app = App( @@ -204,8 +203,7 @@ def test_installation_store_bot_only(self): response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_installation_store_bot_only_oauth_settings(self): app = App( @@ -218,8 +216,7 @@ def test_installation_store_bot_only_oauth_settings(self): response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_installation_store_bot_only_oauth_settings_conflicts(self): app = App( @@ -233,8 +230,7 @@ def test_installation_store_bot_only_oauth_settings_conflicts(self): response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_installation_store_bot_only_oauth_flow(self): app = App( @@ -247,8 +243,7 @@ def test_installation_store_bot_only_oauth_flow(self): response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_installation_store_bot_only_oauth_flow_conflicts(self): app = App( @@ -262,5 +257,4 @@ def test_installation_store_bot_only_oauth_flow_conflicts(self): response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) diff --git a/tests/scenario_tests/test_app_custom_authorize.py b/tests/scenario_tests/test_app_custom_authorize.py index e6293d695..02009162f 100644 --- a/tests/scenario_tests/test_app_custom_authorize.py +++ b/tests/scenario_tests/test_app_custom_authorize.py @@ -1,25 +1,26 @@ import datetime import json import logging -from time import time, sleep +from time import time from typing import Optional import pytest from slack_sdk import WebClient from slack_sdk.errors import SlackApiError from slack_sdk.oauth import InstallationStore -from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store import Bot, Installation from slack_sdk.signature import SignatureVerifier -from slack_bolt import App, BoltRequest, Say, BoltContext +from slack_bolt import App, BoltContext, BoltRequest, Say from slack_bolt.authorization import AuthorizeResult from slack_bolt.authorization.authorize import Authorize from slack_bolt.error import BoltError from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -209,9 +210,8 @@ def test_installation_store_only(self): app.event("app_mention")(self.handle_app_mention) response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 - assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_auth_test_count(self, 2) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_installation_store_and_authorize(self): installation_store = MemoryInstallationStore() @@ -231,8 +231,7 @@ def test_installation_store_and_authorize(self): response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_installation_store_and_func_authorize(self): installation_store = MemoryInstallationStore() diff --git a/tests/scenario_tests/test_app_installation_store.py b/tests/scenario_tests/test_app_installation_store.py index 1c0b5b07c..7434ee3e4 100644 --- a/tests/scenario_tests/test_app_installation_store.py +++ b/tests/scenario_tests/test_app_installation_store.py @@ -1,20 +1,21 @@ import datetime import json import logging -from time import time, sleep +from time import time from typing import Optional from slack_sdk import WebClient from slack_sdk.oauth import InstallationStore -from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store import Bot, Installation from slack_sdk.signature import SignatureVerifier -from slack_bolt import App, BoltRequest, Say, BoltContext +from slack_bolt import App, BoltContext, BoltRequest, Say from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -153,6 +154,5 @@ def handle_app_mention(context: BoltContext, say: Say): response = app.dispatch(self.build_app_mention_request()) assert response.status == 200 - assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_auth_test_count(self, 2) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) 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 c16f19884..3124bec8b 100644 --- a/tests/scenario_tests/test_app_using_methods_in_class.py +++ b/tests/scenario_tests/test_app_using_methods_in_class.py @@ -1,16 +1,17 @@ import inspect import json -from time import time, sleep +from time import time from typing import Callable from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient -from slack_bolt import App, BoltRequest, Say, Ack, BoltContext +from slack_bolt import Ack, App, BoltContext, BoltRequest, Say from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -115,8 +116,7 @@ def run_app_and_verify(self, app: App): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(0.5) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_class_methods(self): app = App(client=self.web_client, signing_secret=self.signing_secret) diff --git a/tests/scenario_tests/test_authorize.py b/tests/scenario_tests/test_authorize.py index bd730e329..13c248693 100644 --- a/tests/scenario_tests/test_authorize.py +++ b/tests/scenario_tests/test_authorize.py @@ -9,11 +9,7 @@ from slack_bolt.app import App from slack_bolt.authorization import AuthorizeResult from slack_bolt.request.payload_utils import is_event -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, - assert_auth_test_count, -) +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 valid_token = "xoxb-valid" @@ -138,7 +134,7 @@ def test_failure(self): response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests.get("/auth.test") == None + assert_auth_test_count(self, 0) def test_bot_context_attributes(self): app = App( diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index c6f2d6641..0cc6641fa 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -1,17 +1,18 @@ import json import re from functools import wraps -from time import time, sleep +from time import time import pytest from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient -from slack_bolt import App, BoltRequest, Say, BoltContext +from slack_bolt import App, BoltContext, BoltRequest, Say from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -87,8 +88,7 @@ def handle_app_mention(body, say, payload, event): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_middleware_skip(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @@ -143,8 +143,7 @@ def handle_app_mention(body, say, payload, event): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_stable_auto_ack(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @@ -221,8 +220,7 @@ def handle_member_left_channel(say): response = app.dispatch(request) assert response.status == 200 - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 2 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) def test_member_join_left_events(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @@ -283,9 +281,8 @@ def handle_app_mention(say): response = app.dispatch(request) assert response.status == 200 - sleep(1) # wait a bit after auto ack() # the listeners should not be executed - assert self.mock_received_requests["/chat.postMessage"] == 2 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) def test_uninstallation_and_revokes(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @@ -335,8 +332,7 @@ def handler2(say: Say): assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 2 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) message_file_share_body = { "token": "verification-token", @@ -564,8 +560,7 @@ def handle_app_mention(body, say, payload, event): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_additional_decorators_2(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @@ -583,8 +578,7 @@ def handle_app_mention(body, say, payload, event): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def my_decorator(f): diff --git a/tests/scenario_tests/test_events_ignore_self.py b/tests/scenario_tests/test_events_ignore_self.py index 12bcb987f..db7d07ea8 100644 --- a/tests/scenario_tests/test_events_ignore_self.py +++ b/tests/scenario_tests/test_events_ignore_self.py @@ -4,9 +4,10 @@ from slack_bolt import App, BoltRequest from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -38,9 +39,9 @@ def handle_app_mention(say): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() + sleep(0.5) # wait a bit after auto ack() # The listener should not be executed - assert self.mock_received_requests.get("/chat.postMessage") is None + assert self.received_requests.get("/chat.postMessage") is None def test_self_events_response_url(self): app = App(client=self.web_client) @@ -53,9 +54,9 @@ def handle_app_mention(say): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() + sleep(0.5) # wait a bit after auto ack() # The listener should not be executed - assert self.mock_received_requests.get("/chat.postMessage") is None + assert self.received_requests.get("/chat.postMessage") is None def test_not_self_events_response_url(self): app = App(client=self.web_client) @@ -68,8 +69,7 @@ def handle_app_mention(say): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests.get("/chat.postMessage") == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_self_events_disabled(self): app = App( @@ -85,9 +85,7 @@ def handle_app_mention(say): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - # The listener should be executed as the ignoring logic is disabled - assert self.mock_received_requests.get("/chat.postMessage") == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) event_body = { diff --git a/tests/scenario_tests/test_events_org_apps.py b/tests/scenario_tests/test_events_org_apps.py index a91c0e3c5..7eed113d5 100644 --- a/tests/scenario_tests/test_events_org_apps.py +++ b/tests/scenario_tests/test_events_org_apps.py @@ -1,5 +1,5 @@ import json -from time import time, sleep +from time import sleep, time from typing import Optional from slack_sdk.oauth import InstallationStore @@ -8,11 +8,7 @@ from slack_sdk.web import WebClient from slack_bolt import App, BoltRequest -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, - assert_auth_test_count, -) +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 valid_token = "xoxb-valid" @@ -25,7 +21,7 @@ def find_installation( enterprise_id: Optional[str], team_id: Optional[str], user_id: Optional[str] = None, - is_enterprise_install: Optional[bool] = False + is_enterprise_install: Optional[bool] = False, ) -> Optional[Installation]: assert enterprise_id == "E111" assert team_id is None @@ -107,7 +103,7 @@ def handle_app_mention(body): assert response.status == 200 # auth.test API call must be skipped assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() + sleep(0.5) # wait a bit after auto ack() assert result.called is True def test_team_access_revoked(self): @@ -143,8 +139,8 @@ def handle_app_mention(body): response = app.dispatch(request) assert response.status == 200 # auth.test API call must be skipped - assert self.mock_received_requests.get("/auth.test") is None - sleep(1) # wait a bit after auto ack() + assert_auth_test_count(self, 0) + sleep(0.5) # wait a bit after auto ack() assert result.called is True def test_app_home_opened(self): @@ -194,7 +190,7 @@ def handle_app_mention(body): assert response.status == 200 # auth.test API call must be skipped assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() + sleep(0.5) # wait a bit after auto ack() assert result.called is True def test_message(self): @@ -250,5 +246,5 @@ def handle_app_mention(body): assert response.status == 200 # auth.test API call must be skipped assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() + sleep(0.5) # wait a bit after auto ack() assert result.called is True diff --git a/tests/scenario_tests/test_events_request_verification.py b/tests/scenario_tests/test_events_request_verification.py index a8c53f23a..18e65eaec 100644 --- a/tests/scenario_tests/test_events_request_verification.py +++ b/tests/scenario_tests/test_events_request_verification.py @@ -1,11 +1,12 @@ import json -from time import sleep, time +from time import time from slack_sdk.web import WebClient from slack_sdk.signature import SignatureVerifier from slack_bolt import App, BoltRequest from tests.mock_web_api_server import ( + assert_received_request_count, setup_mock_web_api_server, cleanup_mock_web_api_server, assert_auth_test_count, @@ -57,8 +58,7 @@ def handle_app_mention(say): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests.get("/chat.postMessage") == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_disabled(self): app = App( @@ -78,8 +78,7 @@ def handle_app_mention(say): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests.get("/chat.postMessage") == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) event_body = { diff --git a/tests/scenario_tests/test_events_shared_channels.py b/tests/scenario_tests/test_events_shared_channels.py index 8e850d331..1a777230d 100644 --- a/tests/scenario_tests/test_events_shared_channels.py +++ b/tests/scenario_tests/test_events_shared_channels.py @@ -1,5 +1,5 @@ import json -from time import time, sleep +from time import sleep, time from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient @@ -7,9 +7,10 @@ from slack_bolt import App, BoltRequest, Say from slack_bolt.authorization import AuthorizeResult from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -108,8 +109,7 @@ def handle_app_mention(body, say: Say, payload, event): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_middleware_skip(self): app = App( @@ -180,8 +180,7 @@ def handle_app_mention(body, say, payload, event): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_stable_auto_ack(self): app = App( @@ -250,9 +249,9 @@ def handle_app_mention(say): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() + sleep(0.5) # wait a bit after auto ack() # The listener should not be executed - assert self.mock_received_requests.get("/chat.postMessage") is None + assert self.received_requests.get("/chat.postMessage") is None def test_self_member_join_left_events(self): app = App( @@ -332,9 +331,7 @@ def handle_member_left_channel(say): request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) response = app.dispatch(request) assert response.status == 200 - - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 2 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) def test_member_join_left_events(self): app = App( @@ -415,9 +412,7 @@ def handle_app_mention(say): response = app.dispatch(request) assert response.status == 200 - sleep(1) # wait a bit after auto ack() - # the listeners should not be executed - assert self.mock_received_requests["/chat.postMessage"] == 2 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) def test_uninstallation_and_revokes(self): app = App( @@ -489,6 +484,5 @@ def handler2(say: Say): assert response.status == 200 # this should not be called when we have authorize - assert self.mock_received_requests.get("/auth.test") is None - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 2 + assert_auth_test_count(self, 0) + assert_received_request_count(self, path="/chat.postMessage", min_count=2) diff --git a/tests/scenario_tests/test_events_socket_mode.py b/tests/scenario_tests/test_events_socket_mode.py index 1b1ddda38..81c45b148 100644 --- a/tests/scenario_tests/test_events_socket_mode.py +++ b/tests/scenario_tests/test_events_socket_mode.py @@ -6,9 +6,10 @@ from slack_bolt import App, BoltRequest, Say from slack_bolt.error import BoltError from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -72,8 +73,7 @@ def handle_app_mention(body, say, payload, event): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_middleware_skip(self): app = App(client=self.web_client) @@ -126,8 +126,7 @@ def handle_app_mention(body, say, payload, event): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_stable_auto_ack(self): app = App(client=self.web_client) @@ -175,9 +174,9 @@ def handle_app_mention(say): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() + sleep(0.5) # wait a bit after auto ack() # The listener should not be executed - assert self.mock_received_requests.get("/chat.postMessage") is None + assert self.received_requests.get("/chat.postMessage") is None def test_self_member_join_left_events(self): app = App(client=self.web_client) @@ -235,9 +234,7 @@ def handle_member_left_channel(say): request: BoltRequest = BoltRequest(body=left_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 2 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) def test_member_join_left_events(self): app = App(client=self.web_client) @@ -296,9 +293,7 @@ def handle_app_mention(say): response = app.dispatch(request) assert response.status == 200 - sleep(1) # wait a bit after auto ack() - # the listeners should not be executed - assert self.mock_received_requests["/chat.postMessage"] == 2 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) def test_uninstallation_and_revokes(self): app = App(client=self.web_client) @@ -346,5 +341,4 @@ def handler2(say: Say): assert response.status == 200 assert_auth_test_count(self, 1) - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 2 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) diff --git a/tests/scenario_tests/test_events_token_revocations.py b/tests/scenario_tests/test_events_token_revocations.py index 3b3227ce7..30e2eff60 100644 --- a/tests/scenario_tests/test_events_token_revocations.py +++ b/tests/scenario_tests/test_events_token_revocations.py @@ -1,5 +1,5 @@ import json -from time import time, sleep +from time import sleep, time from typing import Optional import pytest as pytest @@ -10,11 +10,7 @@ from slack_bolt import App, BoltRequest from slack_bolt.error import BoltError -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, - assert_auth_test_count, -) +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 valid_token = "xoxb-valid" @@ -40,7 +36,7 @@ def find_installation( enterprise_id: Optional[str], team_id: Optional[str], user_id: Optional[str] = None, - is_enterprise_install: Optional[bool] = False + is_enterprise_install: Optional[bool] = False, ) -> Optional[Installation]: assert enterprise_id == "E111" assert team_id is None @@ -132,7 +128,7 @@ def test_tokens_revoked(self): # auth.test API call must be skipped assert_auth_test_count(self, 0) - sleep(1) # wait a bit after auto ack() + sleep(0.5) # wait a bit after auto ack() assert app.installation_store.delete_bot_called is True assert app.installation_store.delete_installation_called is True assert app.installation_store.delete_all_called is False @@ -165,7 +161,7 @@ def test_app_uninstalled(self): assert response.status == 200 # auth.test API call must be skipped assert_auth_test_count(self, 0) - sleep(1) # wait a bit after auto ack() + sleep(0.5) # wait a bit after auto ack() assert app.installation_store.delete_bot_called is True assert app.installation_store.delete_installation_called is True assert app.installation_store.delete_all_called is True diff --git a/tests/scenario_tests/test_events_url_verification.py b/tests/scenario_tests/test_events_url_verification.py index 33e3f2524..f09aab843 100644 --- a/tests/scenario_tests/test_events_url_verification.py +++ b/tests/scenario_tests/test_events_url_verification.py @@ -1,15 +1,11 @@ import json from time import time -from slack_sdk.web import WebClient from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient from slack_bolt import App, BoltRequest -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, - assert_auth_test_count, -) +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 @@ -53,7 +49,7 @@ def test_default(self): response = app.dispatch(request) assert response.status == 200 assert response.body == """{"challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P"}""" - assert_auth_test_count(self, 0) + assert_auth_test_count(self, 1) def test_disabled(self): app = App( @@ -67,7 +63,7 @@ def test_disabled(self): response = app.dispatch(request) assert response.status == 404 assert response.body == """{"error": "unhandled request"}""" - assert_auth_test_count(self, 0) + assert_auth_test_count(self, 1) event_body = { diff --git a/tests/scenario_tests/test_lazy.py b/tests/scenario_tests/test_lazy.py index 3b0956efc..d9e88b280 100644 --- a/tests/scenario_tests/test_lazy.py +++ b/tests/scenario_tests/test_lazy.py @@ -7,10 +7,7 @@ from slack_bolt import BoltRequest from slack_bolt.app import App -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) +from tests.mock_web_api_server import assert_received_request_count, cleanup_mock_web_api_server, setup_mock_web_api_server from tests.utils import remove_os_env_temporarily, restore_os_env @@ -106,8 +103,7 @@ def async2(say): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - time.sleep(1) # wait a bit - assert self.mock_received_requests["/chat.postMessage"] == 2 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) def test_lazy_class(self): def just_ack(ack): @@ -134,8 +130,7 @@ def async2(say): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - time.sleep(1) # wait a bit - assert self.mock_received_requests["/chat.postMessage"] == 2 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) def test_issue_545_context_copy_failure(self): def just_ack(ack): @@ -204,5 +199,4 @@ def set_ssl_context(context, next_): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - time.sleep(1) # wait a bit - assert self.mock_received_requests["/chat.postMessage"] == 2 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) diff --git a/tests/scenario_tests/test_message.py b/tests/scenario_tests/test_message.py index 8aa073809..892776620 100644 --- a/tests/scenario_tests/test_message.py +++ b/tests/scenario_tests/test_message.py @@ -9,9 +9,10 @@ from slack_bolt.app import App from slack_bolt.request import BoltRequest from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -71,8 +72,7 @@ def test_string_keyword(self): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - time.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_all_message_matching_1(self): app = App( @@ -88,8 +88,7 @@ def handle_all_new_messages(say): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - time.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_all_message_matching_2(self): app = App( @@ -105,8 +104,7 @@ def handle_all_new_messages(say): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - time.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_string_keyword_capturing(self): app = App( @@ -119,8 +117,7 @@ def test_string_keyword_capturing(self): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - time.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_string_keyword_capturing2(self): app = App( @@ -133,8 +130,7 @@ def test_string_keyword_capturing2(self): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - time.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_string_keyword_capturing_multi_capture(self): app = App( @@ -147,8 +143,7 @@ def test_string_keyword_capturing_multi_capture(self): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - time.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_string_keyword_unmatched(self): app = App( @@ -173,8 +168,7 @@ def test_regexp_keyword(self): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - time.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_regexp_keyword_unmatched(self): app = App( diff --git a/tests/scenario_tests/test_message_bot.py b/tests/scenario_tests/test_message_bot.py index 94eae4440..bc756be7d 100644 --- a/tests/scenario_tests/test_message_bot.py +++ b/tests/scenario_tests/test_message_bot.py @@ -7,11 +7,7 @@ from slack_bolt.app import App from slack_bolt.request import BoltRequest -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, - assert_auth_test_count, -) +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 @@ -76,7 +72,7 @@ def handle_messages(event, logger): assert response.status == 200 assert_auth_test_count(self, 1) - time.sleep(1) # wait a bit after auto ack() + time.sleep(0.5) # wait a bit after auto ack() assert result["call_count"] == 3 diff --git a/tests/scenario_tests/test_message_file_share.py b/tests/scenario_tests/test_message_file_share.py index 8921b37c9..d014931e3 100644 --- a/tests/scenario_tests/test_message_file_share.py +++ b/tests/scenario_tests/test_message_file_share.py @@ -6,11 +6,7 @@ from slack_bolt.app import App from slack_bolt.request import BoltRequest -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, - assert_auth_test_count, -) +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 @@ -71,7 +67,7 @@ def handle_messages(event, logger): assert response.status == 200 assert_auth_test_count(self, 1) - time.sleep(1) # wait a bit after auto ack() + time.sleep(0.5) # wait a bit after auto ack() assert result["call_count"] == 2 diff --git a/tests/scenario_tests/test_message_thread_broadcast.py b/tests/scenario_tests/test_message_thread_broadcast.py index a69147782..8d2ed7aa2 100644 --- a/tests/scenario_tests/test_message_thread_broadcast.py +++ b/tests/scenario_tests/test_message_thread_broadcast.py @@ -6,11 +6,7 @@ from slack_bolt.app import App from slack_bolt.request import BoltRequest -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, - assert_auth_test_count, -) +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 @@ -71,7 +67,7 @@ def handle_messages(event, logger): assert response.status == 200 assert_auth_test_count(self, 1) - time.sleep(1) # wait a bit after auto ack() + time.sleep(0.5) # wait a bit after auto ack() assert result["call_count"] == 2 diff --git a/tests/scenario_tests/test_workflow_steps.py b/tests/scenario_tests/test_workflow_steps.py index 94efd952a..4c644f027 100644 --- a/tests/scenario_tests/test_workflow_steps.py +++ b/tests/scenario_tests/test_workflow_steps.py @@ -1,18 +1,18 @@ import json import logging -import time as time_module from time import time from urllib.parse import quote from slack_sdk.signature import SignatureVerifier -from slack_sdk.web import WebClient, SlackResponse +from slack_sdk.web import SlackResponse, WebClient -from slack_bolt import App, BoltRequest, Ack -from slack_bolt.workflows.step import Complete, Fail, Update, Configure +from slack_bolt import Ack, App, BoltRequest +from slack_bolt.workflows.step import Complete, Configure, Fail, Update from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -142,8 +142,7 @@ def test_execute(self): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - time_module.sleep(0.5) - assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + assert_received_request_count(self, path="/workflows.stepCompleted", min_count=1, timeout=0.5) app = self.build_app("copy_review___") response = app.dispatch(request) @@ -162,8 +161,7 @@ def test_execute_process_before_response(self): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - time_module.sleep(0.5) - assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + assert_received_request_count(self, path="/workflows.stepCompleted", min_count=1, timeout=0.5) app = self.build_process_before_response_app("copy_review___") response = app.dispatch(request) diff --git a/tests/scenario_tests/test_workflow_steps_decorator_simple.py b/tests/scenario_tests/test_workflow_steps_decorator_simple.py index 37f32738f..bd48b5c5a 100644 --- a/tests/scenario_tests/test_workflow_steps_decorator_simple.py +++ b/tests/scenario_tests/test_workflow_steps_decorator_simple.py @@ -1,16 +1,17 @@ import json -import time as time_module from time import time from urllib.parse import quote from slack_sdk.signature import SignatureVerifier -from slack_sdk.web import WebClient, SlackResponse +from slack_sdk.web import SlackResponse, WebClient -from slack_bolt import App, BoltRequest, Ack -from slack_bolt.workflows.step import Complete, Fail, Update, Configure, WorkflowStep +from slack_bolt import Ack, App, BoltRequest +from slack_bolt.workflows.step import Complete, Configure, Fail, Update, WorkflowStep from tests.mock_web_api_server import ( - setup_mock_web_api_server, + assert_auth_test_count, + assert_received_request_count, cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -48,7 +49,7 @@ def test_edit(self): request: BoltRequest = BoltRequest(body=body, headers=headers) response = self.app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) self.app = App(client=self.web_client, signing_secret=self.signing_secret) self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) @@ -65,7 +66,7 @@ def test_save(self): request: BoltRequest = BoltRequest(body=body, headers=headers) response = self.app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) self.app = App(client=self.web_client, signing_secret=self.signing_secret) self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) @@ -82,9 +83,8 @@ def test_execute(self): request: BoltRequest = BoltRequest(body=body, headers=headers) response = self.app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - time_module.sleep(0.5) - assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/workflows.stepCompleted", min_count=1, timeout=0.5) self.app = App(client=self.web_client, signing_secret=self.signing_secret) self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) diff --git a/tests/scenario_tests/test_workflow_steps_decorator_with_args.py b/tests/scenario_tests/test_workflow_steps_decorator_with_args.py index d07edd90e..4180dff59 100644 --- a/tests/scenario_tests/test_workflow_steps_decorator_with_args.py +++ b/tests/scenario_tests/test_workflow_steps_decorator_with_args.py @@ -1,17 +1,18 @@ import json import logging -import time as time_module from time import time from urllib.parse import quote from slack_sdk.signature import SignatureVerifier -from slack_sdk.web import WebClient, SlackResponse +from slack_sdk.web import SlackResponse, WebClient -from slack_bolt import App, BoltRequest, Ack -from slack_bolt.workflows.step import Complete, Fail, Update, Configure, WorkflowStep +from slack_bolt import Ack, App, BoltRequest +from slack_bolt.workflows.step import Complete, Configure, Fail, Update, WorkflowStep from tests.mock_web_api_server import ( - setup_mock_web_api_server, + assert_auth_test_count, + assert_received_request_count, cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -49,7 +50,7 @@ def test_edit(self): request: BoltRequest = BoltRequest(body=body, headers=headers) response = self.app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) self.app = App(client=self.web_client, signing_secret=self.signing_secret) self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) @@ -66,7 +67,7 @@ def test_save(self): request: BoltRequest = BoltRequest(body=body, headers=headers) response = self.app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) self.app = App(client=self.web_client, signing_secret=self.signing_secret) self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) @@ -83,9 +84,8 @@ def test_execute(self): request: BoltRequest = BoltRequest(body=body, headers=headers) response = self.app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - time_module.sleep(0.5) - assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/workflows.stepCompleted", min_count=1, timeout=0.5) self.app = App(client=self.web_client, signing_secret=self.signing_secret) self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) diff --git a/tests/slack_bolt/authorization/test_authorize.py b/tests/slack_bolt/authorization/test_authorize.py index d7c06b226..69d6fca6d 100644 --- a/tests/slack_bolt/authorization/test_authorize.py +++ b/tests/slack_bolt/authorization/test_authorize.py @@ -183,7 +183,7 @@ def test_installation_store(self): assert result.team_id == "T0G9PQBBK" assert result.team == "Subarachnoid Workspace" assert result.url == "https://subarachnoid.slack.com/" - assert_auth_test_count(self, 1) + assert_auth_test_count(self, 2) result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") assert result.bot_id == "BZYBOTHED" @@ -194,7 +194,7 @@ def test_installation_store(self): assert result.team_id == "T0G9PQBBK" assert result.team == "Subarachnoid Workspace" assert result.url == "https://subarachnoid.slack.com/" - assert_auth_test_count(self, 2) + assert_auth_test_count(self, 4) def test_installation_store_cached(self): installation_store = MemoryInstallationStore() @@ -215,7 +215,7 @@ def test_installation_store_cached(self): assert result.team_id == "T0G9PQBBK" assert result.team == "Subarachnoid Workspace" assert result.url == "https://subarachnoid.slack.com/" - assert_auth_test_count(self, 1) + assert_auth_test_count(self, 2) result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") assert result.bot_id == "BZYBOTHED" @@ -226,7 +226,7 @@ def test_installation_store_cached(self): assert result.team_id == "T0G9PQBBK" assert result.team == "Subarachnoid Workspace" assert result.url == "https://subarachnoid.slack.com/" - assert_auth_test_count(self, 1) # cached + assert_auth_test_count(self, 2) # cached def test_fetch_different_user_token(self): installation_store = ValidUserTokenInstallationStore() @@ -243,7 +243,7 @@ def test_fetch_different_user_token(self): assert result.team_id == "T0G9PQBBK" assert result.team == "Subarachnoid Workspace" assert result.url == "https://subarachnoid.slack.com/" - assert_auth_test_count(self, 1) + assert_auth_test_count(self, 2) def test_fetch_different_user_token_with_rotation(self): context = BoltContext() @@ -279,7 +279,7 @@ def test_fetch_different_user_token_with_rotation(self): assert result.team_id == "T0G9PQBBK" assert result.team == "Subarachnoid Workspace" assert result.url == "https://subarachnoid.slack.com/" - assert_auth_test_count(self, 1) + assert_auth_test_count(self, 2) def test_remove_latest_user_token_if_it_is_not_relevant(self): installation_store = ValidUserTokenInstallationStore() From b6de3b321a370023859b594be1105b6eba742c6a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 26 Feb 2024 09:32:05 +0900 Subject: [PATCH 608/865] Fix #1030 error in exmaple code --- examples/sqlalchemy/async_oauth_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/sqlalchemy/async_oauth_app.py b/examples/sqlalchemy/async_oauth_app.py index a2c52f098..39f9417ce 100644 --- a/examples/sqlalchemy/async_oauth_app.py +++ b/examples/sqlalchemy/async_oauth_app.py @@ -68,7 +68,7 @@ async def async_find_bot( *, enterprise_id: Optional[str], team_id: Optional[str], - is_enterprise_install: Optional[bool], + is_enterprise_install: Optional[bool] = False, ) -> Optional[Bot]: c = self.bots.c query = ( From ce4a5e8be91759c85e1a7d62a6aefa49ff995452 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 5 Mar 2024 17:06:00 -0500 Subject: [PATCH 609/865] feat: use dependabot to maintain packages (#1037) --- .github/dependabot.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..6baed210a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" From 68352c30354a5b2a0bfd8f3684ca35432109902a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 22:32:54 +0000 Subject: [PATCH 610/865] Bump actions/checkout from 3 to 4 (#1038) --- .github/workflows/codecov.yml | 2 +- .github/workflows/flake8.yml | 2 +- .github/workflows/pytype.yml | 2 +- .github/workflows/tests.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 6265aff35..1abe6a510 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -16,7 +16,7 @@ jobs: env: BOLT_PYTHON_CODECOV_RUNNING: "1" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index f0eb9c5e0..984973980 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -13,7 +13,7 @@ jobs: matrix: python-version: ["3.9"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: diff --git a/.github/workflows/pytype.yml b/.github/workflows/pytype.yml index 5aaa5029a..e8a75493b 100644 --- a/.github/workflows/pytype.yml +++ b/.github/workflows/pytype.yml @@ -13,7 +13,7 @@ jobs: matrix: python-version: ["3.9"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 35647154a..7607ad7c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: matrix: python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: From 02428057287494bcb5f59a73a3d64f25e96bf379 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 22:46:31 +0000 Subject: [PATCH 611/865] Bump actions/setup-python from 4 to 5 (#1040) --- .github/workflows/codecov.yml | 2 +- .github/workflows/flake8.yml | 2 +- .github/workflows/pytype.yml | 2 +- .github/workflows/tests.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 1abe6a510..e7a1dd938 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index 984973980..bdc9036f0 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Run flake8 verification diff --git a/.github/workflows/pytype.yml b/.github/workflows/pytype.yml index e8a75493b..1ad3e8063 100644 --- a/.github/workflows/pytype.yml +++ b/.github/workflows/pytype.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Run pytype verification diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7607ad7c1..d3bc7b4a1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install synchronous dependencies From 7b186094df4fb5b3de15c0093616c0509e284cb1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:02:44 +0000 Subject: [PATCH 612/865] Update werkzeug requirement from <3,>=2 to >=2,<4 (#1041) --- requirements/adapter.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/adapter.txt b/requirements/adapter.txt index 81bbd5f12..8027e20c8 100644 --- a/requirements/adapter.txt +++ b/requirements/adapter.txt @@ -11,7 +11,7 @@ falcon>=2,<4; python_version<"3.11" falcon>=3.1.1,<4; python_version>="3.11" fastapi>=0.70.0,<1 Flask>=1,<3 -Werkzeug>=2,<3 +Werkzeug>=2,<4 pyramid>=1,<3 sanic>=20,<21; python_version=="3.6" sanic>=22,<23; python_version>"3.6" From c55e7820043688c7017218bc8e5b9f7796b06a90 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:32:15 +0000 Subject: [PATCH 613/865] Bump actions/stale from 4.0.0 to 9.0.0 (#1042) --- .github/workflows/triage-issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index cf6864ab3..741221516 100644 --- a/.github/workflows/triage-issues.yml +++ b/.github/workflows/triage-issues.yml @@ -17,7 +17,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v4.0.0 + - uses: actions/stale@v9.0.0 with: days-before-issue-stale: 30 days-before-issue-close: 10 From 488dee086c1c86918d5dd640a632c1a158300fea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:44:42 +0000 Subject: [PATCH 614/865] Update pytest requirement from <7,>=6.2.5 to >=6.2.5,<9 (#1043) --- requirements/testing_without_asyncio.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/testing_without_asyncio.txt b/requirements/testing_without_asyncio.txt index 5421347a0..ddc8ee0b3 100644 --- a/requirements/testing_without_asyncio.txt +++ b/requirements/testing_without_asyncio.txt @@ -1,5 +1,5 @@ # pip install -r requirements/testing_without_asyncio.txt -pytest>=6.2.5,<7 +pytest>=6.2.5,<9 pytest-cov>=3,<5 black==22.8.0 # Until we drop Python 3.6 support, we have to stay with this version click<=8.0.4 # black is affected by https://github.com/pallets/click/issues/2225 From f29450e79e9daf302321ce24c8f5f4ad8abc1c8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:59:30 +0000 Subject: [PATCH 615/865] Update flask requirement from <3,>=1 to >=1,<4 (#1044) --- requirements/adapter.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/adapter.txt b/requirements/adapter.txt index 8027e20c8..996538b16 100644 --- a/requirements/adapter.txt +++ b/requirements/adapter.txt @@ -10,7 +10,7 @@ Django>=3,<5 falcon>=2,<4; python_version<"3.11" falcon>=3.1.1,<4; python_version>="3.11" fastapi>=0.70.0,<1 -Flask>=1,<3 +Flask>=1,<4 Werkzeug>=2,<4 pyramid>=1,<3 sanic>=20,<21; python_version=="3.6" From 996f29f0ee741444c5b2a151b2e1930005d91fc2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Mar 2024 00:12:57 +0000 Subject: [PATCH 616/865] Update gunicorn requirement from <21,>=20 to >=20,<22 (#1045) --- requirements/adapter.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/adapter.txt b/requirements/adapter.txt index 996538b16..0f65e38b3 100644 --- a/requirements/adapter.txt +++ b/requirements/adapter.txt @@ -18,5 +18,5 @@ sanic>=22,<23; python_version>"3.6" starlette>=0.14,<1 tornado>=6,<7 uvicorn<1 # The oldest version can vary among Python runtime versions -gunicorn>=20,<21 +gunicorn>=20,<22 websocket_client>=1.2.3,<2 # Socket Mode 3rd party implementation From b5e5439eaca05b10021d68964527b11e791d9ccd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Mar 2024 09:15:03 +0900 Subject: [PATCH 617/865] Update moto requirement from <5,>=3 to >=3,<6 (#1046) * Update moto requirement from <5,>=3 to >=3,<6 Updates the requirements on [moto](https://github.com/getmoto/moto) to permit the latest version. - [Release notes](https://github.com/getmoto/moto/releases) - [Changelog](https://github.com/getmoto/moto/blob/master/CHANGELOG.md) - [Commits](https://github.com/getmoto/moto/compare/3.0.0...5.0.2) --- updated-dependencies: - dependency-name: moto dependency-type: direct:production ... Signed-off-by: dependabot[bot] * Fix imports for moto This change allows the import to use the old and new version of the dependency --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: William Bergamin --- requirements/adapter_testing.txt | 2 +- tests/adapter_tests/aws/test_aws_lambda.py | 21 +++++++++++-------- .../aws/test_lambda_s3_oauth_flow.py | 9 +++++--- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/requirements/adapter_testing.txt b/requirements/adapter_testing.txt index 27d4d9fa2..f9c61d19b 100644 --- a/requirements/adapter_testing.txt +++ b/requirements/adapter_testing.txt @@ -1,5 +1,5 @@ # pip install -r requirements/adapter_testing.txt -moto>=3,<5 # For AWS tests +moto>=3,<6 # For AWS tests docker>=5,<6 # Used by moto boddle>=0.2,<0.3 # For Bottle app tests sanic-testing>=0.7; python_version>"3.6" diff --git a/tests/adapter_tests/aws/test_aws_lambda.py b/tests/adapter_tests/aws/test_aws_lambda.py index 3096dcaab..c2e888fb7 100644 --- a/tests/adapter_tests/aws/test_aws_lambda.py +++ b/tests/adapter_tests/aws/test_aws_lambda.py @@ -1,8 +1,6 @@ import json from time import time from urllib.parse import quote - -from moto import mock_lambda from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient from slack_sdk.oauth import OAuthStateStore @@ -20,6 +18,11 @@ ) from tests.utils import remove_os_env_temporarily, restore_os_env +try: + from moto import mock_aws +except ImportError: + from moto import mock_lambda as mock_aws + class LambdaContext: function_name: str @@ -72,7 +75,7 @@ def test_first_value(self): assert _first_value({"foo": []}, "foo") is None assert _first_value({}, "foo") is None - @mock_lambda + @mock_aws def test_clear_all_log_handlers(self): app = App( client=self.web_client, @@ -81,7 +84,7 @@ def test_clear_all_log_handlers(self): handler = SlackRequestHandler(app) handler.clear_all_log_handlers() - @mock_lambda + @mock_aws def test_events(self): app = App( client=self.web_client, @@ -136,7 +139,7 @@ def event_handler(): assert response["statusCode"] == 200 assert_auth_test_count(self, 1) - @mock_lambda + @mock_aws def test_shortcuts(self): app = App( client=self.web_client, @@ -186,7 +189,7 @@ def shortcut_handler(ack): assert response["statusCode"] == 200 assert_auth_test_count(self, 1) - @mock_lambda + @mock_aws def test_commands(self): app = App( client=self.web_client, @@ -236,7 +239,7 @@ def command_handler(ack): assert response["statusCode"] == 200 assert_auth_test_count(self, 1) - @mock_lambda + @mock_aws def test_lazy_listeners(self): app = App( client=self.web_client, @@ -282,7 +285,7 @@ def say_it(say): assert_auth_test_count(self, 1) assert_received_request_count(self, "/chat.postMessage", 1) - @mock_lambda + @mock_aws def test_oauth(self): app = App( client=self.web_client, @@ -318,7 +321,7 @@ def test_oauth(self): assert response["headers"]["content-type"] == "text/html; charset=utf-8" assert "https://slack.com/oauth/v2/authorize?state=" in response.get("body") - @mock_lambda + @mock_aws def test_oauth_redirect(self): class TestStateStore(OAuthStateStore): def consume(self, state: str) -> bool: diff --git a/tests/adapter_tests/aws/test_lambda_s3_oauth_flow.py b/tests/adapter_tests/aws/test_lambda_s3_oauth_flow.py index 0756bc06f..5f79b64ab 100644 --- a/tests/adapter_tests/aws/test_lambda_s3_oauth_flow.py +++ b/tests/adapter_tests/aws/test_lambda_s3_oauth_flow.py @@ -1,9 +1,12 @@ -from moto import mock_s3 - from slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow import LambdaS3OAuthFlow from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.utils import remove_os_env_temporarily, restore_os_env +try: + from moto import mock_aws +except ImportError: + from moto import mock_s3 as mock_aws + class TestLambdaS3OAuthFlow: def setup_method(self): @@ -12,7 +15,7 @@ def setup_method(self): def teardown_method(self): restore_os_env(self.old_os_env) - @mock_s3 + @mock_aws def test_instantiation(self): oauth_flow = LambdaS3OAuthFlow( settings=OAuthSettings( From 9b30107547e4553db3eaef7613c68de00dd638a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Mar 2024 19:39:56 +0000 Subject: [PATCH 618/865] Bump codecov/codecov-action from 3 to 4 (#1039) --- .github/workflows/codecov.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index e7a1dd938..0014169d2 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -1,4 +1,3 @@ -# TODO: This CI job hangs as of April 2023 name: Run codecov on: @@ -32,7 +31,9 @@ jobs: run: | pytest --cov=./slack_bolt/ --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: fail_ci_if_error: true verbose: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} From 6d9583646a10d4e291b9094d3f89b8b90cd49573 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Mar 2024 08:22:13 +0900 Subject: [PATCH 619/865] Update django requirement from <5,>=3 to >=3,<6 (#1051) Updates the requirements on [django](https://github.com/django/django) to permit the latest version. - [Commits](https://github.com/django/django/compare/3.0...5.0.3) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/adapter.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/adapter.txt b/requirements/adapter.txt index 0f65e38b3..c0efca4c6 100644 --- a/requirements/adapter.txt +++ b/requirements/adapter.txt @@ -6,7 +6,7 @@ bottle>=0.12,<1 chalice<=1.27.3; python_version=="3.6" chalice>=1.28,<2; python_version>"3.6" CherryPy>=18,<19 -Django>=3,<5 +Django>=3,<6 falcon>=2,<4; python_version<"3.11" falcon>=3.1.1,<4; python_version>="3.11" fastapi>=0.70.0,<1 From 87f7ac2b4a47c38d426682cba31fd245dc8c766d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Mar 2024 08:22:39 +0900 Subject: [PATCH 620/865] Update docker requirement from <6,>=5 to >=5,<8 (#1052) Updates the requirements on [docker](https://github.com/docker/docker-py) to permit the latest version. - [Release notes](https://github.com/docker/docker-py/releases) - [Commits](https://github.com/docker/docker-py/compare/5.0.0...7.0.0) --- updated-dependencies: - dependency-name: docker dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/adapter_testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/adapter_testing.txt b/requirements/adapter_testing.txt index f9c61d19b..3d829ee15 100644 --- a/requirements/adapter_testing.txt +++ b/requirements/adapter_testing.txt @@ -1,5 +1,5 @@ # pip install -r requirements/adapter_testing.txt moto>=3,<6 # For AWS tests -docker>=5,<6 # Used by moto +docker>=5,<8 # Used by moto boddle>=0.2,<0.3 # For Bottle app tests sanic-testing>=0.7; python_version>"3.6" From 2f94d43d9735f5e8f930fc8e9d5df83e5c4a6edc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Mar 2024 08:23:13 +0900 Subject: [PATCH 621/865] Update websockets requirement from <11 to <13 (#1054) Updates the requirements on [websockets](https://github.com/python-websockets/websockets) to permit the latest version. - [Release notes](https://github.com/python-websockets/websockets/releases) - [Commits](https://github.com/python-websockets/websockets/compare/1.0...12.0) --- updated-dependencies: - dependency-name: websockets dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/async.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/async.txt b/requirements/async.txt index f27c3baf6..dd105eb8b 100644 --- a/requirements/async.txt +++ b/requirements/async.txt @@ -1,3 +1,3 @@ # pip install -r requirements/async.txt aiohttp>=3,<4 -websockets<11 +websockets<13 From 12d2b5ea0ad91c3d863f7e746806636254af3444 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Mar 2024 08:39:20 +0900 Subject: [PATCH 622/865] Update sanic requirement from <23,>=22 to >=22,<24 (#1055) Updates the requirements on [sanic](https://github.com/sanic-org/sanic) to permit the latest version. - [Release notes](https://github.com/sanic-org/sanic/releases) - [Commits](https://github.com/sanic-org/sanic/compare/v22.3.0...v23.12.1) --- updated-dependencies: - dependency-name: sanic dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/adapter.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/adapter.txt b/requirements/adapter.txt index c0efca4c6..473735e36 100644 --- a/requirements/adapter.txt +++ b/requirements/adapter.txt @@ -14,7 +14,7 @@ Flask>=1,<4 Werkzeug>=2,<4 pyramid>=1,<3 sanic>=20,<21; python_version=="3.6" -sanic>=22,<23; python_version>"3.6" +sanic>=22,<24; python_version>"3.6" starlette>=0.14,<1 tornado>=6,<7 uvicorn<1 # The oldest version can vary among Python runtime versions From dfda23bbd3ac07b0aa8b586a9d7aae41db789744 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 12 Mar 2024 14:10:09 -0400 Subject: [PATCH 623/865] fix: dependabot schedule (#1056) * Update dependabot.yml * Update codecov.yml Tokenless has reached GitHub rate limit. Please upload using a token --- .github/dependabot.yml | 2 +- .github/workflows/codecov.yml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6baed210a..dc523d227 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,7 +6,7 @@ updates: - package-ecosystem: "pip" directory: "/" schedule: - interval: "weekly" + interval: "monthly" open-pull-requests-limit: 5 - package-ecosystem: "github-actions" directory: "/" diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 0014169d2..3520eb892 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -35,5 +35,4 @@ jobs: with: fail_ci_if_error: true verbose: true - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + token: ${{ secrets.CODECOV_TOKEN }} From 880c13323ac4956096e5396508a45ca0113d5aa6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Mar 2024 08:24:33 +0900 Subject: [PATCH 624/865] Update click requirement from <=8.0.4 to <=8.1.7 (#1057) * Update click requirement from <=8.0.4 to <=8.1.7 Updates the requirements on [click](https://github.com/pallets/click) to permit the latest version. - [Release notes](https://github.com/pallets/click/releases) - [Changelog](https://github.com/pallets/click/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/click/compare/0.1...8.1.7) --- updated-dependencies: - dependency-name: click dependency-type: direct:production ... Signed-off-by: dependabot[bot] * Update testing_without_asyncio.txt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: William Bergamin --- requirements/testing_without_asyncio.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements/testing_without_asyncio.txt b/requirements/testing_without_asyncio.txt index ddc8ee0b3..8e410dbd9 100644 --- a/requirements/testing_without_asyncio.txt +++ b/requirements/testing_without_asyncio.txt @@ -2,4 +2,3 @@ pytest>=6.2.5,<9 pytest-cov>=3,<5 black==22.8.0 # Until we drop Python 3.6 support, we have to stay with this version -click<=8.0.4 # black is affected by https://github.com/pallets/click/issues/2225 From 8f8220d6b66da9014a6405f13916eac6a8afc8c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Apr 2024 07:41:32 +0900 Subject: [PATCH 625/865] Update pytest-cov requirement from <5,>=3 to >=3,<6 (#1067) Updates the requirements on [pytest-cov](https://github.com/pytest-dev/pytest-cov) to permit the latest version. - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v3.0.0...v5.0.0) --- updated-dependencies: - dependency-name: pytest-cov dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/testing_without_asyncio.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/testing_without_asyncio.txt b/requirements/testing_without_asyncio.txt index 8e410dbd9..ec941179b 100644 --- a/requirements/testing_without_asyncio.txt +++ b/requirements/testing_without_asyncio.txt @@ -1,4 +1,4 @@ # pip install -r requirements/testing_without_asyncio.txt pytest>=6.2.5,<9 -pytest-cov>=3,<5 +pytest-cov>=3,<6 black==22.8.0 # Until we drop Python 3.6 support, we have to stay with this version From 690eaa4543228215df07f8ba36529efdc8c01e3a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 3 May 2024 10:13:46 +0900 Subject: [PATCH 626/865] Lock pytest version to 8.1 as workaround for tornado test issue --- requirements/testing_without_asyncio.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/testing_without_asyncio.txt b/requirements/testing_without_asyncio.txt index ec941179b..2b55125c7 100644 --- a/requirements/testing_without_asyncio.txt +++ b/requirements/testing_without_asyncio.txt @@ -1,4 +1,4 @@ # pip install -r requirements/testing_without_asyncio.txt -pytest>=6.2.5,<9 +pytest>=6.2.5,<8.2 # https://github.com/tornadoweb/tornado/issues/3375 pytest-cov>=3,<6 black==22.8.0 # Until we drop Python 3.6 support, we have to stay with this version From e5131c9b9ccd5be58926df254356c5ca3159c147 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 May 2024 10:25:39 +0900 Subject: [PATCH 627/865] Update gunicorn requirement from <22,>=20 to >=20,<23 (#1073) Updates the requirements on [gunicorn](https://github.com/benoitc/gunicorn) to permit the latest version. - [Release notes](https://github.com/benoitc/gunicorn/releases) - [Commits](https://github.com/benoitc/gunicorn/compare/20.0.0...22.0.0) --- updated-dependencies: - dependency-name: gunicorn dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kazuhiro Sera --- requirements/adapter.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/adapter.txt b/requirements/adapter.txt index 473735e36..d35a5f2cf 100644 --- a/requirements/adapter.txt +++ b/requirements/adapter.txt @@ -18,5 +18,5 @@ sanic>=22,<24; python_version>"3.6" starlette>=0.14,<1 tornado>=6,<7 uvicorn<1 # The oldest version can vary among Python runtime versions -gunicorn>=20,<22 +gunicorn>=20,<23 websocket_client>=1.2.3,<2 # Socket Mode 3rd party implementation From dbe2333046b582c087fd19d6812c8a523835f53c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 9 May 2024 10:53:55 +0900 Subject: [PATCH 628/865] Fix #1074 Customize user-facing message sent when an installation is not managed by bolt-python app (#1077) --- scripts/install_all_and_run_tests.sh | 14 +++++++------- slack_bolt/app/app.py | 15 +++++++++++++-- slack_bolt/app/async_app.py | 19 +++++++++++++++++-- .../authorization/async_internals.py | 5 ++--- .../async_multi_teams_authorization.py | 15 ++++++++++----- .../async_single_team_authorization.py | 19 +++++++++++++------ .../middleware/authorization/internals.py | 6 +++--- .../multi_teams_authorization.py | 15 ++++++++++----- .../single_team_authorization.py | 14 +++++++++----- .../test_single_team_authorization.py | 15 +++++++++++++-- .../test_single_team_authorization.py | 16 ++++++++++++++-- 11 files changed, 111 insertions(+), 42 deletions(-) diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index c5da9647e..831acc80c 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -14,24 +14,24 @@ python_version=`python --version | awk '{print $2}'` if [ ${python_version:0:3} == "3.6" ] then - pip install -r requirements.txt + pip install -U -r requirements.txt else pip install -e . fi if [[ $test_target != "" ]] then - pip install -r requirements/testing.txt && \ - pip install -r requirements/adapter.txt && \ - pip install -r requirements/adapter_testing.txt && \ + pip install -U -r requirements/testing.txt && \ + pip install -U -r requirements/adapter.txt && \ + pip install -U -r requirements/adapter_testing.txt && \ # To avoid errors due to the old versions of click forced by Chalice pip install -U pip click && \ black slack_bolt/ tests/ && \ pytest $1 else - pip install -r requirements/testing.txt && \ - pip install -r requirements/adapter.txt && \ - pip install -r requirements/adapter_testing.txt && \ + pip install -U -r requirements/testing.txt && \ + pip install -U -r requirements/adapter.txt && \ + pip install -U -r requirements/adapter_testing.txt && \ # To avoid errors due to the old versions of click forced by Chalice pip install -U pip click && \ black slack_bolt/ tests/ && \ diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index d01163636..a2e4f3aca 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -103,6 +103,7 @@ def __init__( # for multi-workspace apps before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, + user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[InstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, @@ -159,6 +160,8 @@ def message_hello(message, say): before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. + user_facing_authorize_error_message: The user-facing error message to display + when the app is installed but the installation is not managed by this app's installation store installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). @@ -178,7 +181,7 @@ def message_hello(message, say): `SslCheck` is a built-in middleware that handles ssl_check requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings. - verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. + verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests. listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will be used. """ @@ -348,6 +351,7 @@ def message_hello(message, say): ignoring_self_events_enabled=ignoring_self_events_enabled, ssl_check_enabled=ssl_check_enabled, url_verification_enabled=url_verification_enabled, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) def _init_middleware_list( @@ -357,6 +361,7 @@ def _init_middleware_list( ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, + user_facing_authorize_error_message: Optional[str] = None, ): if self._init_middleware_list_done: return @@ -385,13 +390,18 @@ def _init_middleware_list( SingleTeamAuthorization( auth_test_result=auth_test_result, base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) except SlackApiError as err: raise BoltError(error_auth_test_failure(err.response)) elif self._authorize is not None: self._middleware_list.append( - MultiTeamsAuthorization(authorize=self._authorize, base_logger=self._base_logger) + MultiTeamsAuthorization( + authorize=self._authorize, + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) ) else: raise BoltError(error_token_required()) @@ -401,6 +411,7 @@ def _init_middleware_list( authorize=self._authorize, base_logger=self._base_logger, user_token_resolution=self._oauth_flow.settings.user_token_resolution, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) if ignoring_self_events_enabled is True: diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index c70fc2e54..6f3366a19 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -114,6 +114,7 @@ def __init__( # for multi-workspace apps before_authorize: Optional[Union[AsyncMiddleware, Callable[..., Awaitable[Any]]]] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, + user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[AsyncInstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, @@ -167,6 +168,8 @@ async def message_hello(message, say): # async function before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. + user_facing_authorize_error_message: The user-facing error message to display + when the app is installed but the installation is not managed by this app's installation store installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `AsyncInstallationStore#async_find_bot()` if True (Default: False) request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). @@ -354,6 +357,7 @@ async def message_hello(message, say): # async function ignoring_self_events_enabled=ignoring_self_events_enabled, ssl_check_enabled=ssl_check_enabled, url_verification_enabled=url_verification_enabled, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) self._server: Optional[AsyncSlackAppServer] = None @@ -364,6 +368,7 @@ def _init_async_middleware_list( ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, + user_facing_authorize_error_message: Optional[str] = None, ): if self._init_middleware_list_done: return @@ -383,10 +388,19 @@ def _init_async_middleware_list( # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: - self._async_middleware_list.append(AsyncSingleTeamAuthorization(base_logger=self._base_logger)) + self._async_middleware_list.append( + AsyncSingleTeamAuthorization( + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) + ) elif self._async_authorize is not None: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize, base_logger=self._base_logger) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) ) else: raise BoltError(error_token_required()) @@ -396,6 +410,7 @@ def _init_async_middleware_list( authorize=self._async_authorize, base_logger=self._base_logger, user_token_resolution=self._async_oauth_flow.settings.user_token_resolution, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) diff --git a/slack_bolt/middleware/authorization/async_internals.py b/slack_bolt/middleware/authorization/async_internals.py index e465d50d2..b5d8264ca 100644 --- a/slack_bolt/middleware/authorization/async_internals.py +++ b/slack_bolt/middleware/authorization/async_internals.py @@ -1,4 +1,3 @@ -from slack_bolt.middleware.authorization.internals import _build_error_text from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse @@ -15,9 +14,9 @@ def _is_no_auth_required(req: AsyncBoltRequest) -> bool: return _is_url_verification(req) or _is_ssl_check(req) -def _build_error_response() -> BoltResponse: +def _build_user_facing_error_response(message: str) -> BoltResponse: # show an ephemeral message to the end-user return BoltResponse( status=200, - body=_build_error_text(), + body=message, ) diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index 3a89f0f2b..cbb38bc2f 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -6,8 +6,8 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from .async_authorization import AsyncAuthorization -from .async_internals import _build_error_response, _is_no_auth_required -from .internals import _is_no_auth_test_call_required, _build_error_text +from .async_internals import _build_user_facing_error_response, _is_no_auth_required +from .internals import _is_no_auth_test_call_required, _build_user_facing_authorize_error_message from ...authorization import AuthorizeResult from ...authorization.async_authorize import AsyncAuthorize @@ -21,6 +21,7 @@ def __init__( authorize: AsyncAuthorize, base_logger: Optional[Logger] = None, user_token_resolution: str = "authed_user", + user_facing_authorize_error_message: Optional[str] = None, ): """Multi-workspace authorization. @@ -28,10 +29,14 @@ def __init__( authorize: The function to authorize incoming requests from Slack. base_logger: The base logger user_token_resolution: "authed_user" or "actor" + user_facing_authorize_error_message: The user-facing error message when installation is not found """ self.authorize = authorize self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization, base_logger=base_logger) self.user_token_resolution = user_token_resolution + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) async def async_process( self, @@ -92,10 +97,10 @@ async def async_process( "the AuthorizeResult (returned value from authorize) for it was not found." ) if req.context.response_url is not None: - await req.context.respond(_build_error_text()) + await req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) diff --git a/slack_bolt/middleware/authorization/async_single_team_authorization.py b/slack_bolt/middleware/authorization/async_single_team_authorization.py index 8d3555a0e..695e17144 100644 --- a/slack_bolt/middleware/authorization/async_single_team_authorization.py +++ b/slack_bolt/middleware/authorization/async_single_team_authorization.py @@ -7,16 +7,23 @@ from slack_bolt.response import BoltResponse from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.errors import SlackApiError -from .async_internals import _build_error_response, _is_no_auth_required -from .internals import _to_authorize_result, _is_no_auth_test_call_required, _build_error_text +from .async_internals import _build_user_facing_error_response, _is_no_auth_required +from .internals import _to_authorize_result, _is_no_auth_test_call_required, _build_user_facing_authorize_error_message from ...authorization import AuthorizeResult class AsyncSingleTeamAuthorization(AsyncAuthorization): - def __init__(self, base_logger: Optional[Logger] = None): + def __init__( + self, + base_logger: Optional[Logger] = None, + user_facing_authorize_error_message: Optional[str] = None, + ): """Single-workspace authorization.""" self.auth_test_result: Optional[AsyncSlackResponse] = None self.logger = get_bolt_logger(AsyncSingleTeamAuthorization, base_logger=base_logger) + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) async def async_process( self, @@ -58,9 +65,9 @@ async def async_process( # Just in case self.logger.error("auth.test API call result is unexpectedly None") if req.context.response_url is not None: - await req.context.respond(_build_error_text()) + await req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) diff --git a/slack_bolt/middleware/authorization/internals.py b/slack_bolt/middleware/authorization/internals.py index af264854b..814101953 100644 --- a/slack_bolt/middleware/authorization/internals.py +++ b/slack_bolt/middleware/authorization/internals.py @@ -43,18 +43,18 @@ def _is_no_auth_test_call_required(req: Union[BoltRequest, "AsyncBoltRequest"]) return _is_no_auth_test_events(req) -def _build_error_text() -> str: +def _build_user_facing_authorize_error_message() -> str: return ( ":warning: We apologize, but for some unknown reason, your installation with this app is no longer available. " "Please reinstall this app into your workspace :bow:" ) -def _build_error_response() -> BoltResponse: +def _build_user_facing_error_response(message: str) -> BoltResponse: # show an ephemeral message to the end-user return BoltResponse( status=200, - body=_build_error_text(), + body=message, ) diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index 5d464d5e4..62972284b 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -8,10 +8,10 @@ from slack_bolt.response import BoltResponse from .authorization import Authorization from .internals import ( - _build_error_response, + _build_user_facing_error_response, _is_no_auth_required, _is_no_auth_test_call_required, - _build_error_text, + _build_user_facing_authorize_error_message, ) from ...authorization import AuthorizeResult from ...authorization.authorize import Authorize @@ -27,6 +27,7 @@ def __init__( authorize: Authorize, base_logger: Optional[Logger] = None, user_token_resolution: str = "authed_user", + user_facing_authorize_error_message: Optional[str] = None, ): """Multi-workspace authorization. @@ -34,10 +35,14 @@ def __init__( authorize: The function to authorize incoming requests from Slack. base_logger: The base logger user_token_resolution: "authed_user" or "actor" + user_facing_authorize_error_message: The user-facing error message when installation is not found """ self.authorize = authorize self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger) self.user_token_resolution = user_token_resolution + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) def process( self, @@ -95,10 +100,10 @@ def process( "the AuthorizeResult (returned value from authorize) for it was not found." ) if req.context.response_url is not None: - req.context.respond(_build_error_text()) + req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) diff --git a/slack_bolt/middleware/authorization/single_team_authorization.py b/slack_bolt/middleware/authorization/single_team_authorization.py index 54cdff5c8..6fe62ed6a 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -8,11 +8,11 @@ from slack_sdk.errors import SlackApiError from slack_sdk.web import SlackResponse from .internals import ( - _build_error_response, + _build_user_facing_error_response, _is_no_auth_required, _to_authorize_result, _is_no_auth_test_call_required, - _build_error_text, + _build_user_facing_authorize_error_message, ) from ...authorization import AuthorizeResult @@ -23,6 +23,7 @@ def __init__( *, auth_test_result: Optional[SlackResponse] = None, base_logger: Optional[Logger] = None, + user_facing_authorize_error_message: Optional[str] = None, ): """Single-workspace authorization. @@ -32,6 +33,9 @@ def __init__( """ self.auth_test_result = auth_test_result self.logger = get_bolt_logger(SingleTeamAuthorization, base_logger=base_logger) + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) def process( self, @@ -73,9 +77,9 @@ def process( # Just in case self.logger.error("auth.test API call result is unexpectedly None") if req.context.response_url is not None: - req.context.respond(_build_error_text()) + req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) diff --git a/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py index 7b7c2d6a0..4cf5d9600 100644 --- a/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py +++ b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py @@ -1,7 +1,7 @@ from slack_sdk import WebClient from slack_bolt.middleware import SingleTeamAuthorization -from slack_bolt.middleware.authorization.internals import _build_error_text +from slack_bolt.middleware.authorization.internals import _build_user_facing_authorize_error_message from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from tests.mock_web_api_server import ( @@ -43,4 +43,15 @@ def test_failure_pattern(self): resp = authorization.process(req=req, resp=resp, next=next) assert resp.status == 200 - assert resp.body == _build_error_text() + assert resp.body == _build_user_facing_authorize_error_message() + + def test_failure_pattern_custom_message(self): + authorization = SingleTeamAuthorization(auth_test_result={}, user_facing_authorize_error_message="foo") + req = BoltRequest(body="payload={}", headers={}) + req.context["client"] = WebClient(base_url=self.mock_api_server_base_url, token="dummy") + resp = BoltResponse(status=404) + + resp = authorization.process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "foo" diff --git a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py index 68112f2c0..ef1baded5 100644 --- a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py +++ b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py @@ -6,7 +6,7 @@ from slack_bolt.middleware.authorization.async_single_team_authorization import ( AsyncSingleTeamAuthorization, ) -from slack_bolt.middleware.authorization.internals import _build_error_text +from slack_bolt.middleware.authorization.internals import _build_user_facing_authorize_error_message from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from tests.mock_web_api_server import ( @@ -57,4 +57,16 @@ async def test_failure_pattern(self): resp = await authorization.async_process(req=req, resp=resp, next=next) assert resp.status == 200 - assert resp.body == _build_error_text() + assert resp.body == _build_user_facing_authorize_error_message() + + @pytest.mark.asyncio + async def test_failure_pattern_custom_message(self): + authorization = AsyncSingleTeamAuthorization(user_facing_authorize_error_message="foo") + req = AsyncBoltRequest(body="payload={}", headers={}) + req.context["client"] = AsyncWebClient(base_url=self.mock_api_server_base_url, token="dummy") + resp = BoltResponse(status=404) + + resp = await authorization.async_process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "foo" From 549252c833502ed7916adf9e399405c757c16edf Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 28 May 2024 00:02:08 +0000 Subject: [PATCH 629/865] feat: add WSGI adapter (#1085) --- examples/wsgi/app.py | 19 ++ examples/wsgi/oauth_app.py | 23 +++ examples/wsgi/requirements.txt | 1 + slack_bolt/adapter/wsgi/__init__.py | 3 + slack_bolt/adapter/wsgi/handler.py | 85 ++++++++ slack_bolt/adapter/wsgi/http_request.py | 37 ++++ slack_bolt/adapter/wsgi/http_response.py | 33 +++ slack_bolt/adapter/wsgi/internals.py | 1 + tests/adapter_tests/wsgi/__init__.py | 0 tests/adapter_tests/wsgi/test_wsgi_http.py | 230 +++++++++++++++++++++ tests/mock_wsgi_server.py | 116 +++++++++++ 11 files changed, 548 insertions(+) create mode 100644 examples/wsgi/app.py create mode 100644 examples/wsgi/oauth_app.py create mode 100644 examples/wsgi/requirements.txt create mode 100644 slack_bolt/adapter/wsgi/__init__.py create mode 100644 slack_bolt/adapter/wsgi/handler.py create mode 100644 slack_bolt/adapter/wsgi/http_request.py create mode 100644 slack_bolt/adapter/wsgi/http_response.py create mode 100644 slack_bolt/adapter/wsgi/internals.py create mode 100644 tests/adapter_tests/wsgi/__init__.py create mode 100644 tests/adapter_tests/wsgi/test_wsgi_http.py create mode 100644 tests/mock_wsgi_server.py diff --git a/examples/wsgi/app.py b/examples/wsgi/app.py new file mode 100644 index 000000000..d994ffbf9 --- /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 000000000..bdb844fd4 --- /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 000000000..5c3ac5752 --- /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 000000000..bf7cf78a4 --- /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 000000000..55537e464 --- /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 000000000..e410f4cc0 --- /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 000000000..1ad32e672 --- /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 000000000..cda3e876a --- /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 000000000..e69de29bb 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 000000000..63ac62627 --- /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 000000000..a389a898e --- /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 From 28827082d4431acd29558d2a9127f045c5179913 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 7 Jun 2024 13:32:50 +0000 Subject: [PATCH 630/865] feat: Add deprecation warnings to Steps from Apps components and Docs (#1089) * Add warnings and comments * deprecate in docs * add deprecation warnings to examples * Improve formating * Update docs/_config.yml * Update docs/_steps/workflow_steps_overview.md Co-authored-by: Fil Maj --------- Co-authored-by: Kazuhiro Sera Co-authored-by: Fil Maj --- docs/_config.yml | 4 +- docs/_includes/sidebar.html | 4 +- docs/_layouts/default.html | 4 +- docs/_steps/workflow_steps_overview.md | 5 +- docs/assets/style.css | 4 +- .../workflow_steps/async_steps_from_apps.py | 5 ++ .../async_steps_from_apps_decorator.py | 5 ++ .../async_steps_from_apps_primitive.py | 5 ++ examples/workflow_steps/steps_from_apps.py | 5 ++ .../steps_from_apps_decorator.py | 5 ++ .../steps_from_apps_primitive.py | 5 ++ slack_bolt/app/app.py | 16 ++++++- slack_bolt/app/async_app.py | 13 +++++ slack_bolt/workflows/step/async_step.py | 47 +++++++++++++++++-- slack_bolt/workflows/step/step.py | 47 +++++++++++++++++-- 15 files changed, 154 insertions(+), 20 deletions(-) diff --git a/docs/_config.yml b/docs/_config.yml index a8d7fa15c..b426f35e0 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -35,7 +35,7 @@ t: start: Getting started contribute: Contributing beta: BETA - legacy: LEGACY + deprecated: Deprecated ja-jp: basic: 基本的な概念 steps: ワークフローステップ @@ -43,7 +43,7 @@ t: start: Bolt 入門ガイド contribute: 貢献 beta: BETA - legacy: LEGACY + deprecated: 非推奨 # Metadata repo_name: bolt-python diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html index 970cf3869..860188e97 100644 --- a/docs/_includes/sidebar.html +++ b/docs/_includes/sidebar.html @@ -56,8 +56,8 @@
    @@ -131,6 +135,7 @@

    Index

  • slack_bolt.adapter.socket_mode
  • slack_bolt.adapter.starlette
  • slack_bolt.adapter.tornado
  • +
  • slack_bolt.adapter.wsgi
  • diff --git a/docs/api-docs/slack_bolt/adapter/sanic/async_handler.html b/docs/api-docs/slack_bolt/adapter/sanic/async_handler.html index 7619d773a..0ba5912ad 100644 --- a/docs/api-docs/slack_bolt/adapter/sanic/async_handler.html +++ b/docs/api-docs/slack_bolt/adapter/sanic/async_handler.html @@ -99,7 +99,7 @@

    Module slack_bolt.adapter.sanic.async_handler

    Functions
    -def to_async_bolt_request(req: sanic.request.Request) ‑> AsyncBoltRequest +def to_async_bolt_request(req: sanic.request.types.Request) ‑> AsyncBoltRequest
    @@ -116,7 +116,7 @@

    Functions

    -def to_sanic_response(bolt_resp: BoltResponse) ‑> sanic.response.HTTPResponse +def to_sanic_response(bolt_resp: BoltResponse) ‑> sanic.response.types.HTTPResponse
    @@ -188,7 +188,7 @@

    Classes

    Methods

    -async def handle(self, req: sanic.request.Request) ‑> sanic.response.HTTPResponse +async def handle(self, req: sanic.request.types.Request) ‑> sanic.response.types.HTTPResponse
    diff --git a/docs/api-docs/slack_bolt/adapter/sanic/index.html b/docs/api-docs/slack_bolt/adapter/sanic/index.html index 767ed7050..ca2e56bd2 100644 --- a/docs/api-docs/slack_bolt/adapter/sanic/index.html +++ b/docs/api-docs/slack_bolt/adapter/sanic/index.html @@ -86,7 +86,7 @@

    Classes

    Methods

    -async def handle(self, req: sanic.request.Request) ‑> sanic.response.HTTPResponse +async def handle(self, req: sanic.request.types.Request) ‑> sanic.response.types.HTTPResponse
    diff --git a/docs/api-docs/slack_bolt/adapter/wsgi/handler.html b/docs/api-docs/slack_bolt/adapter/wsgi/handler.html new file mode 100644 index 000000000..4a9422e37 --- /dev/null +++ b/docs/api-docs/slack_bolt/adapter/wsgi/handler.html @@ -0,0 +1,317 @@ + + + + + + +slack_bolt.adapter.wsgi.handler API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.adapter.wsgi.handler

    +
    +
    +
    + +Expand source code + +
    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}")
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class SlackRequestHandler +(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

    +

    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)
    +
    +
    + +Expand source code + +
    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}")
    +
    +

    Methods

    +
    +
    +def dispatch(self, request: WsgiHttpRequest) ‑> BoltResponse +
    +
    +
    +
    + +Expand source code + +
    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_callback(self, request: WsgiHttpRequest) ‑> BoltResponse +
    +
    +
    +
    + +Expand source code + +
    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 handle_installation(self, request: WsgiHttpRequest) ‑> BoltResponse +
    +
    +
    +
    + +Expand source code + +
    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())
    +    )
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/wsgi/http_request.html b/docs/api-docs/slack_bolt/adapter/wsgi/http_request.html new file mode 100644 index 000000000..8766ee705 --- /dev/null +++ b/docs/api-docs/slack_bolt/adapter/wsgi/http_request.html @@ -0,0 +1,223 @@ + + + + + + +slack_bolt.adapter.wsgi.http_request API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.adapter.wsgi.http_request

    +
    +
    +
    + +Expand source code + +
    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)
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class WsgiHttpRequest +(environ: Dict[str, Any]) +
    +
    +

    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/

    +
    + +Expand source code + +
    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)
    +
    +

    Instance variables

    +
    +
    var environ
    +
    +

    Return an attribute of instance, which is of type owner.

    +
    +
    var method
    +
    +

    Return an attribute of instance, which is of type owner.

    +
    +
    var path
    +
    +

    Return an attribute of instance, which is of type owner.

    +
    +
    var protocol
    +
    +

    Return an attribute of instance, which is of type owner.

    +
    +
    var query_string
    +
    +

    Return an attribute of instance, which is of type owner.

    +
    +
    +

    Methods

    +
    +
    +def get_body(self) ‑> str +
    +
    +
    +
    + +Expand source code + +
    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)
    +
    +
    +
    +def get_headers(self) ‑> Dict[str, str] +
    +
    +
    +
    + +Expand source code + +
    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
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/wsgi/http_response.html b/docs/api-docs/slack_bolt/adapter/wsgi/http_response.html new file mode 100644 index 000000000..d91da60bd --- /dev/null +++ b/docs/api-docs/slack_bolt/adapter/wsgi/http_response.html @@ -0,0 +1,190 @@ + + + + + + +slack_bolt.adapter.wsgi.http_response API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.adapter.wsgi.http_response

    +
    +
    +
    + +Expand source code + +
    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]
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class WsgiHttpResponse +(status: int, headers: Dict[str, Sequence[str]] = {}, body: str = '') +
    +
    +

    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/

    +
    + +Expand source code + +
    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]
    +
    +

    Instance variables

    +
    +
    var status
    +
    +

    Return an attribute of instance, which is of type owner.

    +
    +
    +

    Methods

    +
    +
    +def get_body(self) ‑> Iterable[bytes] +
    +
    +
    +
    + +Expand source code + +
    def get_body(self) -> Iterable[bytes]:
    +    return [self._body]
    +
    +
    +
    +def get_headers(self) ‑> List[Tuple[str, str]] +
    +
    +
    +
    + +Expand source code + +
    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
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/wsgi/index.html b/docs/api-docs/slack_bolt/adapter/wsgi/index.html new file mode 100644 index 000000000..a6ed350bf --- /dev/null +++ b/docs/api-docs/slack_bolt/adapter/wsgi/index.html @@ -0,0 +1,262 @@ + + + + + + +slack_bolt.adapter.wsgi API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.adapter.wsgi

    +
    +
    +
    + +Expand source code + +
    from .handler import SlackRequestHandler
    +
    +__all__ = ["SlackRequestHandler"]
    +
    +
    +
    +

    Sub-modules

    +
    +
    slack_bolt.adapter.wsgi.handler
    +
    +
    +
    +
    slack_bolt.adapter.wsgi.http_request
    +
    +
    +
    +
    slack_bolt.adapter.wsgi.http_response
    +
    +
    +
    +
    slack_bolt.adapter.wsgi.internals
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class SlackRequestHandler +(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

    +

    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)
    +
    +
    + +Expand source code + +
    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}")
    +
    +

    Methods

    +
    +
    +def dispatch(self, request: WsgiHttpRequest) ‑> BoltResponse +
    +
    +
    +
    + +Expand source code + +
    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_callback(self, request: WsgiHttpRequest) ‑> BoltResponse +
    +
    +
    +
    + +Expand source code + +
    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 handle_installation(self, request: WsgiHttpRequest) ‑> BoltResponse +
    +
    +
    +
    + +Expand source code + +
    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())
    +    )
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/adapter/wsgi/internals.html b/docs/api-docs/slack_bolt/adapter/wsgi/internals.html new file mode 100644 index 000000000..b953c1183 --- /dev/null +++ b/docs/api-docs/slack_bolt/adapter/wsgi/internals.html @@ -0,0 +1,59 @@ + + + + + + +slack_bolt.adapter.wsgi.internals API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.adapter.wsgi.internals

    +
    +
    +
    + +Expand source code + +
    ENCODING = "utf-8"  # The content encoding for Slack requests/responses is always utf-8
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/docs/api-docs/slack_bolt/app/app.html b/docs/api-docs/slack_bolt/app/app.html index 3e01431fc..11a307714 100644 --- a/docs/api-docs/slack_bolt/app/app.html +++ b/docs/api-docs/slack_bolt/app/app.html @@ -31,6 +31,7 @@

    Module slack_bolt.app.app

    import logging import os import time +import warnings from concurrent.futures import Executor from concurrent.futures.thread import ThreadPoolExecutor from http.server import SimpleHTTPRequestHandler, HTTPServer @@ -131,6 +132,7 @@

    Module slack_bolt.app.app

    # for multi-workspace apps before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, + user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[InstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, @@ -187,6 +189,8 @@

    Module slack_bolt.app.app

    before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. + user_facing_authorize_error_message: The user-facing error message to display + when the app is installed but the installation is not managed by this app's installation store installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). @@ -206,7 +210,7 @@

    Module slack_bolt.app.app

    `SslCheck` is a built-in middleware that handles ssl_check requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings. - verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. + verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests. listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will be used. """ @@ -376,6 +380,7 @@

    Module slack_bolt.app.app

    ignoring_self_events_enabled=ignoring_self_events_enabled, ssl_check_enabled=ssl_check_enabled, url_verification_enabled=url_verification_enabled, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) def _init_middleware_list( @@ -385,6 +390,7 @@

    Module slack_bolt.app.app

    ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, + user_facing_authorize_error_message: Optional[str] = None, ): if self._init_middleware_list_done: return @@ -413,13 +419,18 @@

    Module slack_bolt.app.app

    SingleTeamAuthorization( auth_test_result=auth_test_result, base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) except SlackApiError as err: raise BoltError(error_auth_test_failure(err.response)) elif self._authorize is not None: self._middleware_list.append( - MultiTeamsAuthorization(authorize=self._authorize, base_logger=self._base_logger) + MultiTeamsAuthorization( + authorize=self._authorize, + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) ) else: raise BoltError(error_token_required()) @@ -429,6 +440,7 @@

    Module slack_bolt.app.app

    authorize=self._authorize, base_logger=self._base_logger, user_token_resolution=self._oauth_flow.settings.user_token_resolution, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) if ignoring_self_events_enabled is True: @@ -676,7 +688,13 @@

    Module slack_bolt.app.app

    save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, ): - """Registers a new Workflow Step listener. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new Workflow Step listener. + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use `WorkflowStepBuilder`'s methods. @@ -705,6 +723,13 @@

    Module slack_bolt.app.app

    save: The function for handling configuration in the Workflow Builder execute: The function for handling the step execution """ + warnings.warn( + ( + "Steps from Apps for legacy workflows are now deprecated. " + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + ), + category=DeprecationWarning, + ) step = callback_id if isinstance(callback_id, (str, Pattern)): step = WorkflowStep( @@ -1485,7 +1510,7 @@

    Classes

    class App -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, before_authorize: Union[Middleware, Callable[..., Any], ForwardRef(None)] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, before_authorize: Union[Middleware, Callable[..., Any], ForwardRef(None)] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None)

    Bolt App that provides functionalities to register middleware/listeners.

    @@ -1536,6 +1561,9 @@

    Args

    authorize
    The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data.
    +
    user_facing_authorize_error_message
    +
    The user-facing error message to display +when the app is installed but the installation is not managed by this app's installation store
    installation_store
    The module offering save/find operations of installation data
    installation_store_bot_only
    @@ -1564,7 +1592,7 @@

    Args

    oauth_flow
    Instantiated OAuthFlow. This is always prioritized over oauth_settings.
    verification_token
    -
    Deprecated verification mechanism. This can used only for ssl_check requests.
    +
    Deprecated verification mechanism. This can be used only for ssl_check requests.
    listener_executor
    Custom executor to run background tasks. If absent, the default ThreadPoolExecutor will be used.
    @@ -1593,6 +1621,7 @@

    Args

    # for multi-workspace apps before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, + user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[InstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, @@ -1649,6 +1678,8 @@

    Args

    before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. + user_facing_authorize_error_message: The user-facing error message to display + when the app is installed but the installation is not managed by this app's installation store installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). @@ -1668,7 +1699,7 @@

    Args

    `SslCheck` is a built-in middleware that handles ssl_check requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings. - verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. + verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests. listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will be used. """ @@ -1838,6 +1869,7 @@

    Args

    ignoring_self_events_enabled=ignoring_self_events_enabled, ssl_check_enabled=ssl_check_enabled, url_verification_enabled=url_verification_enabled, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) def _init_middleware_list( @@ -1847,6 +1879,7 @@

    Args

    ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, + user_facing_authorize_error_message: Optional[str] = None, ): if self._init_middleware_list_done: return @@ -1875,13 +1908,18 @@

    Args

    SingleTeamAuthorization( auth_test_result=auth_test_result, base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) except SlackApiError as err: raise BoltError(error_auth_test_failure(err.response)) elif self._authorize is not None: self._middleware_list.append( - MultiTeamsAuthorization(authorize=self._authorize, base_logger=self._base_logger) + MultiTeamsAuthorization( + authorize=self._authorize, + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) ) else: raise BoltError(error_token_required()) @@ -1891,6 +1929,7 @@

    Args

    authorize=self._authorize, base_logger=self._base_logger, user_token_resolution=self._oauth_flow.settings.user_token_resolution, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) if ignoring_self_events_enabled is True: @@ -2138,7 +2177,13 @@

    Args

    save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, ): - """Registers a new Workflow Step listener. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new Workflow Step listener. + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use `WorkflowStepBuilder`'s methods. @@ -2167,6 +2212,13 @@

    Args

    save: The function for handling configuration in the Workflow Builder execute: The function for handling the step execution """ + warnings.warn( + ( + "Steps from Apps for legacy workflows are now deprecated. " + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + ), + category=DeprecationWarning, + ) step = callback_id if isinstance(callback_id, (str, Pattern)): step = WorkflowStep( @@ -3949,8 +4001,11 @@

    Args

    def step(self, callback_id: Union[str, Pattern, WorkflowStepWorkflowStepBuilder], edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None, save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None, execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None)
    -

    Registers a new Workflow Step listener. -Unlike others, this method doesn't behave as a decorator. +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Registers a new Workflow Step listener.

    +

    Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use WorkflowStepBuilder's methods.

    # Create a new WorkflowStep instance
     from slack_bolt.workflows.step import WorkflowStep
    @@ -3990,7 +4045,13 @@ 

    Args

    save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, ): - """Registers a new Workflow Step listener. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new Workflow Step listener. + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use `WorkflowStepBuilder`'s methods. @@ -4019,6 +4080,13 @@

    Args

    save: The function for handling configuration in the Workflow Builder execute: The function for handling the step execution """ + warnings.warn( + ( + "Steps from Apps for legacy workflows are now deprecated. " + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + ), + category=DeprecationWarning, + ) step = callback_id if isinstance(callback_id, (str, Pattern)): step = WorkflowStep( diff --git a/docs/api-docs/slack_bolt/app/async_app.html b/docs/api-docs/slack_bolt/app/async_app.html index a1e457055..1449a1c54 100644 --- a/docs/api-docs/slack_bolt/app/async_app.html +++ b/docs/api-docs/slack_bolt/app/async_app.html @@ -31,6 +31,7 @@

    Module slack_bolt.app.async_app

    import os import time from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable, Sequence, Any +import warnings from aiohttp import web @@ -142,6 +143,7 @@

    Module slack_bolt.app.async_app

    # for multi-workspace apps before_authorize: Optional[Union[AsyncMiddleware, Callable[..., Awaitable[Any]]]] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, + user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[AsyncInstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, @@ -195,6 +197,8 @@

    Module slack_bolt.app.async_app

    before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. + user_facing_authorize_error_message: The user-facing error message to display + when the app is installed but the installation is not managed by this app's installation store installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `AsyncInstallationStore#async_find_bot()` if True (Default: False) request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). @@ -382,6 +386,7 @@

    Module slack_bolt.app.async_app

    ignoring_self_events_enabled=ignoring_self_events_enabled, ssl_check_enabled=ssl_check_enabled, url_verification_enabled=url_verification_enabled, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) self._server: Optional[AsyncSlackAppServer] = None @@ -392,6 +397,7 @@

    Module slack_bolt.app.async_app

    ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, + user_facing_authorize_error_message: Optional[str] = None, ): if self._init_middleware_list_done: return @@ -411,10 +417,19 @@

    Module slack_bolt.app.async_app

    # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: - self._async_middleware_list.append(AsyncSingleTeamAuthorization(base_logger=self._base_logger)) + self._async_middleware_list.append( + AsyncSingleTeamAuthorization( + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) + ) elif self._async_authorize is not None: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize, base_logger=self._base_logger) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) ) else: raise BoltError(error_token_required()) @@ -424,6 +439,7 @@

    Module slack_bolt.app.async_app

    authorize=self._async_authorize, base_logger=self._base_logger, user_token_resolution=self._async_oauth_flow.settings.user_token_resolution, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) @@ -702,7 +718,12 @@

    Module slack_bolt.app.async_app

    execute: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None, ): """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Registers a new Workflow Step listener. + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use `AsyncWorkflowStepBuilder`'s methods. @@ -730,6 +751,13 @@

    Module slack_bolt.app.async_app

    save: The function for handling configuration in the Workflow Builder execute: The function for handling the step execution """ + warnings.warn( + ( + "Steps from Apps for legacy workflows are now deprecated. " + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + ), + category=DeprecationWarning, + ) step = callback_id if isinstance(callback_id, (str, Pattern)): step = AsyncWorkflowStep( @@ -1399,7 +1427,7 @@

    Classes

    class AsyncApp -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, before_authorize: Union[AsyncMiddleware, Callable[..., Awaitable[Any]], ForwardRef(None)] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, verification_token: Optional[str] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, before_authorize: Union[AsyncMiddleware, Callable[..., Awaitable[Any]], ForwardRef(None)] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, verification_token: Optional[str] = None)

    Bolt App that provides functionalities to register middleware/listeners.

    @@ -1448,6 +1476,9 @@

    Args

    authorize
    The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data.
    +
    user_facing_authorize_error_message
    +
    The user-facing error message to display +when the app is installed but the installation is not managed by this app's installation store
    installation_store
    The module offering save/find operations of installation data
    installation_store_bot_only
    @@ -1501,6 +1532,7 @@

    Args

    # for multi-workspace apps before_authorize: Optional[Union[AsyncMiddleware, Callable[..., Awaitable[Any]]]] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, + user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[AsyncInstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, @@ -1554,6 +1586,8 @@

    Args

    before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. + user_facing_authorize_error_message: The user-facing error message to display + when the app is installed but the installation is not managed by this app's installation store installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `AsyncInstallationStore#async_find_bot()` if True (Default: False) request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). @@ -1741,6 +1775,7 @@

    Args

    ignoring_self_events_enabled=ignoring_self_events_enabled, ssl_check_enabled=ssl_check_enabled, url_verification_enabled=url_verification_enabled, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) self._server: Optional[AsyncSlackAppServer] = None @@ -1751,6 +1786,7 @@

    Args

    ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, + user_facing_authorize_error_message: Optional[str] = None, ): if self._init_middleware_list_done: return @@ -1770,10 +1806,19 @@

    Args

    # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: - self._async_middleware_list.append(AsyncSingleTeamAuthorization(base_logger=self._base_logger)) + self._async_middleware_list.append( + AsyncSingleTeamAuthorization( + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) + ) elif self._async_authorize is not None: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize, base_logger=self._base_logger) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) ) else: raise BoltError(error_token_required()) @@ -1783,6 +1828,7 @@

    Args

    authorize=self._async_authorize, base_logger=self._base_logger, user_token_resolution=self._async_oauth_flow.settings.user_token_resolution, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) @@ -2061,7 +2107,12 @@

    Args

    execute: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None, ): """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Registers a new Workflow Step listener. + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use `AsyncWorkflowStepBuilder`'s methods. @@ -2089,6 +2140,13 @@

    Args

    save: The function for handling configuration in the Workflow Builder execute: The function for handling the step execution """ + warnings.warn( + ( + "Steps from Apps for legacy workflows are now deprecated. " + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + ), + category=DeprecationWarning, + ) step = callback_id if isinstance(callback_id, (str, Pattern)): step = AsyncWorkflowStep( @@ -3925,8 +3983,11 @@

    Args

    def step(self, callback_id: Union[str, Pattern, AsyncWorkflowStepAsyncWorkflowStepBuilder], edit: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], ForwardRef(None)] = None, save: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], ForwardRef(None)] = None, execute: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], ForwardRef(None)] = None)
    -

    Registers a new Workflow Step listener. -Unlike others, this method doesn't behave as a decorator. +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Registers a new Workflow Step listener.

    +

    Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use AsyncWorkflowStepBuilder's methods.

    # Create a new WorkflowStep instance
     from slack_bolt.workflows.async_step import AsyncWorkflowStep
    @@ -3967,7 +4028,12 @@ 

    Args

    execute: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None, ): """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Registers a new Workflow Step listener. + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use `AsyncWorkflowStepBuilder`'s methods. @@ -3995,6 +4061,13 @@

    Args

    save: The function for handling configuration in the Workflow Builder execute: The function for handling the step execution """ + warnings.warn( + ( + "Steps from Apps for legacy workflows are now deprecated. " + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + ), + category=DeprecationWarning, + ) step = callback_id if isinstance(callback_id, (str, Pattern)): step = AsyncWorkflowStep( diff --git a/docs/api-docs/slack_bolt/app/index.html b/docs/api-docs/slack_bolt/app/index.html index 6604ab567..e7bfccc08 100644 --- a/docs/api-docs/slack_bolt/app/index.html +++ b/docs/api-docs/slack_bolt/app/index.html @@ -72,7 +72,7 @@

    Classes

    class App -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, before_authorize: Union[Middleware, Callable[..., Any], ForwardRef(None)] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, before_authorize: Union[Middleware, Callable[..., Any], ForwardRef(None)] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None)

    Bolt App that provides functionalities to register middleware/listeners.

    @@ -123,6 +123,9 @@

    Args

    authorize
    The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data.
    +
    user_facing_authorize_error_message
    +
    The user-facing error message to display +when the app is installed but the installation is not managed by this app's installation store
    installation_store
    The module offering save/find operations of installation data
    installation_store_bot_only
    @@ -151,7 +154,7 @@

    Args

    oauth_flow
    Instantiated OAuthFlow. This is always prioritized over oauth_settings.
    verification_token
    -
    Deprecated verification mechanism. This can used only for ssl_check requests.
    +
    Deprecated verification mechanism. This can be used only for ssl_check requests.
    listener_executor
    Custom executor to run background tasks. If absent, the default ThreadPoolExecutor will be used.
    @@ -180,6 +183,7 @@

    Args

    # for multi-workspace apps before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, + user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[InstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, @@ -236,6 +240,8 @@

    Args

    before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. + user_facing_authorize_error_message: The user-facing error message to display + when the app is installed but the installation is not managed by this app's installation store installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). @@ -255,7 +261,7 @@

    Args

    `SslCheck` is a built-in middleware that handles ssl_check requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings. - verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. + verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests. listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will be used. """ @@ -425,6 +431,7 @@

    Args

    ignoring_self_events_enabled=ignoring_self_events_enabled, ssl_check_enabled=ssl_check_enabled, url_verification_enabled=url_verification_enabled, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) def _init_middleware_list( @@ -434,6 +441,7 @@

    Args

    ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, + user_facing_authorize_error_message: Optional[str] = None, ): if self._init_middleware_list_done: return @@ -462,13 +470,18 @@

    Args

    SingleTeamAuthorization( auth_test_result=auth_test_result, base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) except SlackApiError as err: raise BoltError(error_auth_test_failure(err.response)) elif self._authorize is not None: self._middleware_list.append( - MultiTeamsAuthorization(authorize=self._authorize, base_logger=self._base_logger) + MultiTeamsAuthorization( + authorize=self._authorize, + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) ) else: raise BoltError(error_token_required()) @@ -478,6 +491,7 @@

    Args

    authorize=self._authorize, base_logger=self._base_logger, user_token_resolution=self._oauth_flow.settings.user_token_resolution, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) if ignoring_self_events_enabled is True: @@ -725,7 +739,13 @@

    Args

    save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, ): - """Registers a new Workflow Step listener. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new Workflow Step listener. + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use `WorkflowStepBuilder`'s methods. @@ -754,6 +774,13 @@

    Args

    save: The function for handling configuration in the Workflow Builder execute: The function for handling the step execution """ + warnings.warn( + ( + "Steps from Apps for legacy workflows are now deprecated. " + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + ), + category=DeprecationWarning, + ) step = callback_id if isinstance(callback_id, (str, Pattern)): step = WorkflowStep( @@ -2536,8 +2563,11 @@

    Args

    def step(self, callback_id: Union[str, Pattern, WorkflowStepWorkflowStepBuilder], edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None, save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None, execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None)
    -

    Registers a new Workflow Step listener. -Unlike others, this method doesn't behave as a decorator. +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Registers a new Workflow Step listener.

    +

    Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use WorkflowStepBuilder's methods.

    # Create a new WorkflowStep instance
     from slack_bolt.workflows.step import WorkflowStep
    @@ -2577,7 +2607,13 @@ 

    Args

    save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, ): - """Registers a new Workflow Step listener. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new Workflow Step listener. + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use `WorkflowStepBuilder`'s methods. @@ -2606,6 +2642,13 @@

    Args

    save: The function for handling configuration in the Workflow Builder execute: The function for handling the step execution """ + warnings.warn( + ( + "Steps from Apps for legacy workflows are now deprecated. " + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + ), + category=DeprecationWarning, + ) step = callback_id if isinstance(callback_id, (str, Pattern)): step = WorkflowStep( diff --git a/docs/api-docs/slack_bolt/async_app.html b/docs/api-docs/slack_bolt/async_app.html index 1e066fb03..e9ae54e14 100644 --- a/docs/api-docs/slack_bolt/async_app.html +++ b/docs/api-docs/slack_bolt/async_app.html @@ -195,7 +195,7 @@

    Class variables

    class AsyncApp -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, before_authorize: Union[AsyncMiddleware, Callable[..., Awaitable[Any]], ForwardRef(None)] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, verification_token: Optional[str] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, client: Optional[slack_sdk.web.async_client.AsyncWebClient] = None, before_authorize: Union[AsyncMiddleware, Callable[..., Awaitable[Any]], ForwardRef(None)] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, verification_token: Optional[str] = None)

    Bolt App that provides functionalities to register middleware/listeners.

    @@ -244,6 +244,9 @@

    Args

    authorize
    The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data.
    +
    user_facing_authorize_error_message
    +
    The user-facing error message to display +when the app is installed but the installation is not managed by this app's installation store
    installation_store
    The module offering save/find operations of installation data
    installation_store_bot_only
    @@ -297,6 +300,7 @@

    Args

    # for multi-workspace apps before_authorize: Optional[Union[AsyncMiddleware, Callable[..., Awaitable[Any]]]] = None, authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, + user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[AsyncInstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, @@ -350,6 +354,8 @@

    Args

    before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. + user_facing_authorize_error_message: The user-facing error message to display + when the app is installed but the installation is not managed by this app's installation store installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `AsyncInstallationStore#async_find_bot()` if True (Default: False) request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). @@ -537,6 +543,7 @@

    Args

    ignoring_self_events_enabled=ignoring_self_events_enabled, ssl_check_enabled=ssl_check_enabled, url_verification_enabled=url_verification_enabled, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) self._server: Optional[AsyncSlackAppServer] = None @@ -547,6 +554,7 @@

    Args

    ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, + user_facing_authorize_error_message: Optional[str] = None, ): if self._init_middleware_list_done: return @@ -566,10 +574,19 @@

    Args

    # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: - self._async_middleware_list.append(AsyncSingleTeamAuthorization(base_logger=self._base_logger)) + self._async_middleware_list.append( + AsyncSingleTeamAuthorization( + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) + ) elif self._async_authorize is not None: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize, base_logger=self._base_logger) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) ) else: raise BoltError(error_token_required()) @@ -579,6 +596,7 @@

    Args

    authorize=self._async_authorize, base_logger=self._base_logger, user_token_resolution=self._async_oauth_flow.settings.user_token_resolution, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) @@ -857,7 +875,12 @@

    Args

    execute: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None, ): """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Registers a new Workflow Step listener. + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use `AsyncWorkflowStepBuilder`'s methods. @@ -885,6 +908,13 @@

    Args

    save: The function for handling configuration in the Workflow Builder execute: The function for handling the step execution """ + warnings.warn( + ( + "Steps from Apps for legacy workflows are now deprecated. " + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + ), + category=DeprecationWarning, + ) step = callback_id if isinstance(callback_id, (str, Pattern)): step = AsyncWorkflowStep( @@ -2721,8 +2751,11 @@

    Args

    def step(self, callback_id: Union[str, Pattern, AsyncWorkflowStepAsyncWorkflowStepBuilder], edit: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], ForwardRef(None)] = None, save: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], ForwardRef(None)] = None, execute: Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable], ForwardRef(None)] = None)
    -

    Registers a new Workflow Step listener. -Unlike others, this method doesn't behave as a decorator. +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Registers a new Workflow Step listener.

    +

    Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use AsyncWorkflowStepBuilder's methods.

    # Create a new WorkflowStep instance
     from slack_bolt.workflows.async_step import AsyncWorkflowStep
    @@ -2763,7 +2796,12 @@ 

    Args

    execute: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None, ): """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Registers a new Workflow Step listener. + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use `AsyncWorkflowStepBuilder`'s methods. @@ -2791,6 +2829,13 @@

    Args

    save: The function for handling configuration in the Workflow Builder execute: The function for handling the step execution """ + warnings.warn( + ( + "Steps from Apps for legacy workflows are now deprecated. " + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + ), + category=DeprecationWarning, + ) step = callback_id if isinstance(callback_id, (str, Pattern)): step = AsyncWorkflowStep( diff --git a/docs/api-docs/slack_bolt/index.html b/docs/api-docs/slack_bolt/index.html index 2578ab1f2..f41193602 100644 --- a/docs/api-docs/slack_bolt/index.html +++ b/docs/api-docs/slack_bolt/index.html @@ -210,7 +210,7 @@

    Class variables

    class App -(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, before_authorize: Union[Middleware, Callable[..., Any], ForwardRef(None)] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None) +(*, logger: Optional[logging.Logger] = None, name: Optional[str] = None, process_before_response: bool = False, raise_error_for_unhandled_request: bool = False, signing_secret: Optional[str] = None, token: Optional[str] = None, token_verification_enabled: bool = True, client: Optional[slack_sdk.web.client.WebClient] = None, before_authorize: Union[Middleware, Callable[..., Any], ForwardRef(None)] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[slack_sdk.oauth.installation_store.installation_store.InstallationStore] = None, installation_store_bot_only: Optional[bool] = None, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, verification_token: Optional[str] = None, listener_executor: Optional[concurrent.futures._base.Executor] = None)

    Bolt App that provides functionalities to register middleware/listeners.

    @@ -261,6 +261,9 @@

    Args

    authorize
    The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data.
    +
    user_facing_authorize_error_message
    +
    The user-facing error message to display +when the app is installed but the installation is not managed by this app's installation store
    installation_store
    The module offering save/find operations of installation data
    installation_store_bot_only
    @@ -289,7 +292,7 @@

    Args

    oauth_flow
    Instantiated OAuthFlow. This is always prioritized over oauth_settings.
    verification_token
    -
    Deprecated verification mechanism. This can used only for ssl_check requests.
    +
    Deprecated verification mechanism. This can be used only for ssl_check requests.
    listener_executor
    Custom executor to run background tasks. If absent, the default ThreadPoolExecutor will be used.
    @@ -318,6 +321,7 @@

    Args

    # for multi-workspace apps before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None, authorize: Optional[Callable[..., AuthorizeResult]] = None, + user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[InstallationStore] = None, # for either only bot scope usage or v1.0.x compatibility installation_store_bot_only: Optional[bool] = None, @@ -374,6 +378,8 @@

    Args

    before_authorize: A global middleware that can be executed right before authorize function authorize: The function to authorize an incoming request from Slack by checking if there is a team/user in the installation data. + user_facing_authorize_error_message: The user-facing error message to display + when the app is installed but the installation is not managed by this app's installation store installation_store: The module offering save/find operations of installation data installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). @@ -393,7 +399,7 @@

    Args

    `SslCheck` is a built-in middleware that handles ssl_check requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings. - verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. + verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests. listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will be used. """ @@ -563,6 +569,7 @@

    Args

    ignoring_self_events_enabled=ignoring_self_events_enabled, ssl_check_enabled=ssl_check_enabled, url_verification_enabled=url_verification_enabled, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) def _init_middleware_list( @@ -572,6 +579,7 @@

    Args

    ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, + user_facing_authorize_error_message: Optional[str] = None, ): if self._init_middleware_list_done: return @@ -600,13 +608,18 @@

    Args

    SingleTeamAuthorization( auth_test_result=auth_test_result, base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) except SlackApiError as err: raise BoltError(error_auth_test_failure(err.response)) elif self._authorize is not None: self._middleware_list.append( - MultiTeamsAuthorization(authorize=self._authorize, base_logger=self._base_logger) + MultiTeamsAuthorization( + authorize=self._authorize, + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) ) else: raise BoltError(error_token_required()) @@ -616,6 +629,7 @@

    Args

    authorize=self._authorize, base_logger=self._base_logger, user_token_resolution=self._oauth_flow.settings.user_token_resolution, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) ) if ignoring_self_events_enabled is True: @@ -863,7 +877,13 @@

    Args

    save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, ): - """Registers a new Workflow Step listener. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new Workflow Step listener. + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use `WorkflowStepBuilder`'s methods. @@ -892,6 +912,13 @@

    Args

    save: The function for handling configuration in the Workflow Builder execute: The function for handling the step execution """ + warnings.warn( + ( + "Steps from Apps for legacy workflows are now deprecated. " + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + ), + category=DeprecationWarning, + ) step = callback_id if isinstance(callback_id, (str, Pattern)): step = WorkflowStep( @@ -2674,8 +2701,11 @@

    Args

    def step(self, callback_id: Union[str, Pattern, WorkflowStepWorkflowStepBuilder], edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None, save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None, execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable], ForwardRef(None)] = None)
    -

    Registers a new Workflow Step listener. -Unlike others, this method doesn't behave as a decorator. +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Registers a new Workflow Step listener.

    +

    Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use WorkflowStepBuilder's methods.

    # Create a new WorkflowStep instance
     from slack_bolt.workflows.step import WorkflowStep
    @@ -2715,7 +2745,13 @@ 

    Args

    save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, ): - """Registers a new Workflow Step listener. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new Workflow Step listener. + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step by a decorator, use `WorkflowStepBuilder`'s methods. @@ -2744,6 +2780,13 @@

    Args

    save: The function for handling configuration in the Workflow Builder execute: The function for handling the step execution """ + warnings.warn( + ( + "Steps from Apps for legacy workflows are now deprecated. " + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + ), + category=DeprecationWarning, + ) step = callback_id if isinstance(callback_id, (str, Pattern)): step = WorkflowStep( diff --git a/docs/api-docs/slack_bolt/middleware/async_builtins.html b/docs/api-docs/slack_bolt/middleware/async_builtins.html index d60ff8fee..4746e6c4d 100644 --- a/docs/api-docs/slack_bolt/middleware/async_builtins.html +++ b/docs/api-docs/slack_bolt/middleware/async_builtins.html @@ -275,17 +275,6 @@

    Ancestors

  • Middleware
  • AsyncMiddleware
  • -

    Class variables

    -
    -
    var logger : logging.Logger
    -
    -
    -
    -
    var verification_token : Optional[str]
    -
    -
    -
    -

    Inherited members

    • SslCheck: @@ -382,10 +371,6 @@

      AsyncSslCheck

      -
    • AsyncUrlVerification

      diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_internals.html b/docs/api-docs/slack_bolt/middleware/authorization/async_internals.html index f7a7a6da5..559ecc0cb 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/async_internals.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_internals.html @@ -26,8 +26,7 @@

      Module slack_bolt.middleware.authorization.async_interna Expand source code -
      from slack_bolt.middleware.authorization.internals import _build_error_text
      -from slack_bolt.request.async_request import AsyncBoltRequest
      +
      from slack_bolt.request.async_request import AsyncBoltRequest
       from slack_bolt.response import BoltResponse
       
       
      @@ -43,11 +42,11 @@ 

      Module slack_bolt.middleware.authorization.async_interna return _is_url_verification(req) or _is_ssl_check(req) -def _build_error_response() -> BoltResponse: +def _build_user_facing_error_response(message: str) -> BoltResponse: # show an ephemeral message to the end-user return BoltResponse( status=200, - body=_build_error_text(), + body=message, )

    diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html index 5ffd5d51a..ab17699ba 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html @@ -34,8 +34,8 @@

    Module slack_bolt.middleware.authorization.async_multi_t from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from .async_authorization import AsyncAuthorization -from .async_internals import _build_error_response, _is_no_auth_required -from .internals import _is_no_auth_test_call_required, _build_error_text +from .async_internals import _build_user_facing_error_response, _is_no_auth_required +from .internals import _is_no_auth_test_call_required, _build_user_facing_authorize_error_message from ...authorization import AuthorizeResult from ...authorization.async_authorize import AsyncAuthorize @@ -49,6 +49,7 @@

    Module slack_bolt.middleware.authorization.async_multi_t authorize: AsyncAuthorize, base_logger: Optional[Logger] = None, user_token_resolution: str = "authed_user", + user_facing_authorize_error_message: Optional[str] = None, ): """Multi-workspace authorization. @@ -56,10 +57,14 @@

    Module slack_bolt.middleware.authorization.async_multi_t authorize: The function to authorize incoming requests from Slack. base_logger: The base logger user_token_resolution: "authed_user" or "actor" + user_facing_authorize_error_message: The user-facing error message when installation is not found """ self.authorize = authorize self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization, base_logger=base_logger) self.user_token_resolution = user_token_resolution + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) async def async_process( self, @@ -120,13 +125,13 @@

    Module slack_bolt.middleware.authorization.async_multi_t "the AuthorizeResult (returned value from authorize) for it was not found." ) if req.context.response_url is not None: - await req.context.respond(_build_error_text()) + await req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response()

    + return _build_user_facing_error_response(self.user_facing_authorize_error_message)
    @@ -140,7 +145,7 @@

    Classes

    class AsyncMultiTeamsAuthorization -(authorize: AsyncAuthorize, base_logger: Optional[logging.Logger] = None, user_token_resolution: str = 'authed_user') +(authorize: AsyncAuthorize, base_logger: Optional[logging.Logger] = None, user_token_resolution: str = 'authed_user', user_facing_authorize_error_message: Optional[str] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -153,6 +158,8 @@

    Args

    The base logger
    user_token_resolution
    "authed_user" or "actor"
    +
    user_facing_authorize_error_message
    +
    The user-facing error message when installation is not found
    @@ -167,6 +174,7 @@

    Args

    authorize: AsyncAuthorize, base_logger: Optional[Logger] = None, user_token_resolution: str = "authed_user", + user_facing_authorize_error_message: Optional[str] = None, ): """Multi-workspace authorization. @@ -174,10 +182,14 @@

    Args

    authorize: The function to authorize incoming requests from Slack. base_logger: The base logger user_token_resolution: "authed_user" or "actor" + user_facing_authorize_error_message: The user-facing error message when installation is not found """ self.authorize = authorize self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization, base_logger=base_logger) self.user_token_resolution = user_token_resolution + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) async def async_process( self, @@ -238,13 +250,13 @@

    Args

    "the AuthorizeResult (returned value from authorize) for it was not found." ) if req.context.response_url is not None: - await req.context.respond(_build_error_text()) + await req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response()
    + return _build_user_facing_error_response(self.user_facing_authorize_error_message)

    Ancestors

      diff --git a/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html index 8f3bcff67..3baf4e886 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html @@ -35,16 +35,23 @@

      Module slack_bolt.middleware.authorization.async_single_ from slack_bolt.response import BoltResponse from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.errors import SlackApiError -from .async_internals import _build_error_response, _is_no_auth_required -from .internals import _to_authorize_result, _is_no_auth_test_call_required, _build_error_text +from .async_internals import _build_user_facing_error_response, _is_no_auth_required +from .internals import _to_authorize_result, _is_no_auth_test_call_required, _build_user_facing_authorize_error_message from ...authorization import AuthorizeResult class AsyncSingleTeamAuthorization(AsyncAuthorization): - def __init__(self, base_logger: Optional[Logger] = None): + def __init__( + self, + base_logger: Optional[Logger] = None, + user_facing_authorize_error_message: Optional[str] = None, + ): """Single-workspace authorization.""" self.auth_test_result: Optional[AsyncSlackResponse] = None self.logger = get_bolt_logger(AsyncSingleTeamAuthorization, base_logger=base_logger) + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) async def async_process( self, @@ -86,12 +93,12 @@

      Module slack_bolt.middleware.authorization.async_single_ # Just in case self.logger.error("auth.test API call result is unexpectedly None") if req.context.response_url is not None: - await req.context.respond(_build_error_text()) + await req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message)

    @@ -105,7 +112,7 @@

    Classes

    class AsyncSingleTeamAuthorization -(base_logger: Optional[logging.Logger] = None) +(base_logger: Optional[logging.Logger] = None, user_facing_authorize_error_message: Optional[str] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -115,10 +122,17 @@

    Classes

    Expand source code
    class AsyncSingleTeamAuthorization(AsyncAuthorization):
    -    def __init__(self, base_logger: Optional[Logger] = None):
    +    def __init__(
    +        self,
    +        base_logger: Optional[Logger] = None,
    +        user_facing_authorize_error_message: Optional[str] = None,
    +    ):
             """Single-workspace authorization."""
             self.auth_test_result: Optional[AsyncSlackResponse] = None
             self.logger = get_bolt_logger(AsyncSingleTeamAuthorization, base_logger=base_logger)
    +        self.user_facing_authorize_error_message = (
    +            user_facing_authorize_error_message or _build_user_facing_authorize_error_message()
    +        )
     
         async def async_process(
             self,
    @@ -160,12 +174,12 @@ 

    Classes

    # Just in case self.logger.error("auth.test API call result is unexpectedly None") if req.context.response_url is not None: - await req.context.respond(_build_error_text()) + await req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response()
    + return _build_user_facing_error_response(self.user_facing_authorize_error_message)

    Ancestors

      diff --git a/docs/api-docs/slack_bolt/middleware/authorization/index.html b/docs/api-docs/slack_bolt/middleware/authorization/index.html index 3a1f6e064..e7c618823 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/index.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/index.html @@ -115,7 +115,7 @@

      Inherited members

    class MultiTeamsAuthorization -(*, authorize: Authorize, base_logger: Optional[logging.Logger] = None, user_token_resolution: str = 'authed_user') +(*, authorize: Authorize, base_logger: Optional[logging.Logger] = None, user_token_resolution: str = 'authed_user', user_facing_authorize_error_message: Optional[str] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -128,6 +128,8 @@

    Args

    The base logger
    user_token_resolution
    "authed_user" or "actor"
    +
    user_facing_authorize_error_message
    +
    The user-facing error message when installation is not found
    @@ -143,6 +145,7 @@

    Args

    authorize: Authorize, base_logger: Optional[Logger] = None, user_token_resolution: str = "authed_user", + user_facing_authorize_error_message: Optional[str] = None, ): """Multi-workspace authorization. @@ -150,10 +153,14 @@

    Args

    authorize: The function to authorize incoming requests from Slack. base_logger: The base logger user_token_resolution: "authed_user" or "actor" + user_facing_authorize_error_message: The user-facing error message when installation is not found """ self.authorize = authorize self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger) self.user_token_resolution = user_token_resolution + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) def process( self, @@ -211,13 +218,13 @@

    Args

    "the AuthorizeResult (returned value from authorize) for it was not found." ) if req.context.response_url is not None: - req.context.respond(_build_error_text()) + req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response()
    + return _build_user_facing_error_response(self.user_facing_authorize_error_message)

    Ancestors

      @@ -247,7 +254,7 @@

      Inherited members

      class SingleTeamAuthorization -(*, auth_test_result: Optional[slack_sdk.web.slack_response.SlackResponse] = None, base_logger: Optional[logging.Logger] = None) +(*, auth_test_result: Optional[slack_sdk.web.slack_response.SlackResponse] = None, base_logger: Optional[logging.Logger] = None, user_facing_authorize_error_message: Optional[str] = None)

      A middleware can process request data before other middleware and listener functions.

      @@ -269,6 +276,7 @@

      Args

      *, auth_test_result: Optional[SlackResponse] = None, base_logger: Optional[Logger] = None, + user_facing_authorize_error_message: Optional[str] = None, ): """Single-workspace authorization. @@ -278,6 +286,9 @@

      Args

      """ self.auth_test_result = auth_test_result self.logger = get_bolt_logger(SingleTeamAuthorization, base_logger=base_logger) + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) def process( self, @@ -319,12 +330,12 @@

      Args

      # Just in case self.logger.error("auth.test API call result is unexpectedly None") if req.context.response_url is not None: - req.context.respond(_build_error_text()) + req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message)

      Ancestors

        diff --git a/docs/api-docs/slack_bolt/middleware/authorization/internals.html b/docs/api-docs/slack_bolt/middleware/authorization/internals.html index 4cee299f3..5c015391e 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/internals.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/internals.html @@ -71,18 +71,18 @@

        Module slack_bolt.middleware.authorization.internalsModule slack_bolt.middleware.authorization.multi_teams_a from slack_bolt.response import BoltResponse from .authorization import Authorization from .internals import ( - _build_error_response, + _build_user_facing_error_response, _is_no_auth_required, _is_no_auth_test_call_required, - _build_error_text, + _build_user_facing_authorize_error_message, ) from ...authorization import AuthorizeResult from ...authorization.authorize import Authorize @@ -55,6 +55,7 @@

        Module slack_bolt.middleware.authorization.multi_teams_a authorize: Authorize, base_logger: Optional[Logger] = None, user_token_resolution: str = "authed_user", + user_facing_authorize_error_message: Optional[str] = None, ): """Multi-workspace authorization. @@ -62,10 +63,14 @@

        Module slack_bolt.middleware.authorization.multi_teams_a authorize: The function to authorize incoming requests from Slack. base_logger: The base logger user_token_resolution: "authed_user" or "actor" + user_facing_authorize_error_message: The user-facing error message when installation is not found """ self.authorize = authorize self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger) self.user_token_resolution = user_token_resolution + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) def process( self, @@ -123,13 +128,13 @@

        Module slack_bolt.middleware.authorization.multi_teams_a "the AuthorizeResult (returned value from authorize) for it was not found." ) if req.context.response_url is not None: - req.context.respond(_build_error_text()) + req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message)

    @@ -143,7 +148,7 @@

    Classes

    class MultiTeamsAuthorization -(*, authorize: Authorize, base_logger: Optional[logging.Logger] = None, user_token_resolution: str = 'authed_user') +(*, authorize: Authorize, base_logger: Optional[logging.Logger] = None, user_token_resolution: str = 'authed_user', user_facing_authorize_error_message: Optional[str] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -156,6 +161,8 @@

    Args

    The base logger
    user_token_resolution
    "authed_user" or "actor"
    +
    user_facing_authorize_error_message
    +
    The user-facing error message when installation is not found
    @@ -171,6 +178,7 @@

    Args

    authorize: Authorize, base_logger: Optional[Logger] = None, user_token_resolution: str = "authed_user", + user_facing_authorize_error_message: Optional[str] = None, ): """Multi-workspace authorization. @@ -178,10 +186,14 @@

    Args

    authorize: The function to authorize incoming requests from Slack. base_logger: The base logger user_token_resolution: "authed_user" or "actor" + user_facing_authorize_error_message: The user-facing error message when installation is not found """ self.authorize = authorize self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger) self.user_token_resolution = user_token_resolution + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) def process( self, @@ -239,13 +251,13 @@

    Args

    "the AuthorizeResult (returned value from authorize) for it was not found." ) if req.context.response_url is not None: - req.context.respond(_build_error_text()) + req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response()
    + return _build_user_facing_error_response(self.user_facing_authorize_error_message)

    Ancestors

      diff --git a/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html b/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html index 9d023b2d6..038be83c4 100644 --- a/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html +++ b/docs/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html @@ -36,11 +36,11 @@

      Module slack_bolt.middleware.authorization.single_team_a from slack_sdk.errors import SlackApiError from slack_sdk.web import SlackResponse from .internals import ( - _build_error_response, + _build_user_facing_error_response, _is_no_auth_required, _to_authorize_result, _is_no_auth_test_call_required, - _build_error_text, + _build_user_facing_authorize_error_message, ) from ...authorization import AuthorizeResult @@ -51,6 +51,7 @@

      Module slack_bolt.middleware.authorization.single_team_a *, auth_test_result: Optional[SlackResponse] = None, base_logger: Optional[Logger] = None, + user_facing_authorize_error_message: Optional[str] = None, ): """Single-workspace authorization. @@ -60,6 +61,9 @@

      Module slack_bolt.middleware.authorization.single_team_a """ self.auth_test_result = auth_test_result self.logger = get_bolt_logger(SingleTeamAuthorization, base_logger=base_logger) + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) def process( self, @@ -101,12 +105,12 @@

      Module slack_bolt.middleware.authorization.single_team_a # Just in case self.logger.error("auth.test API call result is unexpectedly None") if req.context.response_url is not None: - req.context.respond(_build_error_text()) + req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message)

    @@ -120,7 +124,7 @@

    Classes

    class SingleTeamAuthorization -(*, auth_test_result: Optional[slack_sdk.web.slack_response.SlackResponse] = None, base_logger: Optional[logging.Logger] = None) +(*, auth_test_result: Optional[slack_sdk.web.slack_response.SlackResponse] = None, base_logger: Optional[logging.Logger] = None, user_facing_authorize_error_message: Optional[str] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -142,6 +146,7 @@

    Args

    *, auth_test_result: Optional[SlackResponse] = None, base_logger: Optional[Logger] = None, + user_facing_authorize_error_message: Optional[str] = None, ): """Single-workspace authorization. @@ -151,6 +156,9 @@

    Args

    """ self.auth_test_result = auth_test_result self.logger = get_bolt_logger(SingleTeamAuthorization, base_logger=base_logger) + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) def process( self, @@ -192,12 +200,12 @@

    Args

    # Just in case self.logger.error("auth.test API call result is unexpectedly None") if req.context.response_url is not None: - req.context.respond(_build_error_text()) + req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response()
    + return _build_user_facing_error_response(self.user_facing_authorize_error_message)

    Ancestors

      diff --git a/docs/api-docs/slack_bolt/middleware/index.html b/docs/api-docs/slack_bolt/middleware/index.html index ed61cf1ba..f455d6c1f 100644 --- a/docs/api-docs/slack_bolt/middleware/index.html +++ b/docs/api-docs/slack_bolt/middleware/index.html @@ -463,7 +463,7 @@

      Returns

    class MultiTeamsAuthorization -(*, authorize: Authorize, base_logger: Optional[logging.Logger] = None, user_token_resolution: str = 'authed_user') +(*, authorize: Authorize, base_logger: Optional[logging.Logger] = None, user_token_resolution: str = 'authed_user', user_facing_authorize_error_message: Optional[str] = None)

    A middleware can process request data before other middleware and listener functions.

    @@ -476,6 +476,8 @@

    Args

    The base logger
    user_token_resolution
    "authed_user" or "actor"
    +
    user_facing_authorize_error_message
    +
    The user-facing error message when installation is not found
    @@ -491,6 +493,7 @@

    Args

    authorize: Authorize, base_logger: Optional[Logger] = None, user_token_resolution: str = "authed_user", + user_facing_authorize_error_message: Optional[str] = None, ): """Multi-workspace authorization. @@ -498,10 +501,14 @@

    Args

    authorize: The function to authorize incoming requests from Slack. base_logger: The base logger user_token_resolution: "authed_user" or "actor" + user_facing_authorize_error_message: The user-facing error message when installation is not found """ self.authorize = authorize self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger) self.user_token_resolution = user_token_resolution + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) def process( self, @@ -559,13 +566,13 @@

    Args

    "the AuthorizeResult (returned value from authorize) for it was not found." ) if req.context.response_url is not None: - req.context.respond(_build_error_text()) + req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message)

    Ancestors

      @@ -684,7 +691,7 @@

      Inherited members

      class SingleTeamAuthorization -(*, auth_test_result: Optional[slack_sdk.web.slack_response.SlackResponse] = None, base_logger: Optional[logging.Logger] = None) +(*, auth_test_result: Optional[slack_sdk.web.slack_response.SlackResponse] = None, base_logger: Optional[logging.Logger] = None, user_facing_authorize_error_message: Optional[str] = None)

      A middleware can process request data before other middleware and listener functions.

      @@ -706,6 +713,7 @@

      Args

      *, auth_test_result: Optional[SlackResponse] = None, base_logger: Optional[Logger] = None, + user_facing_authorize_error_message: Optional[str] = None, ): """Single-workspace authorization. @@ -715,6 +723,9 @@

      Args

      """ self.auth_test_result = auth_test_result self.logger = get_bolt_logger(SingleTeamAuthorization, base_logger=base_logger) + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) def process( self, @@ -756,12 +767,12 @@

      Args

      # Just in case self.logger.error("auth.test API call result is unexpectedly None") if req.context.response_url is not None: - req.context.respond(_build_error_text()) + req.context.respond(self.user_facing_authorize_error_message) return BoltResponse(status=200, body="") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message)

      Ancestors

        diff --git a/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html b/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html index 6624b8e10..8fe631fee 100644 --- a/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html +++ b/docs/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html @@ -106,17 +106,6 @@

        Ancestors

      • Middleware
      • AsyncMiddleware
      -

      Class variables

      -
      -
      var logger : logging.Logger
      -
      -
      -
      -
      var verification_token : Optional[str]
      -
      -
      -
      -

      Inherited members

      • SslCheck: @@ -150,10 +139,6 @@

        Index

      • diff --git a/docs/api-docs/slack_bolt/version.html b/docs/api-docs/slack_bolt/version.html index 19a0d5d64..efd03ad4b 100644 --- a/docs/api-docs/slack_bolt/version.html +++ b/docs/api-docs/slack_bolt/version.html @@ -28,7 +28,7 @@

        Module slack_bolt.version

        Expand source code
        """Check the latest version at https://pypi.org/project/slack-bolt/"""
        -__version__ = "1.18.1"
        +__version__ = "1.19.0"
    diff --git a/docs/api-docs/slack_bolt/workflows/step/async_step.html b/docs/api-docs/slack_bolt/workflows/step/async_step.html index b9b685b68..7afd01e37 100644 --- a/docs/api-docs/slack_bolt/workflows/step/async_step.html +++ b/docs/api-docs/slack_bolt/workflows/step/async_step.html @@ -71,7 +71,12 @@

    Module slack_bolt.workflows.step.async_step

    app_name: Optional[str] = None, base_logger: Optional[Logger] = None, ): - """This builder is supposed to be used as decorator. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + This builder is supposed to be used as decorator. my_step = AsyncWorkflowStep.builder("my_step") @my_step.edit @@ -108,7 +113,13 @@

    Module slack_bolt.workflows.step.async_step

    middleware: Optional[Union[Callable, AsyncMiddleware]] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, ): - """Registers a new edit listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new edit listener with details. + You can use this method as decorator as well. @my_step.edit @@ -155,7 +166,13 @@

    Module slack_bolt.workflows.step.async_step

    middleware: Optional[Union[Callable, AsyncMiddleware]] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, ): - """Registers a new save listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new save listener with details. + You can use this method as decorator as well. @my_step.save @@ -202,7 +219,13 @@

    Module slack_bolt.workflows.step.async_step

    middleware: Optional[Union[Callable, AsyncMiddleware]] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, ): - """Registers a new execute listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new execute listener with details. + You can use this method as decorator as well. @my_step.execute @@ -243,7 +266,12 @@

    Module slack_bolt.workflows.step.async_step

    return _inner def build(self, base_logger: Optional[Logger] = None) -> "AsyncWorkflowStep": - """Constructs a WorkflowStep object. This method may raise an exception + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. Returns: @@ -337,6 +365,10 @@

    Module slack_bolt.workflows.step.async_step

    base_logger: Optional[Logger] = None, ): """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Args: callback_id: The callback_id for this workflow step edit: Either a single function or a list of functions for opening a modal in the builder UI @@ -378,6 +410,11 @@

    Module slack_bolt.workflows.step.async_step

    callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None, ) -> AsyncWorkflowStepBuilder: + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + """ return AsyncWorkflowStepBuilder(callback_id, base_logger=base_logger) @classmethod @@ -552,7 +589,10 @@

    Classes

    (*, callback_id: Union[str, Pattern], edit: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], save: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], execute: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], app_name: Optional[str] = None, base_logger: Optional[logging.Logger] = None)
    -

    Args

    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Args

    callback_id
    The callback_id for this workflow step
    @@ -595,6 +635,10 @@

    Classes

    base_logger: Optional[Logger] = None, ): """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Args: callback_id: The callback_id for this workflow step edit: Either a single function or a list of functions for opening a modal in the builder UI @@ -636,6 +680,11 @@

    Classes

    callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None, ) -> AsyncWorkflowStepBuilder: + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + """ return AsyncWorkflowStepBuilder(callback_id, base_logger=base_logger) @classmethod @@ -781,7 +830,9 @@

    Static methods

    def builder(callback_id: Union[str, Pattern], base_logger: Optional[logging.Logger] = None) ‑> AsyncWorkflowStepBuilder
    -
    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Expand source code @@ -792,6 +843,11 @@

    Static methods

    callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None, ) -> AsyncWorkflowStepBuilder: + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + """ return AsyncWorkflowStepBuilder(callback_id, base_logger=base_logger)
    @@ -804,6 +860,9 @@

    Static methods

    Steps from Apps Refer to https://api.slack.com/workflows/steps for details.

    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    This builder is supposed to be used as decorator.

    my_step = AsyncWorkflowStep.builder("my_step")
     @my_step.edit
    @@ -850,7 +909,12 @@ 

    Args

    app_name: Optional[str] = None, base_logger: Optional[Logger] = None, ): - """This builder is supposed to be used as decorator. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + This builder is supposed to be used as decorator. my_step = AsyncWorkflowStep.builder("my_step") @my_step.edit @@ -887,7 +951,13 @@

    Args

    middleware: Optional[Union[Callable, AsyncMiddleware]] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, ): - """Registers a new edit listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new edit listener with details. + You can use this method as decorator as well. @my_step.edit @@ -934,7 +1004,13 @@

    Args

    middleware: Optional[Union[Callable, AsyncMiddleware]] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, ): - """Registers a new save listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new save listener with details. + You can use this method as decorator as well. @my_step.save @@ -981,7 +1057,13 @@

    Args

    middleware: Optional[Union[Callable, AsyncMiddleware]] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, ): - """Registers a new execute listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new execute listener with details. + You can use this method as decorator as well. @my_step.execute @@ -1022,7 +1104,12 @@

    Args

    return _inner def build(self, base_logger: Optional[Logger] = None) -> "AsyncWorkflowStep": - """Constructs a WorkflowStep object. This method may raise an exception + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. Returns: @@ -1161,7 +1248,10 @@

    Methods

    def build(self, base_logger: Optional[logging.Logger] = None) ‑> AsyncWorkflowStep
    -

    Constructs a WorkflowStep object. This method may raise an exception +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object.

    Returns

    An AsyncWorkflowStep object

    @@ -1170,7 +1260,12 @@

    Returns

    Expand source code
    def build(self, base_logger: Optional[Logger] = None) -> "AsyncWorkflowStep":
    -    """Constructs a WorkflowStep object. This method may raise an exception
    +    """
    +    Deprecated:
    +        Steps from Apps for legacy workflows are now deprecated.
    +        Use new custom steps: https://api.slack.com/automation/functions/custom-bolt
    +
    +    Constructs a WorkflowStep object. This method may raise an exception
         if the builder doesn't have enough configurations to build the object.
     
         Returns:
    @@ -1197,8 +1292,11 @@ 

    Returns

    def edit(self, *args, matchers: Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, AsyncMiddleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None)
    -

    Registers a new edit listener with details. -You can use this method as decorator as well.

    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Registers a new edit listener with details.

    +

    You can use this method as decorator as well.

    @my_step.edit
     def edit_my_step(ack, configure):
         pass
    @@ -1233,7 +1331,13 @@ 

    Args

    middleware: Optional[Union[Callable, AsyncMiddleware]] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, ): - """Registers a new edit listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new edit listener with details. + You can use this method as decorator as well. @my_step.edit @@ -1278,8 +1382,11 @@

    Args

    def execute(self, *args, matchers: Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, AsyncMiddleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None)
    -

    Registers a new execute listener with details. -You can use this method as decorator as well.

    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Registers a new execute listener with details.

    +

    You can use this method as decorator as well.

    @my_step.execute
     def execute_my_step(step, complete, fail):
         pass
    @@ -1314,7 +1421,13 @@ 

    Args

    middleware: Optional[Union[Callable, AsyncMiddleware]] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, ): - """Registers a new execute listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new execute listener with details. + You can use this method as decorator as well. @my_step.execute @@ -1359,8 +1472,11 @@

    Args

    def save(self, *args, matchers: Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, AsyncMiddleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None)
    -

    Registers a new save listener with details. -You can use this method as decorator as well.

    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Registers a new save listener with details.

    +

    You can use this method as decorator as well.

    @my_step.save
     def save_my_step(ack, step, update):
         pass
    @@ -1395,7 +1511,13 @@ 

    Args

    middleware: Optional[Union[Callable, AsyncMiddleware]] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, ): - """Registers a new save listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new save listener with details. + You can use this method as decorator as well. @my_step.save diff --git a/docs/api-docs/slack_bolt/workflows/step/index.html b/docs/api-docs/slack_bolt/workflows/step/index.html index 1e8f214b1..80c93de68 100644 --- a/docs/api-docs/slack_bolt/workflows/step/index.html +++ b/docs/api-docs/slack_bolt/workflows/step/index.html @@ -390,7 +390,10 @@

    Classes

    (*, callback_id: Union[str, Pattern], edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], app_name: Optional[str] = None, base_logger: Optional[logging.Logger] = None)
    -

    Args

    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Args

    callback_id
    The callback_id for this workflow step
    @@ -433,6 +436,10 @@

    Classes

    base_logger: Optional[Logger] = None, ): """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Args: callback_id: The callback_id for this workflow step edit: Either a single function or a list of functions for opening a modal in the builder UI @@ -470,6 +477,11 @@

    Classes

    @classmethod def builder(cls, callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None) -> WorkflowStepBuilder: + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + """ return WorkflowStepBuilder( callback_id, base_logger=base_logger, @@ -646,13 +658,20 @@

    Static methods

    def builder(callback_id: Union[str, Pattern], base_logger: Optional[logging.Logger] = None) ‑> WorkflowStepBuilder
    -
    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Expand source code
    @classmethod
     def builder(cls, callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None) -> WorkflowStepBuilder:
    +    """
    +    Deprecated:
    +        Steps from Apps for legacy workflows are now deprecated.
    +        Use new custom steps: https://api.slack.com/automation/functions/custom-bolt
    +    """
         return WorkflowStepBuilder(
             callback_id,
             base_logger=base_logger,
    diff --git a/docs/api-docs/slack_bolt/workflows/step/step.html b/docs/api-docs/slack_bolt/workflows/step/step.html
    index 190a78663..8282e3d30 100644
    --- a/docs/api-docs/slack_bolt/workflows/step/step.html
    +++ b/docs/api-docs/slack_bolt/workflows/step/step.html
    @@ -66,7 +66,12 @@ 

    Module slack_bolt.workflows.step.step

    app_name: Optional[str] = None, base_logger: Optional[Logger] = None, ): - """This builder is supposed to be used as decorator. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + This builder is supposed to be used as decorator. my_step = WorkflowStep.builder("my_step") @my_step.edit @@ -103,7 +108,13 @@

    Module slack_bolt.workflows.step.step

    middleware: Optional[Union[Callable, Middleware]] = None, lazy: Optional[List[Callable[..., None]]] = None, ): - """Registers a new edit listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new edit listener with details. + You can use this method as decorator as well. @my_step.edit @@ -151,7 +162,13 @@

    Module slack_bolt.workflows.step.step

    middleware: Optional[Union[Callable, Middleware]] = None, lazy: Optional[List[Callable[..., None]]] = None, ): - """Registers a new save listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new save listener with details. + You can use this method as decorator as well. @my_step.save @@ -198,7 +215,13 @@

    Module slack_bolt.workflows.step.step

    middleware: Optional[Union[Callable, Middleware]] = None, lazy: Optional[List[Callable[..., None]]] = None, ): - """Registers a new execute listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new execute listener with details. + You can use this method as decorator as well. @my_step.execute @@ -239,7 +262,12 @@

    Module slack_bolt.workflows.step.step

    return _inner def build(self, base_logger: Optional[Logger] = None) -> "WorkflowStep": - """Constructs a WorkflowStep object. This method may raise an exception + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. Returns: @@ -348,6 +376,10 @@

    Module slack_bolt.workflows.step.step

    base_logger: Optional[Logger] = None, ): """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Args: callback_id: The callback_id for this workflow step edit: Either a single function or a list of functions for opening a modal in the builder UI @@ -385,6 +417,11 @@

    Module slack_bolt.workflows.step.step

    @classmethod def builder(cls, callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None) -> WorkflowStepBuilder: + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + """ return WorkflowStepBuilder( callback_id, base_logger=base_logger, @@ -571,7 +608,10 @@

    Classes

    (*, callback_id: Union[str, Pattern], edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], app_name: Optional[str] = None, base_logger: Optional[logging.Logger] = None)
    -

    Args

    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Args

    callback_id
    The callback_id for this workflow step
    @@ -614,6 +654,10 @@

    Classes

    base_logger: Optional[Logger] = None, ): """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Args: callback_id: The callback_id for this workflow step edit: Either a single function or a list of functions for opening a modal in the builder UI @@ -651,6 +695,11 @@

    Classes

    @classmethod def builder(cls, callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None) -> WorkflowStepBuilder: + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + """ return WorkflowStepBuilder( callback_id, base_logger=base_logger, @@ -827,13 +876,20 @@

    Static methods

    def builder(callback_id: Union[str, Pattern], base_logger: Optional[logging.Logger] = None) ‑> WorkflowStepBuilder
    -
    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Expand source code
    @classmethod
     def builder(cls, callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None) -> WorkflowStepBuilder:
    +    """
    +    Deprecated:
    +        Steps from Apps for legacy workflows are now deprecated.
    +        Use new custom steps: https://api.slack.com/automation/functions/custom-bolt
    +    """
         return WorkflowStepBuilder(
             callback_id,
             base_logger=base_logger,
    @@ -849,6 +905,9 @@ 

    Static methods

    Steps from Apps Refer to https://api.slack.com/workflows/steps for details.

    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    This builder is supposed to be used as decorator.

    my_step = WorkflowStep.builder("my_step")
     @my_step.edit
    @@ -895,7 +954,12 @@ 

    Args

    app_name: Optional[str] = None, base_logger: Optional[Logger] = None, ): - """This builder is supposed to be used as decorator. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + This builder is supposed to be used as decorator. my_step = WorkflowStep.builder("my_step") @my_step.edit @@ -932,7 +996,13 @@

    Args

    middleware: Optional[Union[Callable, Middleware]] = None, lazy: Optional[List[Callable[..., None]]] = None, ): - """Registers a new edit listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new edit listener with details. + You can use this method as decorator as well. @my_step.edit @@ -980,7 +1050,13 @@

    Args

    middleware: Optional[Union[Callable, Middleware]] = None, lazy: Optional[List[Callable[..., None]]] = None, ): - """Registers a new save listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new save listener with details. + You can use this method as decorator as well. @my_step.save @@ -1027,7 +1103,13 @@

    Args

    middleware: Optional[Union[Callable, Middleware]] = None, lazy: Optional[List[Callable[..., None]]] = None, ): - """Registers a new execute listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new execute listener with details. + You can use this method as decorator as well. @my_step.execute @@ -1068,7 +1150,12 @@

    Args

    return _inner def build(self, base_logger: Optional[Logger] = None) -> "WorkflowStep": - """Constructs a WorkflowStep object. This method may raise an exception + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. Returns: @@ -1237,7 +1324,10 @@

    Methods

    def build(self, base_logger: Optional[logging.Logger] = None) ‑> WorkflowStep
    -

    Constructs a WorkflowStep object. This method may raise an exception +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object.

    Returns

    WorkflowStep object

    @@ -1246,7 +1336,12 @@

    Returns

    Expand source code
    def build(self, base_logger: Optional[Logger] = None) -> "WorkflowStep":
    -    """Constructs a WorkflowStep object. This method may raise an exception
    +    """
    +    Deprecated:
    +        Steps from Apps for legacy workflows are now deprecated.
    +        Use new custom steps: https://api.slack.com/automation/functions/custom-bolt
    +
    +    Constructs a WorkflowStep object. This method may raise an exception
         if the builder doesn't have enough configurations to build the object.
     
         Returns:
    @@ -1273,8 +1368,11 @@ 

    Returns

    def edit(self, *args, matchers: Union[Callable[..., bool], ListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, Middleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., None]]] = None)
    -

    Registers a new edit listener with details. -You can use this method as decorator as well.

    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Registers a new edit listener with details.

    +

    You can use this method as decorator as well.

    @my_step.edit
     def edit_my_step(ack, configure):
         pass
    @@ -1309,7 +1407,13 @@ 

    Args

    middleware: Optional[Union[Callable, Middleware]] = None, lazy: Optional[List[Callable[..., None]]] = None, ): - """Registers a new edit listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new edit listener with details. + You can use this method as decorator as well. @my_step.edit @@ -1355,8 +1459,11 @@

    Args

    def execute(self, *args, matchers: Union[Callable[..., bool], ListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, Middleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., None]]] = None)
    -

    Registers a new execute listener with details. -You can use this method as decorator as well.

    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Registers a new execute listener with details.

    +

    You can use this method as decorator as well.

    @my_step.execute
     def execute_my_step(step, complete, fail):
         pass
    @@ -1391,7 +1498,13 @@ 

    Args

    middleware: Optional[Union[Callable, Middleware]] = None, lazy: Optional[List[Callable[..., None]]] = None, ): - """Registers a new execute listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new execute listener with details. + You can use this method as decorator as well. @my_step.execute @@ -1436,8 +1549,11 @@

    Args

    def save(self, *args, matchers: Union[Callable[..., bool], ListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, Middleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., None]]] = None)
    -

    Registers a new save listener with details. -You can use this method as decorator as well.

    +

    Deprecated

    +

    Steps from Apps for legacy workflows are now deprecated. +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +

    Registers a new save listener with details.

    +

    You can use this method as decorator as well.

    @my_step.save
     def save_my_step(ack, step, update):
         pass
    @@ -1472,7 +1588,13 @@ 

    Args

    middleware: Optional[Union[Callable, Middleware]] = None, lazy: Optional[List[Callable[..., None]]] = None, ): - """Registers a new save listener with details. + """ + Deprecated: + Steps from Apps for legacy workflows are now deprecated. + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + + Registers a new save listener with details. + You can use this method as decorator as well. @my_step.save From ce2778076c0b0f7a961a7ad8cdeb80bdf7d5130b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 25 Jun 2024 10:35:28 +0900 Subject: [PATCH 634/865] Fix #1099 by locking pytype version (#1100) --- scripts/install_all_and_run_tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index 831acc80c..1271f1761 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -36,6 +36,6 @@ else pip install -U pip click && \ black slack_bolt/ tests/ && \ pytest && \ - pip install -U pytype && \ + pip install "pytype==2022.12.15" && \ pytype slack_bolt/ fi From e2505bffe99b8179f49b36ee0f1886dcdd7cb7fa Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 3 Jul 2024 10:09:33 +0900 Subject: [PATCH 635/865] Add bot|user_scopes to context.authorize_result set by SingleTeamAuthorization (#1104) --- .../middleware/authorization/internals.py | 3 +++ tests/mock_web_api_server.py | 2 ++ .../test_single_team_authorization.py | 24 +++++++++++++++++++ .../test_single_team_authorization.py | 16 +++++++++++++ 4 files changed, 45 insertions(+) diff --git a/slack_bolt/middleware/authorization/internals.py b/slack_bolt/middleware/authorization/internals.py index 814101953..29d65e3b9 100644 --- a/slack_bolt/middleware/authorization/internals.py +++ b/slack_bolt/middleware/authorization/internals.py @@ -68,6 +68,7 @@ def _to_authorize_result( # type: ignore request_user_id: Optional[str], ) -> AuthorizeResult: user_id = auth_test_result.get("user_id") + oauth_scopes: Optional[str] = auth_test_result.headers.get("x-oauth-scopes") return AuthorizeResult( enterprise_id=auth_test_result.get("enterprise_id"), team_id=auth_test_result.get("team_id"), @@ -76,4 +77,6 @@ def _to_authorize_result( # type: ignore bot_token=token if _is_bot_token(token) else None, user_id=request_user_id or (user_id if not _is_bot_token(token) else None), user_token=token if not _is_bot_token(token) else None, + bot_scopes=oauth_scopes if _is_bot_token(token) else None, + user_scopes=None if _is_bot_token(token) else oauth_scopes, ) diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index ce42ddb1f..57fc85b26 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -149,6 +149,7 @@ def _handle(self): if self.is_valid_user_token(): if path == "/auth.test": self.send_response(200) + self.send_header("x-oauth-scopes", "chat:write,search:read") self.set_common_headers(len(USER_AUTH_TEST_RESPONSE)) self.wfile.write(USER_AUTH_TEST_RESPONSE.encode("utf-8")) return @@ -156,6 +157,7 @@ def _handle(self): if self.is_valid_token(): if path == "/auth.test": self.send_response(200) + self.send_header("x-oauth-scopes", "chat:write,commands") self.set_common_headers(len(BOT_AUTH_TEST_RESPONSE)) self.wfile.write(BOT_AUTH_TEST_RESPONSE.encode("utf-8")) return diff --git a/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py index 4cf5d9600..7e3a0c11d 100644 --- a/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py +++ b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py @@ -1,4 +1,5 @@ from slack_sdk import WebClient +from slack_sdk.web import SlackResponse from slack_bolt.middleware import SingleTeamAuthorization from slack_bolt.middleware.authorization.internals import _build_user_facing_authorize_error_message @@ -34,6 +35,29 @@ def test_success_pattern(self): assert resp.status == 200 assert resp.body == "" + def test_success_pattern_with_bot_scopes(self): + client = WebClient(base_url=self.mock_api_server_base_url, token="xoxb-valid") + auth_test_result: SlackResponse = SlackResponse( + client=client, + http_verb="POST", + api_url="https://slack.com/api/auth.test", + req_args={}, + data={}, + headers={"x-oauth-scopes": "chat:write,commands"}, + status_code=200, + ) + authorization = SingleTeamAuthorization(auth_test_result=auth_test_result) + req = BoltRequest(body="payload={}", headers={}) + req.context["client"] = client + resp = BoltResponse(status=404) + + resp = authorization.process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "" + assert req.context.authorize_result.bot_scopes == ["chat:write", "commands"] + assert req.context.authorize_result.user_scopes is None + def test_failure_pattern(self): authorization = SingleTeamAuthorization(auth_test_result={}) req = BoltRequest(body="payload={}", headers={}) diff --git a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py index ef1baded5..05a18eeaf 100644 --- a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py +++ b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py @@ -1,6 +1,7 @@ import asyncio import pytest +from slack.web.async_slack_response import AsyncSlackResponse from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.middleware.authorization.async_single_team_authorization import ( @@ -47,6 +48,21 @@ async def test_success_pattern(self): assert resp.status == 200 assert resp.body == "" + @pytest.mark.asyncio + async def test_success_pattern_with_bot_scopes(self): + client = AsyncWebClient(base_url=self.mock_api_server_base_url, token="xoxb-valid") + authorization = AsyncSingleTeamAuthorization() + req = AsyncBoltRequest(body="payload={}", headers={}) + req.context["client"] = client + resp = BoltResponse(status=404) + + resp = await authorization.async_process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "" + assert req.context.authorize_result.bot_scopes == ["chat:write", "commands"] + assert req.context.authorize_result.user_scopes is None + @pytest.mark.asyncio async def test_failure_pattern(self): authorization = AsyncSingleTeamAuthorization() From 7b19d3dccfb03f9bf4ebb62229053e6bc5c3ab65 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 3 Jul 2024 10:12:52 +0900 Subject: [PATCH 636/865] version 1.19.1 --- .../slack_bolt/adapter/aiohttp/index.html | 121 +- .../adapter/asgi/aiohttp/index.html | 86 +- .../adapter/asgi/async_handler.html | 36 +- .../slack_bolt/adapter/asgi/base_handler.html | 128 +- .../adapter/asgi/builtin/index.html | 99 +- .../slack_bolt/adapter/asgi/http_request.html | 91 +- .../adapter/asgi/http_response.html | 90 +- .../slack_bolt/adapter/asgi/index.html | 51 +- .../slack_bolt/adapter/asgi/utils.html | 40 +- .../adapter/aws_lambda/chalice_handler.html | 234 +- .../chalice_lazy_listener_runner.html | 82 +- .../adapter/aws_lambda/handler.html | 244 +- .../slack_bolt/adapter/aws_lambda/index.html | 96 +- .../adapter/aws_lambda/internals.html | 42 +- .../aws_lambda/lambda_s3_oauth_flow.html | 154 +- .../aws_lambda/lazy_listener_runner.html | 73 +- .../aws_lambda/local_lambda_client.html | 76 +- .../slack_bolt/adapter/bottle/handler.html | 130 +- .../slack_bolt/adapter/bottle/index.html | 62 +- .../slack_bolt/adapter/cherrypy/handler.html | 195 +- .../slack_bolt/adapter/cherrypy/index.html | 64 +- .../slack_bolt/adapter/django/handler.html | 296 +- .../slack_bolt/adapter/django/index.html | 58 +- .../adapter/falcon/async_resource.html | 137 +- .../slack_bolt/adapter/falcon/index.html | 67 +- .../slack_bolt/adapter/falcon/resource.html | 135 +- .../adapter/fastapi/async_handler.html | 66 +- .../slack_bolt/adapter/fastapi/index.html | 63 +- .../slack_bolt/adapter/flask/handler.html | 124 +- .../slack_bolt/adapter/flask/index.html | 58 +- .../google_cloud_functions/handler.html | 105 +- .../adapter/google_cloud_functions/index.html | 57 +- docs/api-docs/slack_bolt/adapter/index.html | 35 +- .../slack_bolt/adapter/pyramid/handler.html | 169 +- .../slack_bolt/adapter/pyramid/index.html | 61 +- .../adapter/sanic/async_handler.html | 156 +- .../slack_bolt/adapter/sanic/index.html | 62 +- .../adapter/socket_mode/aiohttp/index.html | 124 +- .../socket_mode/async_base_handler.html | 135 +- .../adapter/socket_mode/async_handler.html | 39 +- .../adapter/socket_mode/async_internals.html | 117 +- .../adapter/socket_mode/base_handler.html | 149 +- .../adapter/socket_mode/builtin/index.html | 109 +- .../slack_bolt/adapter/socket_mode/index.html | 47 +- .../adapter/socket_mode/internals.html | 113 +- .../socket_mode/websocket_client/index.html | 103 +- .../adapter/socket_mode/websockets/index.html | 123 +- .../adapter/starlette/async_handler.html | 178 +- .../slack_bolt/adapter/starlette/handler.html | 169 +- .../slack_bolt/adapter/starlette/index.html | 63 +- .../adapter/tornado/async_handler.html | 128 +- .../slack_bolt/adapter/tornado/handler.html | 174 +- .../slack_bolt/adapter/tornado/index.html | 80 +- .../slack_bolt/adapter/wsgi/handler.html | 147 +- .../slack_bolt/adapter/wsgi/http_request.html | 105 +- .../adapter/wsgi/http_response.html | 89 +- .../slack_bolt/adapter/wsgi/index.html | 65 +- .../slack_bolt/adapter/wsgi/internals.html | 34 +- docs/api-docs/slack_bolt/app/app.html | 2402 +---------------- docs/api-docs/slack_bolt/app/async_app.html | 2345 +--------------- .../api-docs/slack_bolt/app/async_server.html | 165 +- docs/api-docs/slack_bolt/app/index.html | 929 +------ docs/api-docs/slack_bolt/async_app.html | 1130 +------- .../authorization/async_authorize.html | 412 +-- .../authorization/async_authorize_args.html | 71 +- .../slack_bolt/authorization/authorize.html | 409 +-- .../authorization/authorize_args.html | 71 +- .../authorization/authorize_result.html | 185 +- .../slack_bolt/authorization/index.html | 84 +- docs/api-docs/slack_bolt/context/ack/ack.html | 79 +- .../slack_bolt/context/ack/async_ack.html | 79 +- .../slack_bolt/context/ack/index.html | 39 +- .../slack_bolt/context/ack/internals.html | 134 +- .../slack_bolt/context/async_context.html | 186 +- .../slack_bolt/context/base_context.html | 228 +- docs/api-docs/slack_bolt/context/context.html | 189 +- docs/api-docs/slack_bolt/context/index.html | 76 +- .../context/respond/async_respond.html | 101 +- .../slack_bolt/context/respond/index.html | 39 +- .../slack_bolt/context/respond/internals.html | 73 +- .../slack_bolt/context/respond/respond.html | 101 +- .../slack_bolt/context/say/async_say.html | 109 +- .../slack_bolt/context/say/index.html | 39 +- .../slack_bolt/context/say/internals.html | 38 +- docs/api-docs/slack_bolt/context/say/say.html | 109 +- docs/api-docs/slack_bolt/error/index.html | 61 +- docs/api-docs/slack_bolt/index.html | 1077 +------- .../slack_bolt/kwargs_injection/args.html | 170 +- .../kwargs_injection/async_args.html | 166 +- .../kwargs_injection/async_utils.html | 229 +- .../slack_bolt/kwargs_injection/index.html | 137 +- .../slack_bolt/kwargs_injection/utils.html | 229 +- .../lazy_listener/async_internals.html | 93 +- .../lazy_listener/async_runner.html | 99 +- .../lazy_listener/asyncio_runner.html | 59 +- .../slack_bolt/lazy_listener/index.html | 95 +- .../slack_bolt/lazy_listener/internals.html | 93 +- .../slack_bolt/lazy_listener/runner.html | 97 +- .../lazy_listener/thread_runner.html | 61 +- .../slack_bolt/listener/async_builtins.html | 98 +- .../slack_bolt/listener/async_listener.html | 252 +- .../async_listener_completion_handler.html | 108 +- .../async_listener_error_handler.html | 120 +- .../async_listener_start_handler.html | 108 +- .../slack_bolt/listener/asyncio_runner.html | 343 +-- .../slack_bolt/listener/builtins.html | 96 +- .../slack_bolt/listener/custom_listener.html | 91 +- docs/api-docs/slack_bolt/listener/index.html | 118 +- .../slack_bolt/listener/listener.html | 166 +- .../listener/listener_completion_handler.html | 108 +- .../listener/listener_error_handler.html | 120 +- .../listener/listener_start_handler.html | 116 +- .../slack_bolt/listener/thread_runner.html | 368 +-- .../listener_matcher/async_builtins.html | 52 +- .../async_listener_matcher.html | 125 +- .../slack_bolt/listener_matcher/builtins.html | 1007 +------ .../custom_listener_matcher.html | 66 +- .../slack_bolt/listener_matcher/index.html | 69 +- .../listener_matcher/listener_matcher.html | 69 +- docs/api-docs/slack_bolt/logger/index.html | 108 +- docs/api-docs/slack_bolt/logger/messages.html | 717 +---- .../slack_bolt/middleware/async_builtins.html | 67 +- .../middleware/async_custom_middleware.html | 91 +- .../middleware/async_middleware.html | 127 +- .../async_middleware_error_handler.html | 120 +- .../authorization/async_authorization.html | 40 +- .../authorization/async_internals.html | 55 +- .../async_multi_teams_authorization.html | 139 +- .../async_single_team_authorization.html | 106 +- .../authorization/authorization.html | 38 +- .../middleware/authorization/index.html | 43 +- .../middleware/authorization/internals.html | 112 +- .../multi_teams_authorization.html | 142 +- .../single_team_authorization.html | 118 +- .../middleware/custom_middleware.html | 80 +- .../async_ignoring_self_events.html | 57 +- .../ignoring_self_events.html | 93 +- .../ignoring_self_events/index.html | 38 +- .../api-docs/slack_bolt/middleware/index.html | 117 +- .../async_message_listener_matches.html | 69 +- .../message_listener_matches/index.html | 38 +- .../message_listener_matches.html | 69 +- .../slack_bolt/middleware/middleware.html | 127 +- .../middleware/middleware_error_handler.html | 120 +- .../async_request_verification.html | 69 +- .../request_verification/index.html | 38 +- .../request_verification.html | 94 +- .../middleware/ssl_check/async_ssl_check.html | 73 +- .../middleware/ssl_check/index.html | 38 +- .../middleware/ssl_check/ssl_check.html | 95 +- .../async_url_verification.html | 58 +- .../middleware/url_verification/index.html | 38 +- .../url_verification/url_verification.html | 77 +- .../oauth/async_callback_options.html | 147 +- .../slack_bolt/oauth/async_internals.html | 117 +- .../slack_bolt/oauth/async_oauth_flow.html | 702 +---- .../oauth/async_oauth_settings.html | 223 +- .../slack_bolt/oauth/callback_options.html | 151 +- docs/api-docs/slack_bolt/oauth/index.html | 349 +-- docs/api-docs/slack_bolt/oauth/internals.html | 237 +- .../api-docs/slack_bolt/oauth/oauth_flow.html | 703 +---- .../slack_bolt/oauth/oauth_settings.html | 216 +- .../slack_bolt/request/async_internals.html | 130 +- .../slack_bolt/request/async_request.html | 130 +- docs/api-docs/slack_bolt/request/index.html | 58 +- .../slack_bolt/request/internals.html | 629 +---- .../slack_bolt/request/payload_utils.html | 439 +-- docs/api-docs/slack_bolt/request/request.html | 129 +- docs/api-docs/slack_bolt/response/index.html | 68 +- .../slack_bolt/response/response.html | 114 +- .../api-docs/slack_bolt/util/async_utils.html | 58 +- docs/api-docs/slack_bolt/util/index.html | 34 +- docs/api-docs/slack_bolt/util/utils.html | 223 +- docs/api-docs/slack_bolt/version.html | 35 +- docs/api-docs/slack_bolt/workflows/index.html | 43 +- .../slack_bolt/workflows/step/async_step.html | 881 +----- .../workflows/step/async_step_middleware.html | 90 +- .../slack_bolt/workflows/step/index.html | 120 +- .../slack_bolt/workflows/step/internals.html | 43 +- .../slack_bolt/workflows/step/step.html | 929 +------ .../workflows/step/step_middleware.html | 93 +- .../step/utilities/async_complete.html | 69 +- .../step/utilities/async_configure.html | 88 +- .../workflows/step/utilities/async_fail.html | 70 +- .../step/utilities/async_update.html | 85 +- .../workflows/step/utilities/complete.html | 69 +- .../workflows/step/utilities/configure.html | 85 +- .../workflows/step/utilities/fail.html | 70 +- .../workflows/step/utilities/index.html | 52 +- .../workflows/step/utilities/update.html | 85 +- slack_bolt/version.py | 2 +- 191 files changed, 3108 insertions(+), 30291 deletions(-) diff --git a/docs/api-docs/slack_bolt/adapter/aiohttp/index.html b/docs/api-docs/slack_bolt/adapter/aiohttp/index.html index 693657840..54bee5e0f 100644 --- a/docs/api-docs/slack_bolt/adapter/aiohttp/index.html +++ b/docs/api-docs/slack_bolt/adapter/aiohttp/index.html @@ -2,18 +2,21 @@ - - + + slack_bolt.adapter.aiohttp API documentation - - - - - - + + + + + + - - + +
    @@ -22,58 +25,6 @@

    Module slack_bolt.adapter.aiohttp

    -
    - -Expand source code - -
    import re
    -
    -from aiohttp import web
    -
    -from slack_bolt.request.async_request import AsyncBoltRequest
    -from slack_bolt.response import BoltResponse
    -
    -
    -async def to_bolt_request(request: web.Request) -> AsyncBoltRequest:
    -    return AsyncBoltRequest(
    -        body=await request.text(),
    -        query=request.query_string,
    -        headers=request.headers,
    -    )
    -
    -
    -async def to_aiohttp_response(bolt_resp: BoltResponse) -> web.Response:
    -    content_type = bolt_resp.headers.pop(
    -        "content-type",
    -        ["application/json" if bolt_resp.body.startswith("{") else "text/plain"],
    -    )[0]
    -    content_type = re.sub(r";\s*charset=utf-8", "", content_type)
    -    resp = web.Response(
    -        status=bolt_resp.status,
    -        body=bolt_resp.body,
    -        headers=bolt_resp.first_headers_without_set_cookie(),
    -        content_type=content_type,
    -    )
    -    for cookie in bolt_resp.cookies():
    -        for name, c in cookie.items():
    -            resp.set_cookie(
    -                name=name,
    -                value=c.value,
    -                max_age=c.get("max-age"),
    -                expires=c.get("expires"),
    -                path=c.get("path"),
    -                domain=c.get("domain"),
    -                secure=True,
    -                httponly=True,
    -            )
    -    return resp
    -
    -
    -__all__ = [
    -    "to_bolt_request",
    -    "to_aiohttp_response",
    -]
    -
    @@ -87,53 +38,12 @@

    Functions

    -
    - -Expand source code - -
    async def to_aiohttp_response(bolt_resp: BoltResponse) -> web.Response:
    -    content_type = bolt_resp.headers.pop(
    -        "content-type",
    -        ["application/json" if bolt_resp.body.startswith("{") else "text/plain"],
    -    )[0]
    -    content_type = re.sub(r";\s*charset=utf-8", "", content_type)
    -    resp = web.Response(
    -        status=bolt_resp.status,
    -        body=bolt_resp.body,
    -        headers=bolt_resp.first_headers_without_set_cookie(),
    -        content_type=content_type,
    -    )
    -    for cookie in bolt_resp.cookies():
    -        for name, c in cookie.items():
    -            resp.set_cookie(
    -                name=name,
    -                value=c.value,
    -                max_age=c.get("max-age"),
    -                expires=c.get("expires"),
    -                path=c.get("path"),
    -                domain=c.get("domain"),
    -                secure=True,
    -                httponly=True,
    -            )
    -    return resp
    -
    async def to_bolt_request(request: aiohttp.web_request.Request) ‑> AsyncBoltRequest
    -
    - -Expand source code - -
    async def to_bolt_request(request: web.Request) -> AsyncBoltRequest:
    -    return AsyncBoltRequest(
    -        body=await request.text(),
    -        query=request.query_string,
    -        headers=request.headers,
    -    )
    -
    @@ -141,7 +51,6 @@

    Functions

    - \ No newline at end of file + diff --git a/docs/api-docs/slack_bolt/adapter/asgi/aiohttp/index.html b/docs/api-docs/slack_bolt/adapter/asgi/aiohttp/index.html index 3bd13d292..6fa6de3a4 100644 --- a/docs/api-docs/slack_bolt/adapter/asgi/aiohttp/index.html +++ b/docs/api-docs/slack_bolt/adapter/asgi/aiohttp/index.html @@ -2,18 +2,21 @@ - - + + slack_bolt.adapter.asgi.aiohttp API documentation - - - - - - + + + + + + - - + +
    @@ -22,64 +25,6 @@

    Module slack_bolt.adapter.asgi.aiohttp

    -
    - -Expand source code - -
    from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow
    -
    -from slack_bolt.adapter.asgi.http_request import AsgiHttpRequest
    -from slack_bolt.adapter.asgi.builtin import SlackRequestHandler
    -
    -from slack_bolt.async_app import AsyncApp
    -
    -from slack_bolt.async_app import AsyncBoltRequest
    -from slack_bolt.response import BoltResponse
    -
    -
    -class AsyncSlackRequestHandler(SlackRequestHandler):
    -    app: AsyncApp
    -
    -    def __init__(self, app: AsyncApp, path: str = "/slack/events"):
    -        """Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers.
    -        This can be used for production deployment.
    -
    -        With the default settings, `http://localhost:3000/slack/events`
    -        Run Bolt with [uvicron](https://www.uvicorn.org/)
    -
    -            # Python
    -            app = AsyncApp()
    -            api = SlackRequestHandler(app)
    -
    -            # bash
    -            export SLACK_SIGNING_SECRET=***
    -            export SLACK_BOT_TOKEN=xoxb-***
    -            uvicorn app:api --port 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
    -
    -    async def dispatch(self, request: AsgiHttpRequest) -> BoltResponse:
    -        return await self.app.async_dispatch(
    -            AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
    -        )
    -
    -    async def handle_installation(self, request: AsgiHttpRequest) -> BoltResponse:
    -        oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
    -        return await oauth_flow.handle_installation(
    -            AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
    -        )
    -
    -    async def handle_callback(self, request: AsgiHttpRequest) -> BoltResponse:
    -        oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
    -        return await oauth_flow.handle_callback(
    -            AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
    -        )
    -
    @@ -189,7 +134,6 @@

    Inherited members

    - \ No newline at end of file + diff --git a/docs/api-docs/slack_bolt/adapter/asgi/async_handler.html b/docs/api-docs/slack_bolt/adapter/asgi/async_handler.html index b93e472f9..f100b1161 100644 --- a/docs/api-docs/slack_bolt/adapter/asgi/async_handler.html +++ b/docs/api-docs/slack_bolt/adapter/asgi/async_handler.html @@ -2,18 +2,21 @@ - - + + slack_bolt.adapter.asgi.async_handler API documentation - - - - - - + + + + + + - - + +
    @@ -22,14 +25,6 @@

    Module slack_bolt.adapter.asgi.async_handler

    -
    - -Expand source code - -
    from .aiohttp import AsyncSlackRequestHandler
    -
    -__all__ = ["AsyncSlackRequestHandler"]
    -
    @@ -139,7 +134,6 @@

    Inherited members

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/async_step.html b/docs/static/api-docs/slack_bolt/workflows/step/async_step.html index b957cabad..14f26c1c0 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/async_step.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/async_step.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.async_step API documentation - + @@ -37,7 +37,7 @@

    Classes

    class AsyncWorkflowStep -(*, callback_id: Union[str, Pattern], edit: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], save: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], execute: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], app_name: Optional[str] = None, base_logger: Optional[logging.Logger] = None) +(*,
    callback_id: str | Pattern,
    edit: Callable[..., Awaitable[BoltResponse]] | AsyncListener | Sequence[Callable],
    save: Callable[..., Awaitable[BoltResponse]] | AsyncListener | Sequence[Callable],
    execute: Callable[..., Awaitable[BoltResponse]] | AsyncListener | Sequence[Callable],
    app_name: str | None = None,
    base_logger: logging.Logger | None = None)

    Deprecated

    @@ -210,7 +210,7 @@

    Args

    Class variables

    -
    var callback_id : Union[str, Pattern]
    +
    var callback_id : str | Pattern

    The Callback ID of the step from app

    @@ -230,13 +230,13 @@

    Class variables

    Static methods

    -def build_listener(callback_id: Union[str, Pattern], app_name: str, listener_or_functions: Union[AsyncListener, Callable, List[Callable]], name: str, matchers: Optional[List[AsyncListenerMatcher]] = None, middleware: Optional[List[AsyncMiddleware]] = None, base_logger: Optional[logging.Logger] = None) +def build_listener(callback_id: str | Pattern,
    app_name: str,
    listener_or_functions: AsyncListener | Callable | List[Callable],
    name: str,
    matchers: List[AsyncListenerMatcher] | None = None,
    middleware: List[AsyncMiddleware] | None = None,
    base_logger: logging.Logger | None = None)
    -def builder(callback_id: Union[str, Pattern], base_logger: Optional[logging.Logger] = None) ‑> AsyncWorkflowStepBuilder +def builder(callback_id: str | Pattern, base_logger: logging.Logger | None = None) ‑> AsyncWorkflowStepBuilder

    Deprecated

    @@ -247,7 +247,7 @@

    Static methods

    class AsyncWorkflowStepBuilder -(callback_id: Union[str, Pattern], app_name: Optional[str] = None, base_logger: Optional[logging.Logger] = None) +(callback_id: str | Pattern,
    app_name: str | None = None,
    base_logger: logging.Logger | None = None)

    Steps from apps @@ -575,7 +575,7 @@

    Args

    Class variables

    -
    var callback_id : Union[str, Pattern]
    +
    var callback_id : str | Pattern
    @@ -583,13 +583,13 @@

    Class variables

    Static methods

    -def to_listener_matchers(app_name: str, matchers: Optional[List[Union[AsyncListenerMatcher, Callable[..., Awaitable[bool]]]]]) ‑> List[AsyncListenerMatcher] +def to_listener_matchers(app_name: str,
    matchers: List[AsyncListenerMatcher | Callable[..., Awaitable[bool]]] | None) ‑> List[AsyncListenerMatcher]
    -def to_listener_middleware(app_name: str, middleware: Optional[List[Union[Callable, AsyncMiddleware]]]) ‑> List[AsyncMiddleware] +def to_listener_middleware(app_name: str,
    middleware: List[Callable | AsyncMiddleware] | None) ‑> List[AsyncMiddleware]
    @@ -598,7 +598,7 @@

    Static methods

    Methods

    -def build(self, base_logger: Optional[logging.Logger] = None) ‑> AsyncWorkflowStep +def build(self, base_logger: logging.Logger | None = None) ‑> AsyncWorkflowStep

    Deprecated

    @@ -610,7 +610,7 @@

    Returns

    An AsyncWorkflowStep object

    -def edit(self, *args, matchers: Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, AsyncMiddleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None) +def edit(self,
    *args,
    matchers: Callable[..., Awaitable[bool]] | AsyncListenerMatcher | None = None,
    middleware: Callable | AsyncMiddleware | None = None,
    lazy: List[Callable[..., Awaitable[None]]] | None = None)

    Deprecated

    @@ -643,7 +643,7 @@

    Args

    -def execute(self, *args, matchers: Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, AsyncMiddleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None) +def execute(self,
    *args,
    matchers: Callable[..., Awaitable[bool]] | AsyncListenerMatcher | None = None,
    middleware: Callable | AsyncMiddleware | None = None,
    lazy: List[Callable[..., Awaitable[None]]] | None = None)

    Deprecated

    @@ -676,7 +676,7 @@

    Args

    -def save(self, *args, matchers: Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, AsyncMiddleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., Awaitable[None]]]] = None) +def save(self,
    *args,
    matchers: Callable[..., Awaitable[bool]] | AsyncListenerMatcher | None = None,
    middleware: Callable | AsyncMiddleware | None = None,
    lazy: List[Callable[..., Awaitable[None]]] | None = None)

    Deprecated

    @@ -754,7 +754,7 @@

    -

    Generated by pdoc 0.11.1.

    +

    Generated by pdoc 0.11.3.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/async_step_middleware.html b/docs/static/api-docs/slack_bolt/workflows/step/async_step_middleware.html index 43034c84f..02f06eedc 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/async_step_middleware.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/async_step_middleware.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.async_step_middleware API documentation - + @@ -129,7 +129,7 @@

    -

    Generated by pdoc 0.11.1.

    +

    Generated by pdoc 0.11.3.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/index.html b/docs/static/api-docs/slack_bolt/workflows/step/index.html index 17362d7b0..2ddde44bf 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/index.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/index.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step API documentation - + @@ -370,7 +370,7 @@

    Classes

    class WorkflowStep -(*, callback_id: Union[str, Pattern], edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], app_name: Optional[str] = None, base_logger: Optional[logging.Logger] = None) +(*,
    callback_id: str | Pattern,
    edit: Callable[..., BoltResponse | None] | Listener | Sequence[Callable],
    save: Callable[..., BoltResponse | None] | Listener | Sequence[Callable],
    execute: Callable[..., BoltResponse | None] | Listener | Sequence[Callable],
    app_name: str | None = None,
    base_logger: logging.Logger | None = None)

    Deprecated

    @@ -556,7 +556,7 @@

    Args

    Class variables

    -
    var callback_id : Union[str, Pattern]
    +
    var callback_id : str | Pattern

    The Callback ID of the step from app

    @@ -576,13 +576,13 @@

    Class variables

    Static methods

    -def build_listener(callback_id: Union[str, Pattern], app_name: str, listener_or_functions: Union[Listener, Callable, List[Callable]], name: str, matchers: Optional[List[ListenerMatcher]] = None, middleware: Optional[List[Middleware]] = None, base_logger: Optional[logging.Logger] = None) ‑> Listener +def build_listener(callback_id: str | Pattern,
    app_name: str,
    listener_or_functions: Listener | Callable | List[Callable],
    name: str,
    matchers: List[ListenerMatcher] | None = None,
    middleware: List[Middleware] | None = None,
    base_logger: logging.Logger | None = None) ‑> Listener
    -def builder(callback_id: Union[str, Pattern], base_logger: Optional[logging.Logger] = None) ‑> WorkflowStepBuilder +def builder(callback_id: str | Pattern, base_logger: logging.Logger | None = None) ‑> WorkflowStepBuilder

    Deprecated

    @@ -721,7 +721,7 @@

    -

    Generated by pdoc 0.11.1.

    +

    Generated by pdoc 0.11.3.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/internals.html b/docs/static/api-docs/slack_bolt/workflows/step/internals.html index 346cff464..5b6300d0b 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/internals.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/internals.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.internals API documentation - + @@ -49,7 +49,7 @@

    Module slack_bolt.workflows.step.internals

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/step.html b/docs/static/api-docs/slack_bolt/workflows/step/step.html index 4c74b76f8..6030a2dde 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/step.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/step.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.step API documentation - + @@ -37,7 +37,7 @@

    Classes

    class WorkflowStep -(*, callback_id: Union[str, Pattern], edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], app_name: Optional[str] = None, base_logger: Optional[logging.Logger] = None) +(*,
    callback_id: str | Pattern,
    edit: Callable[..., BoltResponse | None] | Listener | Sequence[Callable],
    save: Callable[..., BoltResponse | None] | Listener | Sequence[Callable],
    execute: Callable[..., BoltResponse | None] | Listener | Sequence[Callable],
    app_name: str | None = None,
    base_logger: logging.Logger | None = None)

    Deprecated

    @@ -223,7 +223,7 @@

    Args

    Class variables

    -
    var callback_id : Union[str, Pattern]
    +
    var callback_id : str | Pattern

    The Callback ID of the step from app

    @@ -243,13 +243,13 @@

    Class variables

    Static methods

    -def build_listener(callback_id: Union[str, Pattern], app_name: str, listener_or_functions: Union[Listener, Callable, List[Callable]], name: str, matchers: Optional[List[ListenerMatcher]] = None, middleware: Optional[List[Middleware]] = None, base_logger: Optional[logging.Logger] = None) ‑> Listener +def build_listener(callback_id: str | Pattern,
    app_name: str,
    listener_or_functions: Listener | Callable | List[Callable],
    name: str,
    matchers: List[ListenerMatcher] | None = None,
    middleware: List[Middleware] | None = None,
    base_logger: logging.Logger | None = None) ‑> Listener
    -def builder(callback_id: Union[str, Pattern], base_logger: Optional[logging.Logger] = None) ‑> WorkflowStepBuilder +def builder(callback_id: str | Pattern, base_logger: logging.Logger | None = None) ‑> WorkflowStepBuilder

    Deprecated

    @@ -260,7 +260,7 @@

    Static methods

    class WorkflowStepBuilder -(callback_id: Union[str, Pattern], app_name: Optional[str] = None, base_logger: Optional[logging.Logger] = None) +(callback_id: str | Pattern,
    app_name: str | None = None,
    base_logger: logging.Logger | None = None)

    Steps from apps @@ -604,7 +604,7 @@

    Args

    Class variables

    -
    var callback_id : Union[str, Pattern]
    +
    var callback_id : str | Pattern
    @@ -612,13 +612,13 @@

    Class variables

    Static methods

    -def to_listener_matchers(app_name: str, matchers: Optional[List[Union[ListenerMatcher, Callable[..., bool]]]], base_logger: Optional[logging.Logger] = None) ‑> List[ListenerMatcher] +def to_listener_matchers(app_name: str,
    matchers: List[ListenerMatcher | Callable[..., bool]] | None,
    base_logger: logging.Logger | None = None) ‑> List[ListenerMatcher]
    -def to_listener_middleware(app_name: str, middleware: Optional[List[Union[Callable, Middleware]]], base_logger: Optional[logging.Logger] = None) ‑> List[Middleware] +def to_listener_middleware(app_name: str,
    middleware: List[Callable | Middleware] | None,
    base_logger: logging.Logger | None = None) ‑> List[Middleware]
    @@ -627,7 +627,7 @@

    Static methods

    Methods

    -def build(self, base_logger: Optional[logging.Logger] = None) ‑> WorkflowStep +def build(self, base_logger: logging.Logger | None = None) ‑> WorkflowStep

    Deprecated

    @@ -639,7 +639,7 @@

    Returns

    WorkflowStep object

    -def edit(self, *args, matchers: Union[Callable[..., bool], ListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, Middleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., None]]] = None) +def edit(self,
    *args,
    matchers: Callable[..., bool] | ListenerMatcher | None = None,
    middleware: Callable | Middleware | None = None,
    lazy: List[Callable[..., None]] | None = None)

    Deprecated

    @@ -672,7 +672,7 @@

    Args

    -def execute(self, *args, matchers: Union[Callable[..., bool], ListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, Middleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., None]]] = None) +def execute(self,
    *args,
    matchers: Callable[..., bool] | ListenerMatcher | None = None,
    middleware: Callable | Middleware | None = None,
    lazy: List[Callable[..., None]] | None = None)

    Deprecated

    @@ -705,7 +705,7 @@

    Args

    -def save(self, *args, matchers: Union[Callable[..., bool], ListenerMatcher, ForwardRef(None)] = None, middleware: Union[Callable, Middleware, ForwardRef(None)] = None, lazy: Optional[List[Callable[..., None]]] = None) +def save(self,
    *args,
    matchers: Callable[..., bool] | ListenerMatcher | None = None,
    middleware: Callable | Middleware | None = None,
    lazy: List[Callable[..., None]] | None = None)

    Deprecated

    @@ -783,7 +783,7 @@

    -

    Generated by pdoc 0.11.1.

    +

    Generated by pdoc 0.11.3.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/step_middleware.html b/docs/static/api-docs/slack_bolt/workflows/step/step_middleware.html index 60b71fb99..62e00b9eb 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/step_middleware.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/step_middleware.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.step_middleware API documentation - + @@ -132,7 +132,7 @@

    -

    Generated by pdoc 0.11.1.

    +

    Generated by pdoc 0.11.3.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_complete.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_complete.html index 6c9c07332..629ceff2a 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_complete.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_complete.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.utilities.async_complete API documentation - + @@ -123,7 +123,7 @@

    -

    Generated by pdoc 0.11.1.

    +

    Generated by pdoc 0.11.3.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_configure.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_configure.html index 5d9b78553..0e89e1781 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_configure.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_configure.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.utilities.async_configure API documentation - + @@ -37,7 +37,7 @@

    Classes

    class AsyncConfigure -(*, callback_id: str, client: slack_sdk.web.async_client.AsyncWebClient, body: dict) +(*,
    callback_id: str,
    client: slack_sdk.web.async_client.AsyncWebClient,
    body: dict)

    configure() utility to send the modal view in Workflow Builder.

    @@ -146,7 +146,7 @@

    -

    Generated by pdoc 0.11.1.

    +

    Generated by pdoc 0.11.3.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_fail.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_fail.html index 078cfd9e4..220a5025c 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_fail.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_fail.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.utilities.async_fail API documentation - + @@ -121,7 +121,7 @@

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_update.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_update.html index b92052234..45692da2d 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_update.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_update.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.utilities.async_update API documentation - + @@ -155,7 +155,7 @@

    -

    Generated by pdoc 0.11.1.

    +

    Generated by pdoc 0.11.3.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/complete.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/complete.html index 66c844372..0243a8e1c 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/complete.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/complete.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.utilities.complete API documentation - + @@ -123,7 +123,7 @@

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/configure.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/configure.html index aedc190bc..eabf41e6c 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/configure.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/configure.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.utilities.configure API documentation - + @@ -143,7 +143,7 @@

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/fail.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/fail.html index 703b565d7..9a878c2c3 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/fail.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/fail.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.utilities.fail API documentation - + @@ -121,7 +121,7 @@

    -

    Generated by pdoc 0.11.1.

    +

    Generated by pdoc 0.11.3.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/index.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/index.html index f610b0b6d..3e8165202 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/index.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/index.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.utilities API documentation - + @@ -116,7 +116,7 @@

    Sub-modules

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/update.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/update.html index f39c3a56d..8f95aa1ec 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/update.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/update.html @@ -3,13 +3,13 @@ - + slack_bolt.workflows.step.utilities.update API documentation - + @@ -155,7 +155,7 @@

    -

    Generated by pdoc 0.11.1.

    +

    Generated by pdoc 0.11.3.

    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 7b7d241dc..faea892ad 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,3 +1,3 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.21.2" +__version__ = "1.21.3" From 4eb385531a50cb1f174ef4c698347550d5b362c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 08:15:00 +0900 Subject: [PATCH 704/865] chore(deps): bump path-to-regexp and express in /docs (#1218) Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together. Updates `path-to-regexp` from 0.1.10 to 1.9.0 - [Release notes](https://github.com/pillarjs/path-to-regexp/releases) - [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md) - [Commits](https://github.com/pillarjs/path-to-regexp/compare/v0.1.10...v1.9.0) Updates `express` from 4.21.1 to 4.21.2 - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/4.21.2/History.md) - [Commits](https://github.com/expressjs/express/compare/4.21.1...4.21.2) --- updated-dependencies: - dependency-name: path-to-regexp dependency-type: indirect - dependency-name: express dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index e2a708b9c..3f7b89df8 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -7262,9 +7262,10 @@ } }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7285,7 +7286,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -7300,6 +7301,10 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/content-disposition": { @@ -7327,9 +7332,10 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" }, "node_modules/express/node_modules/range-parser": { "version": "1.2.1", From 35a4b09b112a09a8398a9c28740b0d02d242396e Mon Sep 17 00:00:00 2001 From: Henrique Date: Wed, 4 Dec 2024 10:16:39 -0500 Subject: [PATCH 705/865] Fix #1204 Deprecation Warnings in Slack Bolt Integration with Sanic Co-authored-by: Kazuhiro Sera --- slack_bolt/adapter/sanic/async_handler.py | 28 +++++++++++-------- tests/adapter_tests_async/test_async_sanic.py | 2 ++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/slack_bolt/adapter/sanic/async_handler.py b/slack_bolt/adapter/sanic/async_handler.py index 004de61eb..4b01d1e58 100644 --- a/slack_bolt/adapter/sanic/async_handler.py +++ b/slack_bolt/adapter/sanic/async_handler.py @@ -29,19 +29,25 @@ def to_sanic_response(bolt_resp: BoltResponse) -> HTTPResponse: body=bolt_resp.body, headers=bolt_resp.first_headers_without_set_cookie(), ) + for cookie in bolt_resp.cookies(): - for name, c in cookie.items(): - resp.cookies[name] = c.value + for key, c in cookie.items(): expire_value = c.get("expires") - if expire_value is not None and expire_value != "": - expire = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") - resp.cookies[name]["expires"] = expire - resp.cookies[name]["path"] = c.get("path") - resp.cookies[name]["domain"] = c.get("domain") - if c.get("max-age") is not None and len(c.get("max-age")) > 0: # type: ignore[arg-type] - resp.cookies[name]["max-age"] = int(c.get("max-age")) # type: ignore[arg-type] - resp.cookies[name]["secure"] = True - resp.cookies[name]["httponly"] = True + expires = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value else None + max_age = int(c["max-age"]) if c.get("max-age") else None + path = str(c.get("path")) if c.get("path") else "/" + domain = str(c.get("domain")) if c.get("domain") else None + resp.add_cookie( + key=key, + value=c.value, + expires=expires, + path=path, + domain=domain, + max_age=max_age, + secure=True, + httponly=True, + ) + return resp diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py index 1b6bca8e2..316110a87 100644 --- a/tests/adapter_tests_async/test_async_sanic.py +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -218,6 +218,8 @@ async def endpoint(req: Request): _, response = await api.asgi_client.get(url="/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 response.headers.get("set-cookie").endswith("; Path=/; Max-Age=600; SameSite=Lax; Secure; HttpOnly") is True # NOTE: Although sanic-testing 0.6 does not have this value, # Sanic apps properly generate the content-length header From 79b43e2b21c630573ce5ca933048199508b45856 Mon Sep 17 00:00:00 2001 From: Tracy Rericha <108959677+technically-tracy@users.noreply.github.com> Date: Thu, 12 Dec 2024 13:40:06 -0500 Subject: [PATCH 706/865] Docs: Custom steps for JIRA tutorial. (#1214) * custom steps tutorial * PR feedback --- .../content/tutorial/custom-steps-for-jira.md | 172 ++++++++++++++++++ docs/sidebars.js | 3 +- docs/static/img/custom-steps-jira/1.png | Bin 0 -> 80073 bytes docs/static/img/custom-steps-jira/2.png | Bin 0 -> 169104 bytes docs/static/img/custom-steps-jira/3.png | Bin 0 -> 52013 bytes docs/static/img/custom-steps-jira/4.png | Bin 0 -> 56543 bytes docs/static/img/custom-steps-jira/5.png | Bin 0 -> 35043 bytes docs/static/img/custom-steps-jira/6.png | Bin 0 -> 98092 bytes docs/static/img/custom-steps-jira/7.png | Bin 0 -> 80480 bytes 9 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 docs/content/tutorial/custom-steps-for-jira.md create mode 100644 docs/static/img/custom-steps-jira/1.png create mode 100644 docs/static/img/custom-steps-jira/2.png create mode 100644 docs/static/img/custom-steps-jira/3.png create mode 100644 docs/static/img/custom-steps-jira/4.png create mode 100644 docs/static/img/custom-steps-jira/5.png create mode 100644 docs/static/img/custom-steps-jira/6.png create mode 100644 docs/static/img/custom-steps-jira/7.png diff --git a/docs/content/tutorial/custom-steps-for-jira.md b/docs/content/tutorial/custom-steps-for-jira.md new file mode 100644 index 000000000..5da362a83 --- /dev/null +++ b/docs/content/tutorial/custom-steps-for-jira.md @@ -0,0 +1,172 @@ +# Custom steps for JIRA + +In this tutorial, you'll learn how to configure custom steps for use with JIRA. Here's what we'll do with this sample app: + +1. Create your app from an app manifest and clone a starter template +2. Set up and run your local project +3. Create a workflow with a custom step using Workflow Builder +4. Create an issue in JIRA using your custom step + +## Prerequisites {#prereqs} + +Before getting started, you will need the following: + +* a development workspace where you have permissions to install apps. If you don’t have a workspace, go ahead and set that up now—you can [go here](https://slack.com/get-started#create) to create one, or you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. +* a development environment with [Python 3.6](https://www.python.org/downloads/) or later. + +**Skip to the code** +If you'd rather skip the tutorial and just head straight to the code, you can use our [Bolt for Python JIRA functions sample](https://github.com/slack-samples/bolt-python-jira-functions) as a template. + +## Creating your app {#create-app} + +1. Navigate to the [app creation page](https://api.slack.com/apps/new) and select **From a manifest**. +2. Select the workspace you want to install the application in, then click **Next**. +3. Copy the contents of the [`manifest.json`](https://github.com/slack-samples/bolt-python-ai-chatbot/blob/main/manifest.json) file below into the text box that says **Paste your manifest code here** (within the **JSON** tab), then click **Next**: + +```js reference title="manifest.json" +https://github.com/slack-samples/bolt-python-jira-functions/blob/main/manifest.json +``` + +4. Review the configuration and click **Create**. +5. You're now in your app configuration's **Basic Information** page. Click **Install App**, then **Install to _your-workspace-name_**, then **Allow** on the screen that follows. + +### Obtaining and storing your environment variables {#environment-variables} + +Before you'll be able to successfully run the app, you'll need to obtain and set some environment variables. + +1. Once you have installed the app to your workspace, copy the **Bot User OAuth Token** from the **Install App** page. You will store this in your environment as `SLACK_BOT_TOKEN` (we'll get to that next). +2. Navigate to **Basic Information** and in the **App-Level Tokens** section , click **Generate Token and Scopes**. Add the [`connections:write`](https://api.slack.com/scopes/connections:write) scope, name the token, and click **Generate**. (For more details, refer to [understanding OAuth scopes for bots](https://api.slack.com/tutorials/tracks/understanding-oauth-scopes-bot)). Copy this token. You will store this in your environment as `SLACK_APP_TOKEN`. +3. Follow [these instructions](https://confluence.atlassian.com/adminjiraserver0909/configure-an-incoming-link-1251415519.html) to create an external app link and to generate its redirect URL (the base of which will be stored as your APP_BASE_URL variable below), client ID, and client secret. +4. Run the following commands in your terminal to store your environment variables, client ID, and client secret. +5. You'll also need to know your team ID (found by opening your Slack instance in a web browser and copying the value within the link that starts with the letter **T**) and your app ID (found under **Basic Information**). + +**For macOS** +```bash +export SLACK_BOT_TOKEN= +export SLACK_APP_TOKEN= +export JIRA_CLIENT_ID= +export JIRA_CLIENT_SECRET= +``` + +**For Windows** +```bash +set SLACK_BOT_TOKEN= +set SLACK_APP_TOKEN= +set JIRA_CLIENT_ID= +set JIRA_CLIENT_SECRET= +``` + +## Setting up and running your local project {#configure-project} + +Clone the starter template onto your machine by running the following command: + +```bash +git clone https://github.com/slack-samples/bolt-python-jira-functions.git +``` + +Change into the new project directory: + +```bash +cd bolt-python-jira-functions +``` + +Start your Python virtual environment: + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + + + + +```bash +python3 -m venv .venv +source .venv/bin/activate +``` + + + + +```bash +py -m venv .venv +.venv\Scripts\activate +``` + + + +Install the required dependencies: + +```bash +pip install -r requirements.txt +``` + +Rename the `.example.env` file to `.env` and replace the values for each of the variables listed in the file: + +``` +JIRA_BASE_URL=https://your-jira-instance.com +SECRET_HEADER_KEY=Your-Header +SECRET_HEADER_VALUE=abc123 +JIRA_CLIENT_ID=abc123 +JIRA_CLIENT_SECRET=abc123 +APP_BASE_URL=https://1234-123-123-12.ngrok-free.app +APP_HOME_PAGE_URL=slack://app?team=YOUR_TEAM_ID&id=YOUR_APP_ID&tab=home +``` + +You could also store the values for your `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` here. + +Start your local server: + +```bash +python app.py +``` + +If your app is up and running, you'll see a message noting that the app is starting to receive messages from a new connection. + +## Setting up your workflow in Workflow Builder {#workflow} + +1. Within your development workspace, open Workflow Builder by clicking your workspace name and then selecting **Tools** > **Workflow Builder**. +2. Select **New Workflow** > **Build Workflow**. +3. Click **Untitled Workflow** at the top of the pane to rename your workflow. We'll call it **Create Issue**. For the description, enter _Creates a new issue_, then click **Save**. + +![Workflow details](/img/custom-steps-jira/1.png) + +4. Select **Choose an event** under **Start the workflow...**, and then select **From a link in Slack**. Click **Continue**. + +![Start the workflow](/img/custom-steps-jira/2.png) + +5. Under **Then, do these things** click **Add steps** to add the custom step. Your custom step will be the function defined in the [`create_issue.py`](https://github.com/slack-samples/bolt-python-jira-functions/blob/main/listeners/functions/create_issue.py) file. + + Scroll down to the bottom of the list on the right-hand pane and select **Custom**, then **BoltPy Jira Functions** > **Create an issue**. Enter the project details, issue type (optional), summary (optional), and description (optional). Click **Save**. + +![Custom function](/img/custom-steps-jira/3.png) + +6. Add another step and select **Messages** > **Send a message to a channel**. Select **Channel where the workflow was used** from the drop-down list and then select **Insert a variable** and **Issue url**. Click **Save**. + +![Insert variable for issue URL](/img/custom-steps-jira/4.png) + +7. Click **Publish** to make the workflow available to your workspace. + +## Running your app {#run} + +1. Copy your workflow link. +2. Navigate to your app's home tab and click **Connect an Account** to connect your JIRA account to the app. + +![Connect account](/img/custom-steps-jira/5.png) + +3. Click **Allow** on the screen that appears. + +![Allow connection](/img/custom-steps-jira/6.png) + +4. In any channel, post the workflow link you copied. +5. Click **Start Workflow** and observe as the link to a new JIRA ticket is posted in the channel. Click the link to be directed to the newly-created issue within your JIRA project. + +![JIRA issue](/img/custom-steps-jira/7.png) + +When finished, you can click the **Disconnect Account** button in the home tab to disconnect your app from your JIRA account. + +## Next steps {#next-steps} + +Congratulations! You've successfully customized your workspace with custom steps in Workflow Builder. Check out these links to take the next steps in your journey. + +* To learn more about Bolt for Python, refer to the [getting started](/getting-started) documentation. +* For more details about creating workflow steps using the Bolt SDK, refer to the [workflow steps for Bolt](https://api.slack.com/automation/functions/custom-bolt) guide. +* For information about custom steps dynamic options, refer to [custom steps dynamic options in Workflow Builder](https://api.slack.com/automation/runonslack/custom-steps-dynamic-options). diff --git a/docs/sidebars.js b/docs/sidebars.js index 2fed41a3c..0db14e18d 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -83,7 +83,8 @@ const sidebars = { label: 'Tutorials', items: [ 'tutorial/getting-started-http', - 'tutorial/ai-chatbot' + 'tutorial/ai-chatbot', + `tutorial/custom-steps-for-jira` ], }, { type: 'html', value: '
    ' }, diff --git a/docs/static/img/custom-steps-jira/1.png b/docs/static/img/custom-steps-jira/1.png new file mode 100644 index 0000000000000000000000000000000000000000..5d8bb0448766ad39b1083d5549d77bdbc691818d GIT binary patch literal 80073 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/custom-steps-jira/2.png b/docs/static/img/custom-steps-jira/2.png new file mode 100644 index 0000000000000000000000000000000000000000..67e55c65d0f20a546db4c1d9291fd02755bdda87 GIT binary patch literal 169104 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%0D8BSiBZpW2vPi3gXn`TsVMK_4Qv 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 z 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 UD~sG`80e4mI|cCyQ3L<~2Yoe}3;+NC literal 0 HcmV?d00001 diff --git a/docs/static/img/custom-steps-jira/3.png b/docs/static/img/custom-steps-jira/3.png new file mode 100644 index 0000000000000000000000000000000000000000..76829fcd77aa67ca5ceed7c1311d1e54c8bebc4e GIT binary patch literal 52013 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 J^w`kve*nBM+;ji{ literal 0 HcmV?d00001 diff --git a/docs/static/img/custom-steps-jira/4.png b/docs/static/img/custom-steps-jira/4.png new file mode 100644 index 0000000000000000000000000000000000000000..ac4d3e89a6f2e3ea9937792eb46e746dcb9525ea GIT binary patch literal 56543 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%0At(@PB-APGky?aa3^mLIbzT>` z@AE#6$q;qRmv4Vuv|CJ^jGlR-fb`l5Xmo(U`+Q~dehi+`4T9Af)(k^oBoD1xk7x@G zw@C+lNJ2YkQA~R~pSyZck}n{u#%<`7r5J{aCaES+ZSTDQsKw^_@e%#bc0_DdFTLX~ z&d&!YpS)%vY+s}d67(tv`~pmQmQcU9s0g9HfZ-+A)y}`%((aGv@k7FU2tNtb&1z34 zh1_MCeZBB1BKvcuGoP1gi9X;Xrrpcy-ko#$+N!+g%~ZuN3aK3JP^iYFRTk|y!QS4P zYY_OG%TQ((q71-!!Q7Lx~gcyGjQ$_^PZ+OPS$Ke?44Y}j!=-T{2Z&u3(Ry+|+ zEwA+6R^VpQpe9SZ4?mHQueVa55n|4V$5}Z2=|WFd$6-OrF7dLZW!AUTidZJPQS&C) zG(@y+nt%A__`5x*?u2OsDtQcDRqF!u-Qu}McSbsu$#}Iir|eAbTPEFlhOs*olRwOi(y5_zAv6gx2-oJDxGS$g*# z&?w$To&v+ASRM!7-(?vXgxp$PPw56&IACxLi}`Hw<1GCKSeKr;9R!ti%3#F!(v3Ew z?q!>}vMh>J7sdjWBN4lJb&Y*wt5p7ni9T)66+1i%)KJ{8HMg^QS0Mfp|L1M=8f{L7 zdH0-Y8qv`$V#2@1KQnCS8-GMuM2?05i zEX8ciiUsZ-9>Gv3P=6-}sa^71UyPUR!2=6e`!$nsoFS7P3Rkk^Tlix~s5!F&30hXp z4ApIcF4{d6y%xNA6w2F?+OKBYw&W>+imd+NVF<#p`wXD~3j<3Th$7ftMM!oDMIMDZx2(MO^nQ3{%1# zf2oJKnm)sD>q4%iBPRJpq0-ZYqOdLl>*-D(iwi9tHXbw=vL}u;Cj<_EpA@sp)w8a% z?of!xxtYvy@UBVV4EO>}3mUJfs-)BF+iqU?J`-LtW#$&vkM!cOKc}|zw8n`UEkKhB zCsFNxZ}Z)Wr$USfOW78TP+5k^=)m@nzwRmgda9=?rXMv>f~R}|_7L391FU-A`CNwr z8kOeQRiqp&;Z5<1^d?ApujJSL<4IxHMAaG7Sj+4w@H4+XZ)gtzt#F_^|!O7v1lEV?9DSq;Iv1H)xIg+3-# z5Py(pXtvalgre0a?t?5TLGBXSfE(W}1yT{U88=qPV6mv9Mjei)@fh;Uf|LG|^4Bb5 z;v?mrRxhE`-l<_(6h1t8c`NCzOWC1+X)i2c_*d-IOB9Wnoc_$EpKLR(r*nd4MD_ZK z9_S8~4XoWM6(h_uDDt8sunU+9G)RL+dP8cg0cIbMqmBv~%t7IVxEm(K5jCjC;@%-p z-c)~8>h2SbxNk_oZGeJV;h;K#g5%#^kqj`{myju}#m^Eu%Evw5`x1*YD9Sm3g}{ng zZ8RUMh`9b60W!}fWts0h<;(#Spq480;`hfo@&1w069_JnJ#fF6kR+fSwdawV{|Q#N zgCUw~G7`i?`x%oZUU~?E)iGOj{9cCd{0d8&ehG^{ek4WJN{fT}KLPgwIs8aR4uCB! zLQ{=;>D80M;wgY!353)iU&yagxKg}I$p1khY>i4fs2NNP0ZNs(Y zPG^1z`=>0Y8W{>OwHPYq^`CBZV+9Mu1Tmili@E%#2Y#Jm1Vci)#Mt*=XzgE||9|Zi zKBqbbH8nS3!TI@lR`L2w7b-?5+<#R2da=S33Jyx0LIi4Mb+sK83;C#2Tn3o{E{u6^ zr10i>Uq+NE_Wrgx<9wm0li2d_)x|$X+Hw@g0dxgblvdXE1e{i0tL5YII1UjYyeTE9 zeuZZKspzO2$42;G-?*ScIqm#sLQX|xBlkz9S-oA{Z5Vw*VzRo2B^Tr}g{n8orlQ+z zs}{cEM0L^TM+3i)n@1_F1aRfz+?<-Wb|ot->+<%WkPg%#4OLy!KfwqM#NtsTR`ziR zs3jGXp+73A7GiIp#Vr`$FNSjH!%8Skbz0s|uX+D7q=oAQP)pT+v*+M}MRH?EChkxZjT>L;&LY7%use6oQb6`d~Kcft3*LjU0JOh%!5>3ZBQ=YW$F zs=rZE?PXH>a&)P=;zqbEW-*WXRaJDcB--}U5qyKu_{H9`HDy%oVx=3cf=)J-e&6Jz zzv3(-ao86u$}B7XuhPCLFt4raI<{@6apN?$lg4)AG`7_y4IA5yZQE93Ta9geyXUm$ zz58$e%j`URd969um}8Eu=ln5+?I*wh+BME=E|%t+31#`B{NK6ID?1z8f`%q-5{rLD3p=HI>D52eossFfG6931dOdDK|3|}WGP_qOcwgnsvt7LMw<~!O4ZlzCg50?SFj(&mAaIk+^Zg_jvzP)ChSWuQ10DrQRO=FU5 zM}im6G(?I2a}1_gz+9J@kP??#C>Jcwcf1zdj@drbJ=NIy^u*)WeDZp{pA&c((q6TC zeSJDxw)K7D(L_o)0WQgv01qxyV`g!tNO{%a{Md2Ay)N@{rXbC!^?FJsMJTdX+oov| zxN-018ZK5}9iKF;*AEc;>i7pbE0um&UZ^s<>id;>W->_zX*>McPnhU^Vh=EQRc)Sk zOZc{}7Wf}*FlbUJMuwbRRR!NkUi+Rb)e%iFnOqvT?4^{fa@))Cn8 zF8ObU>OV$B0mvEqta(t>hNX#KN!_SVXp%RY+>OUb)pq}4I4-M-d^I)2Q6GuE zKa&uvYboAxgRQGc1Z9u1{*jF7(W68Fh&PYxMHAd-f>R1mb+{8oy;cpD@df}Nqbdoz zEK{A_&gpO;?GKqWFiB#!Oh`q4Ak{OXU$HAbpTv zSFtdx$6{m*wko)3OfzP=9jMP;R+;>hjB4*urWJEoG~U5%iJqF?)MQt8E4;=PWR{gN z5JG?;bYX-qEd@jeLl_4(PhXBXm8m-ONJSAu|Fz}*hNwV){J@sU9eGDcQg>yj#7VXL z>|{NeD(As2V5SS?8^%%_2CbRIL+i(xigGrzb`m7v6o&QS&>=N7HRHcf&T4NFDAWVn z%Ypc6VLdHrlLc z!=QCKIEsA-u?MgseAn)b?$zH8k9-g;IwL#09+q8xe1TB(DNXX@JlEAv6O_b%WL69E ze!6SY-iqL{X#h6xG8tj2ZrABT)rikyP3B_$$5cY}L9mp%4h+-0QapI<_mgP-c&_BU zKe9xF;UG*}>ER$ug((2rX!^Yx4`s$3$PBqsMZVrHYe?_}2yUt+yd)06Fc2D>2^tq` zaE#EuI`iyrX4OX=ss=m5cuSKyPXw8$ced&DUt`*?r$%m1Th81wyO2lVD7tOMndPN+ z{?>lvh#n zUQdeLc0tow$HfuV?A)z+PSjg1el~JSi#ybI&_2G+i_<_lKl9D=6ck%1b_MZxFSxpYN|>xL zmPJD+xW`Sk3ReS516N1@+VX+Y_=vBp(CzgB+bU5_tT9!54yzRD+I4qW6*$O4@2rD# zxEk#mp-2~MEpiT*ywl9P7XCMzVq^*&wp}r|;3O&h$@_ zbkJ^YZuE%l5rdEj6I3qOM_7a;qEQwf=}>J2pHRMtKqI?{Z3q%S7|TO$p+*q#%~c_? zJt^Eb84dMqlwhgEV3Kbr@M)oqO@t+6pY{r$>5#|GPNHIUyd?=$(QT!oV&`U{@(!-W&HLyLZ<4#gMmmxw?!J=75mm z&IFqHS!YNQ?N;!jfP~gagTxAiVg-&x%00O=PEK@0WpG3@KV`5>y){BH54xD->R*TS zx<4YQ?SR81>O%zhrk$&z=#c!37`B73Frc9{hBVx=K4@;ybxN;SCdBxgaf||k5!C4? z*7RdiM^PFzyS0+-JVcnhiVGb!aKN<`WWZP|3*ns`2IG1%JL`xkF_d~PJ^1KL4)0^U zmz=DVp9iH$C>P<)neC4M42oWi`o#JaAsqMZ-xWz;$Wo_Bt!{i z;Xnl)@*G6OLr|RtB=^J zhhP`+E>fAxkI|LjjT0l4o z!9cMkANTm?_8e6Ek?$r4eB5{C|1K@S)isd5R!WE&{GEwU#4QqKX2GIz0$2xgP0oQ5 zWf8={Fzv@*!}5tNlxdcp`)dO#AtCywtPX^FTX2!;MhI|sN6lwjV{SPClEN;cLe7PCTmaW3N65p-2sakc1v zNLVB{5gg8`lo$OVAUt%xwE(iCn$O+`mrGK2$mi?o?_r+CMP9Ay9>rD3BS+kI2-d}X zI9j*=8S~`tK$tuDq?*+#=ibqR5?HFUiDu97_trv<(B*OGQ#Os9!; zKNH^gK~?$P(@%9JANJ8^-L8C@SNy)E9HsFQ&(_OlEgqBm17w|A-iP}X*9A?%BvEXR zpGL3adE37evi>>!50LzaW$z4@sKhsRlwxO8TV`v!gm!GzhZ%@e2-c-$7)aqCR@3ns zU)E9&Z?PH7UXJ66zV@XBSf$hQE`kq556zG&$vsAF$69Y=(CD9kOB1YLGgSDj4L*ys zdh$^_@3x@fM4^19-eWp*t#*2uKYl55t$sR{KmBkaJ;Xb7-@ip`hO^i3HBK`}%{FsZ z#XsS-u7sP=;MFC)Kp%p@0f}guX_%x^{Fsn4wu>Ev+d|g*<;`e(g5xKb z-uBTPQ$)jYZZH?6I^j)TMNUFwTs|)1;yrvuiRtBEBW!t_LxgmL>>-=%pK!sFG1s zV0uv9P4yZpvq~zZYFIN{V!;9&J)#_r2c=4RM;ooD3$|fJxDxtZtx9h?LgE4-1{kjv zymtMK_i;QgkA3$kJWivx%rehtAk)&~j;2z^_1i#;(=m>)Oq)z7#!=t8l5IxIy|(n- ztrcMqt-C+!nU;Zsn2R)TaNR!UPO-&#hz0r9u2*MVwne#!LYXD zJcZ|~$GM?HY(~=*7WdFuHlk`g$WQeL@naCiYPITv?21c%3o`Z|r#?kwabPCxom8I- zKHOv!jz8Zzu6yQac(O*_<`YWVRu~-gIkG8uAFA@81S*}C`5hs8v^B}M9Zn0bl4o4` ze?CI6q!Lm;{5sE*&2BKUihJI$bM}q16dUp8o5fzg_uHTiLkZ<_9a#29(H}sbScIhF zheXv--du)=8m|Wvf#>ue+;0VqN<~D&CQ`c&VkJx3R+2KY7LF#U5H75f0yKdRuq&r@ zoYgFSzF0i4cBt23&s_Gc(G(W%Y+vIIjXCvBMJZ)0gI4;+LSmF8-B~TP>MJa17=xWfs zHLFzbSO}((DJu^**;79j{#di7nZ=Gey6tWk+VY~(FTaG>+{mJN^ec8slgpL;pil=~ zpH4t0I{eu_Uvzxx3xRHTHuIGPB_af&($DJ8Ki_JNjBK(b!P4(t+YizY=~dV)sYqjJ zB80gJZw)aa?qXXX*NxKC@uW43ce!;Bf04|ui+-*dzf%FJiu%~0+~7H$raPv3;+nJl zF!&zpRhBQiC_rpe2XZ`rd5Sp;Yw+jp%+RL+zAb%bnoo(JG-P3SxezOlZmuTR7eK27 zkefv!jF|6!?PLUPy(KuvyV<@aKq0hP5ghs+OTTHOeJgn2R7uVlsRdPt77^~U@+cFL zI3z*xc~4_OKv1hXYHb`$_UXr<_sur?%P@x8k41&K2^9&sn3z+&{kdqAM476hC|S)# zHoq(AWN1T|sup>ZlcI6qKD1kudYh$BOw=mp5c{2NDw@(Z)Kp1lcA5^G=uRYW4&{W% zehfDc)R?@x1(L#T0o|0wH|4vR({e+ujdAtWE-*w$$5UftzV&j~y7zhe5#f{|`oe11 zq=_9n9eH~7(`OU9Nae<};)7@q45XI)E^UXg>Iyp*X*vZ{oI53?DC^MAUf=bNGpnuI zHH}=N?xc1=5$Z>ezH#p8@@~$@u-x5;8$;ylGQ-IUyJfKzrpG4T0b3hwH*Gx%j05&_`_tv}t)?8Smy*M;9ulr&y)}X1nuy$I#31U( zLv>5I zz|Cy@3p>K3-TX$(kBI$2{vsJD2e<&N&bN)O{>MKD^RFTDcktB}o=<#tjY+S&*0(YH z7?v@`G4xOYedDUKv+Ejt8NnL8P@U|sSQ4a6STIyS0{IwwQ&J%Bo5x{NlleT#yP1+1gh^Y`_2gj6zmvdhjQ8UMQ>z4-feNR!f> z$==+4WR-HJq}-zroK;s-dzY7B1Whe;Y4SN@S_3X6B_-wy{ruCkqx)4JnSeX3N+#W8 z>6d5Of=*WqH%sl3y88NJeW{1U(wVNZ;$dc5#tOSimJv|bQRY!-=6Ty-Dbiy(Pz~XL zKSMr^;J@>Y+M-|E7&(uiBzI?TG{a;%|6`p4jgKUFjbS@79^2qJmstcNJ_j2OZ&43` zyIAthnFBay6+q2$CsHY;vJh{07tEVQ5rjx+O5Aaa_ZzAz%dZzVi(%O_RLW|NN%^Y7 zMUQIJm*pg_bJt3n+Qd;fFhV0E*XGI9QO=qkqaJVZc~>p-stNH0;_K3qR>`zH02J#_ z98&tKF=lE?pHOiv2dbwIiicGu0}@m`Z_g%bx~kSW;t{o}w(Udx5ro^$3Q&f^!-tTT zhhskGAN~h%(Si}$1S`R3Pz!u6YHwe&>3DH@x@~w{5l0}lk%%O8&Yql_GEDS6Ho8rG zJ7qi`=Q*{b96rXU{9v=9@iZ;)VAOIo&XXo-d?JW?zyBU4I^wbG#%1UqTv`7(l+$7} z_aL@=?k=qatS*e@#7vbULO34Z@N4AyoA;(e!N+ut zKK_dk?cXv7+1>yLG)?8_yG!@f!7>*f7??{^k=!SK0_3mG)4d*6i<+t#tO%i#gTXQ}*7zW(LuZuw`9`3Q=~ta-5$@_uTfjvJ&SzS-KZB=0~XuSvBvQ+(Q+!Qxg8a zThSk#B#LbsmL=X}aW<`Xw(BdjHGr48^kF8Blrha`cpcJAvn{?0Ly%2o>05i-0Qb%` zW;GqHWYBJqR#aQa7K1Mv5+)c)k0TDUa8E-2+b@sWX z<>&M)0&XW@#V&;7Ft4~AFIX(cnHB4h^?XB&rI)aNdAym+;*ZXvG#UtxrZi5QIF7b{ z`G)vDH8z~UTs2;m!)VVb6=37Db1VW9EN#R9hT32n{WS#r-NOCFc4>2S|1E&FE z`Q0Z-MUj53rq4))D=D*6p-ppyc$ihWy1cF23rAcl?4txZyGPknI7502ecRgA2JiA$R zNscqeWwnbA{5cbgxJ<|9R)v`sb4d zYd|F8J+!o2?dXd$**hNBQzwVY`xVDBxeLtEdwn3GrRD4u`gpQSghRUu=XVa4YsIAY zlJBp)#N$l~qIk>=^B^)J4zHwDpJi(YkS5C%=`HJO1qWIodO;T|zW|3BM>408>ynZ@_KPc2}2epcbO zUam&66`suM#o=3y!Nhujo$JNea;(%qACAgwZq8|5V>VXpy5>EPd5PVA?w-o-m>;(- zuqYiJG4j4`2J`xGw!}K!^r3hSeeaf1X8gvlFT3!uR!k0~Cs1aG=rAoWs*ar0>>gl)z(-Ky~uxC;ivmf!lt3 z+l5T(h^@~}Xlrr!8!vYPm5koM!Z?i24HB)#mYiKm3}I|qFT|HAMmk3-S`R~sHtl*= z94Gi}P8NV5!AmR#UoH%!oKFAb50Mkww_Dq`vk$9IIba+W_6}ErLSLl6#py!9#4{QW>@I3%=+tDM=?VHPou^eMq3NIjduy1&Vizn_a~~4#jyHTYr{s za^h&ogCG;(g^^)i1xFR#>>=q-{$tINNhfU+AhRFr@uSuzt)7*Y=6=Tj2$ez`k(v4$ zXk84(me^1C2V@0IN6jJGiQ>0(f8aW%Pb7t4H=Z^}K3UH#k3QX3;dqSr~Zu-i`E|_Bm3xP5`;pTjI%V7S+&!8RYX~ zW%Ou7(9x=KvA`9Y|3A;K{P&(4jB}e?cu#Zgd^Eo}MJeN*(571s3kIWEAAICb(gq2? zXF1_lMYPapv&x;Cmjr@w3n)~rbjS;mm(bDXvccL>z)WH0M-0v90%=A{ouzSpymk>s zrCFQ_k36Tc(+y< z4_SVl`zy)0Lc2p~H)x2GX=D|$R<;%V)q5B%R`8snE3%Oni$QCr3LHYskk1^G#x5)% zD*J^(fQPBRsEsxn{(^Q;!ZJ)-Ok;^l)ju(AkJSPJx4P!43^9C3L0v0q3-~994v*UsWhxS(LH& z^Na3Pn$p+c567BKol`>}@>)X>1t0hsAn)QCeHUL>^`8Uj)W3iZSmJKDzHSolv}>eV z=8qn^9c%_!6&Vt>KkqVr7Wf+GT|ep|28N|S^$M|1{kpmSy6n510JnnHNKE9m0Xo|< zREC$dr}$iIjKl2|tj(-pC?z%+XxI-1($zI=_XY&h6WgCDpGrC&BZSXK7ELPIRJy*}dGXxdHlOKa5Je12FAA$Qvfq0+^jzFw+3%3YcF z*b(^o_EcN#mnEjgYj9{*Vm`c4Y*4iM4H1qruDZ0O?8^MjEW=&A%cp`*y$(F149J8aWCIwiw*EQFanYaS{U$21olPghd|H zk;n2kF{)eqb6sr3Q8L6RlrJb(=rtZ&*pk$?974j?*-Jn4;^no)rp3$Ulf#=tDq2(Z zqfpg!o;QhKe`qte-?g1R#mE+Coi&oMXp+e2?w)CtK^u6`e=B#BXx)xHkzEvVCX09f@VCuB~+&qc)i*TA!0q-V5>X zU7rPlHn#jenC)8%L*ao8W&uQBsx$A?^(_q`Z5|7G_=UttdRUq5(}7a0GM^Yxsdj^2 zsuUk}L${uGh2S{W3c=(2OzFInhzmu_oyd(zrkEPx;{SYevdKu$2r8=_-m6uf zJYN6knG#3E<3sb@eYEv4t`GZC@0^Z7?La)`ANuS+?pB%y%r&FtAdR8=23%{8j-Hak zm4_6i^!RaCG|Spmhd^vBrF~e0;3yj2RW;?##Dqt2Gv&c_5v?}A&tDo==k@8Hk0iCI zK4@0*hpteIo-Cia{+G!})*r)$W%O;N%n$d*#?)^*U1#GX9(VL>n&Y6J%LciUvnfoG};#%mE7ES_ha)R!_5D;UW zE0MCN?XTB~nm5jQ|8WaDNG2JLNm;$GhTcg~OR6J1T1~8doD^|o2~6?6jk*y@REW}i zB5qz7gN2c{U81GzWVUQpq5K6WKo_3oz7FqwGV>PBgfX1tdMfI?XL3?gaY76#^ow;f zC;OjuJ!Hs1s_$uRElE(ZJ8S!Vcq@lswyG$fI{}70aDestYj8x;3TN ztX~7M`<4)5Fwc73FWvw&Xd_%SYkla8EfTC#n@+OkRC(ZM)2v`&Ti5rzL?e_DNpQNts9%*?V*CV5W%Pdqc=##jYXCiMZGysd8wZ@^DA<( zfiQ@3fJ5O{Q?L+zaQ|{V*05GNustDRs};`jr?@tethd20{WoxJDR?F$mPM&I^-?om zZ8u9T+h16vHA=HSGNzH4w04@M??i{bpR10FzX)aFn}J`Qs^c@h!=}8+exFF`gDINz zl^Cm4Ya{p6LM$)?6VCLmL9m|l&#^yw2XjrVIhi%S{Em)}?wNfy9*}URx%9jI7~s#4 z#eOJ)r4>8t|A8et_KE?luOHG4cUJ77R5p2umR1UB*m$=&!JZ{2RK~l_Y2IV2@eby^ z`0DWRGmpb{p@6&)jz^?P+&|cDmI}o4DvCFss*Lo90w}Fh4(BD62DS~g&OSc} zD&kCQYp&`}NXm7*C4opfpN`3%ic$p4748jQirIOQsufD(v8BiJT6S{O2a3I3XYq&( zltHEw8hMxRo&YJyzt;6kZ@Jw_!iDCq+Y1E(>iYrWuqv+WNz{QdPis#t5D*$$+>wg4 zx;tH+o18TG{{6d+bbrcZLYUE@>2X{SUR05r@nhv7|C)!|>3#B=FV9VgpCLtM>kf>n zJfE|&w>++#zvT(nmxUT?USy51sjxw)FekYbUa1MyJnO|~_NE{Y;BzE@mh3es*vtO6 zC_8rcSq{T{yWh}BY+!%?J3x?I4vujE=H6V4@ZMe={V~fdPvwVcm|}OH%3`8>`ru7( z%42aAsGmSja{dY)#OX=dN)c;W@Frwo-0YOaW&A8-5VEs!Ve0+r$|+Ng-afHAP`U@zOV2UU4%jn_?aW| z;ONXN{}&%WkcW7jt&UoONWrA#WKfPlcuY#TQZ_Ra*|&Unfb(T!_ubTjIxM9dLBp3x zgZ)1AhJJ?)mf(=iX=|oPV(l&1&aM8qnWKa5v+ij=9s(q;7(X~-LFs5f42%CEE8yTj zJjT;Ioq%BlTA3#MGAR2|_ZeFZ>c(#!$|W}99Sd_`9?|c5o?%$hFc3^EM_kfz?Z&kq zjcpB^VWq~T(w0BQlrX6H={YyA2eRxAO;?4-dxZ#L(xUGFG0l%S{-Bib?fOEIgoEZH z@6pwQiLNqzu%SyO+@)`4#EP8Qs};HhDalHsx^2&w2_3H^wCu6r`_>A42}}M;%g)X z{z!`g={1w6p_P;B2t7CmqGQdUOLHk}4L>{p@Ewpq?oRek?{_vb3QdGir?vHsT zAlv_63&Jl*V$qNtuS#6IkOzkWF73P&SLAZSKED9mS!@7Evt}>2R>1 zwN``=lME_R*P{r~F_`%c-Y$3eb_9RX8#}S4@Z~Ktv-whrN`+tClX|1S82>C^&uh~F z7c4dWD*%*?$$XNeW%Jw7N%hG4S&qS=G(kXzdzA~!q!We|PE}VjqjWZ-JbFMEnoA}? zi(121O?_Ve&<35ur2BY9EbOHDh~Gxw>0!e9r5W)R3BgqY1Fg|g%%0$1Gd+0*6KA$< zuMRmk&yZ6I_mV|D5P@sduTy)YQkJ-k9w}f0(1lljlcyK^zo%Nw6ryrRwp+$) zScDmv=1|j(&YRPi&NMPmM_Le>#I=ee~XK?srDxQK=6*S1CFLSh^Rn!xge!A;{1co=Nw1y)^$MUm< zU_3Z)Dl6)3o=s;V?zk>ac4Me*;mAPJgFIZ1FsE}k7pFUp6SU=)NQqh&GU!z=d<&`c zgJ$9Rp`>SLF^#-i<3qsZFf*iOq@2?b^3;0~yD}s1@F98_0Z*kM$3;^vh8^V2eceBFbNkiTV>ula_&X4KyW}+2HI|mc;u5Ry3~;nE55ky#gm8fk-6;|2 z*FuW3<89WJi~lG=OY>bAc9+?9O^4^L^-2fU_wDJ;oU!9MG)||)YZ3GIep~*)tA=oD zq%dyw5PEibrRmBFpi|eIO%QpWwPDtqjHs2Al+}=mKrQp?F8@%+$w^Yk0_cC$yCdr^ zAH>5j(Dyvnyh4UNPgfjOObZ!&J)6q(J4k5^VKtQ%t!qrjssZ(`#qsnOtCw##b;5n0_ z)lj?vu)NjP-qnJ_XHWLrA`w+SI=DFy$*UY}UP=k!nZZfC7SuH@r;lk2+RZa&BeOBK+xZ`F<=?I+Zr4IF$=H7j zeBXZw?$@E2%XtqSdiNuUsJi0qSLV{7B6*o^z~)594a1Wl+_mlNo^5&(Q;)bsW`|Xg z_rr+33*^c>bpC*?MYGI0;@1NSxd8sF3EVUV;e&s|B))v{efGb58V3l-Vegv>k_?T>1JNv3=om1uQD14PD50L zH|c(~e;PB2T*T&ob02D+Byq@mTG_WcTWh=hO8nfZ=lO!Dsf$O*+MsYv75xH}`DXsF zDVzrNk4&SVnVnnW9OGvmsQ@&$Lxi!YVT8=5!dqHfht0C?cg@C9EC3xp9@H?`mnZg6 zN5LlaiO$jV$0Toom+NUVP{iN*sh@9KX3SPK=q0JQ@7QX*uYXFIgd|z2>pADOAgy^= zHtCRNRz`XsvqZ+aUG!m7E_13IHmo?*oCs8|^S@q1F7u9clTtCZUB?Vi3A*v$8|1xQ zCU#U;_}tL((-qjz6CirTvy@{EMy77X16oOSHq4*q!VNn%zQC^tgJ~Lm?$b`2nAA4IN>v1 z@pk#hD&XTGEhwiDc2Mpj3^m@zgsp~0PH9yehm3q7BWC{`#~8SS^@&!$(cp(K5vN@z zAoKdk^XbG@nc(Y=Xw{Er{=24$X4n@X+omSH#ic1G7vNftF^tIxbBv5e$P>BtpRTr8 z*PCtwLOQM^a5!wmCxCpb`e{5v!~j^696+VtWA&tmiL+J=?_+9W#bVSeRizLS&B|~f zXs}u>jz`h9PO(o4+MmyjeT(GJ0@v%`a#bSmqowU%O z^Y7Y!%ZcPq*|lrc^Sa0z^(Ub{--+!QGw*zU7QPYvnM~^%&`wT8#&62E!@;!sW$o(f zDqZ09lkIiS4Q^2*>0_Wd{Y?^!^uhWLQZ-Ftj9I{}3MdQAbuVDxuSa zrlxUBb-kZlFTX5GKjMXd4)9RrMr#$Y-!k5J!_f$bbw}8tFfa(gO5=S{#5)1Bmz~># z(*k4+h#4`3{4eLk0D7$>&nU& z8ODN&iRK!I+jT4^CqOM1qjdj%#fhP9n=T^}Vh;BcA`NaB7583_6NN&n-QnC|KP4$b zh-#rKJof3=eKsp<U8>@21L9H=h>Di0yVJR=E#|=c$dKOIiR{D*b6iqK^(eA`V_36k$;a%4a^{y zOTv)ICFmzzKTND#xW75n(epHCpSv2=Mr)Ml0}x|$eZ3s+vii!z-5+rP#bU7XAqZ5D z+aoSCTNY;^qrKPM5FmZX1&EMawh}yv!ktRAgigjn?(H{l+ zW?Zx_&SbVnm$}W(QItR~jW-%LEp4DR<_x4@rN+>OV?k}`2d6MhV8p?9xh6carA0j0 z7%meOmVDAkj!y`zY1HML^t#Tovqjj-yw`+Mp>g{Hm(>Uf!3spAK9}Y08@WHXfW*Hee{!d1@>mtZ}aU*!lr8swYMr#?Z5Cjl0FhkaEH?#8&kWi$~Xs_mB z97*ksd<0?0uUA5%l&`blamn*$HW|^6skX0tM-PpI#RPWAHbYikTA&tU#Bo&0UW^Svwdbb-?pnr4JF!!?-hn)v^ zoc9E<{%q;wd$OZpZMfZbsQzxGdpA-$JtzV< zGs>TK7J5i;iGq(R+HM=hGs$72(%&s&`|W06a1u4aKHauOO_S}&M6Gt1O~f`)Tqc|O zWXi#IJ;;-J2pf`cVMPkO9<9{En)z8^hBG@p{UQh`ozebYUcyH<`PCfci3{aZ=Pv^b zY8f7Eti9>M7=++$NBXpao=v}>NU$;V5+;3++$W1QR2p9C+ zp66h?ywH7mh#o>y^Un*bsaa+v>nyx0{VL=OaGPW4;BDG&5|S7@I3$kOy&P%LQGgJK zO>n>*Zl6A}<0S>Tp+RDg29e&gSwy9jDMjhTD}3qpfI(Hs;O*aR0m=W6uFUVWEh31>3H1z`hTM81~n4j#+ipo^(XyJZ~F^{W_ ziGZFuU1bm$6zbO&6j(1=N+wESimx7v*#5b_vCQ6;1mxb=q_{D?62_mLyIvEKMSZ#{ zn^w;2CChuGKTW`1!V7ZXvv~RLk28(FeK#M}E}~O}3}=C?7kA2W$dD^wey0SwjNk4m z!cBQ_Z6nGTmHDx1GDruSBjnL_@WN+MuY7E5m0&1od1{D+5h5rk7%T$T`wRay^aHsl zt(z}5L8i#2!p7w2_}YS|ju%xq;TS<6kD)%|ZH|V98_%6pBS%7N^&7Yxj`!vG*5>iq za2t-?9|Q%7{{?zdX^o%HCrMmyw0t=_oC3E@nLX2_fr@-IYUN?( zS-+nZT(FxVnP(Kp7Lhh_Dirg0Jed7x?8hr03Lg1MmNna(j**f&h-k@ma%hkWRC z$ZmZPBvO%=vd~f3LfhfjBLqArj_FOFhoipsgs{~o=0sm5xONJ>MYr(w7gVcbilk#k z9Y~o>VCNkf&lcbOqkQxtT{PVNA~-2_4Wx6Xu*S%LoL3)`>Q;)_j5~Ne%qITA_(AT0 z8vl#W{OcQy*^0!|P)B)Y^T^zU81m^rvLC8*jIW^YB<)=fwvAZlsUutQDie)_apBvf z<^k1~+3&5sO~tfm5v(O}tYF=6iO>&`L$LSm)rh0`>y<@eH%L5_D_*q|PO*cIZA2lb zfW3akvEDYJ3A2+$<&{Ug`2ZJ0_d-xhj5rsf)yx;r9^AoO&v4p1j0dLBPrAwl$(V=5 z5v3pa6C8HE4spQp;_L~(O7h?=BqvL|qy?|IaM@(Pj1FXdX^cz*agtnBEVYT5C9&wW zv?(&v5xI3@vCH2gzPSihj@vQ~QgKk*r9EX!l=%7MH8`M>R${m-s5!$4?$W4MI5U8k zlcF`!^vZGCN9>2nZV51-ps=OjV2Nr17pugNVegf3!up#L&0BGZ^rjmTsK0h?93^%D<515M+~ z^(z7g?G84xFN>nHa+DG<0S?|D3vTzL%$(a+j{i0G*k1+t@yUE$dL~O-a`5)@6@Rg3 zr5)gr?VJ)m+-hbDN%t{?f17Zh=?4^>#JK# z+c#$JFp-S2Il)DxV$0^1`77OZK5un=k?{w|IYpa8)I!>2>kjQ)?Ly1_uZq4eidG6l zowT^`DLgHlR4R0Ts@vnL@cj%|@`@k*3g;;Yh6Zdxaq+B_Z`2)~!P75mUW zPO}w6xRIr6QSMR9Cl$mZvpjfb9sK1JA5)!u^Ga_#%ggdvaB&Cncy1EfyGXZMX5Ems zt!A1jfBOd{t}loPb$z%rQ8paN6eWyjADYAx{lj4fT|bHz72viP#_eeuBVuOx*< z2)>9^h{QQz!6S%6;p|o`EvAI9!;s`K36$7nI|!}MJyKN$+*v073oiI%0YMufs?7JC zeMW1ruAksjA{mj_Go4Tjk-ntUI~JM4tEh|p9?m2G&ik~$$q2+RZd#=BOjg|kM?#h+ z3w98}q#Gnw>+O73n8juiR-Eh@8o&T_exsNy{e}~CZACcBieKKe*HV>!{;>&LP)=&+ z8sf*!U;+%Cf=SbrDS?v4z6aU)8|Qc4msqo}^o+hZEpk3$DZf0FK5G76`LYcDRow7a zcOzF|=P>owE$@jZd$)rx&{%5PkQmX8IWQeQcNm$`-7=&L6IDC!40=9H(#M$D^L|?- zIJ5%gCI83&v?OKXHzP#=lLylXDT2umzE|XhDHcV#k7m@y-d~s`eyO`&{KoiHhUmqa zE}HED4kwPy@jNdC2^^(}f(BmWV8a8*p17h3lEN&c*(gZDj=7NL{12wXum&6RXj0Na zeiiU?re5kP&-0*G1H*b}R#4psbgL?duB= zq1apjSiI0V7u+%r_x!l3)J$H#=Z{Gb?7<2~+)CBU)$99>z)sP++O_Zd7y~L^ExFD! z!I2J-Vn$$CQaZ)L=+hXFM`7$|YkPBx7G9g*8Hj$;b&c!>Dp!^?K>x~N3d1(1ywFli z@y{Tj;eh&o#v@w|yy*8JM2yI4C>8!^sZub3f*GxL*@HbUPfFXD#ZaW>P(o>c2ifRA(^k0ev`5;q32W z05(JZcfaxO+MB{hpyYoxhpeF4r(VIbf42$+lQo1BR(_epti+%87y`ACLw$Ds{aauP zEXXTE21GK?Nad&VEB}^3QON<8It-p`hxe}o{kvpA0ic7z0zWdtGXL2$z_UWw9r9ux zX4K7p&!*ocLZLEs*`r`i{AnD;B+8_wxx&5pzm=2t3jwWxQoKm}I~IsQU!ekR|8diS z5^+R<#{d7kZZM5tW_Ed&b(9hNyL-S0XYE3S)7L)3XFdMuF+A#Eft*qyufn152RGfn z4RiVZblfTJo4I;JN!h=z8n}VUi6Bf2$NYIy4GrjwNkd8PfB)u&@2RQ&GqG9mK;N_$ zPiPtbtcVO?V$MtHY5x7woD%4&noEnc%)j&WjS5%=ymT_s|Nf~ZC1~~|ezkn+PhFe9 zEdM{NPF1V6x^cZ<{#$;Opf(IJ9rl=NYEC#yQ@;-X)T;pxT0lA{JW~CqU@Wx4e9~~* z+R7tU%|AC?fXCdhyeOwmqt(drymSJKjvK8LjU%I9OwrD hmCT+j$gn7w8(mrtlK4 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ℑ)wy3rM#Q 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

    kBUs6TEl5en~Ixfl4Qy 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 cb Date: Mon, 16 Dec 2024 11:04:09 +0900 Subject: [PATCH 707/865] chore(deps): bump nanoid from 3.3.7 to 3.3.8 in /docs (#1223) Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8) --- updated-dependencies: - dependency-name: nanoid dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 3f7b89df8..f95bdc6c8 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -11632,9 +11632,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", From 5f4c847025b568e77e35169c4a4285f7f2de6616 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 16 Dec 2024 15:25:57 +0000 Subject: [PATCH 708/865] chore: support python 3.13 (#1221) * chore: support python 3.13 * Update .github/workflows/tests.yml Co-authored-by: Kazuhiro Sera --------- Co-authored-by: Kazuhiro Sera --- .github/workflows/codecov.yml | 2 +- .github/workflows/mypy.yml | 2 +- .github/workflows/tests.yml | 22 ++++++++++------------ pyproject.toml | 1 + 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index a065a54fa..79fd440b2 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -11,7 +11,7 @@ jobs: timeout-minutes: 10 strategy: matrix: - python-version: ["3.11"] + python-version: ["3.13"] env: BOLT_PYTHON_CODECOV_RUNNING: "1" steps: diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index d2d048bb7..a592bd8cd 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -11,7 +11,7 @@ jobs: timeout-minutes: 20 strategy: matrix: - python-version: ["3.12"] + python-version: ["3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d3bc7b4a1..c0b58b9d7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,15 @@ jobs: timeout-minutes: 10 strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: + - "3.6" + - "3.7" + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} @@ -44,17 +52,9 @@ jobs: - name: Run tests for HTTP Mode adapters (Django) run: | pytest tests/adapter_tests/django/ - - name: Run tests for HTTP Mode adapters (Falcon 3.x) + - name: Run tests for HTTP Mode adapters (Falcon) run: | pytest tests/adapter_tests/falcon/ - - name: Run tests for HTTP Mode adapters (Falcon 2.x) - run: | - # Falcon 2.x does not support Python 3.11 or newer - # See also: https://github.com/slackapi/bolt-python/issues/757 - if [ ${{ matrix.python-version }} != "3.11" ]; then - pip install "falcon<3" - pytest tests/adapter_tests/falcon/ - fi - name: Run tests for HTTP Mode adapters (Flask) run: | pytest tests/adapter_tests/flask/ @@ -76,8 +76,6 @@ jobs: pytest tests/adapter_tests/socket_mode/ - name: Run tests for HTTP Mode adapters (asyncio-based libraries) run: | - # Falcon supports Python 3.11 since its v3.1.1 - pip install "falcon>=3.1.1,<4" pytest tests/adapter_tests_async/ - name: Run tests for HTTP Mode adapters (ASGI) run: | diff --git a/pyproject.toml b/pyproject.toml index c5034f0c1..ef5440fed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", From 5e41ae816ccafab21e527f1e189344168c97c752 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 16 Dec 2024 19:59:02 -0800 Subject: [PATCH 709/865] docs(examples): update aws lambda tooling and iam role names (#1224) --- examples/aws_lambda/aws_lambda.py | 2 +- examples/aws_lambda/aws_lambda_config.yaml | 9 ++++----- examples/aws_lambda/aws_lambda_oauth.py | 2 +- .../aws_lambda/aws_lambda_oauth_config.yaml | 17 +++++++---------- examples/aws_lambda/deploy.sh | 4 ++-- examples/aws_lambda/deploy_lazy.sh | 2 +- examples/aws_lambda/deploy_oauth.sh | 2 +- examples/aws_lambda/lazy_aws_lambda.py | 4 ++-- examples/aws_lambda/lazy_aws_lambda_config.yaml | 11 ++++------- 9 files changed, 23 insertions(+), 30 deletions(-) diff --git a/examples/aws_lambda/aws_lambda.py b/examples/aws_lambda/aws_lambda.py index 8ca79a82c..171de6e2e 100644 --- a/examples/aws_lambda/aws_lambda.py +++ b/examples/aws_lambda/aws_lambda.py @@ -31,5 +31,5 @@ def handler(event, context): # export SLACK_BOT_TOKEN=xoxb-*** # rm -rf vendor && cp -pr ../../src/* vendor/ -# pip install python-lambda +# pip install git+https://github.com/nficano/python-lambda # lambda deploy --config-file aws_lambda_config.yaml --requirements requirements.txt diff --git a/examples/aws_lambda/aws_lambda_config.yaml b/examples/aws_lambda/aws_lambda_config.yaml index 5f158892e..5c7c7a6de 100644 --- a/examples/aws_lambda/aws_lambda_config.yaml +++ b/examples/aws_lambda/aws_lambda_config.yaml @@ -4,7 +4,7 @@ function_name: bolt_py_function handler: aws_lambda.handler description: My first lambda function runtime: python3.8 -# role: lambda_basic_execution +role: bolt_python_lambda_invocation # S3 upload requires appropriate role with s3:PutObject permission # (ex. basic_s3_upload), a destination bucket, and the key prefix @@ -20,12 +20,11 @@ aws_secret_access_key: # timeout: 15 # memory_size: 512 # concurrency: 500 -# -# Experimental Environment variables +# Lambda environment variables environment_variables: - SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN} - SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} + SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN} + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} # If `tags` is uncommented then tags will be set at creation or update # time. During an update all other tags will be removed except the tags diff --git a/examples/aws_lambda/aws_lambda_oauth.py b/examples/aws_lambda/aws_lambda_oauth.py index 06b2572a6..f8fd175d5 100644 --- a/examples/aws_lambda/aws_lambda_oauth.py +++ b/examples/aws_lambda/aws_lambda_oauth.py @@ -46,5 +46,5 @@ def handler(event, context): # - AWSLambdaRole # rm -rf latest_slack_bolt && cp -pr ../../src latest_slack_bolt -# pip install python-lambda +# pip install git+https://github.com/nficano/python-lambda # lambda deploy --config-file aws_lambda_oauth_config.yaml --requirements requirements_oauth.txt diff --git a/examples/aws_lambda/aws_lambda_oauth_config.yaml b/examples/aws_lambda/aws_lambda_oauth_config.yaml index b491a54e2..e5e837566 100644 --- a/examples/aws_lambda/aws_lambda_oauth_config.yaml +++ b/examples/aws_lambda/aws_lambda_oauth_config.yaml @@ -4,7 +4,6 @@ function_name: bolt_py_oauth_function handler: aws_lambda_oauth.handler description: My first lambda function runtime: python3.8 -# role: lambda_basic_execution role: bolt_python_s3_storage # S3 upload requires appropriate role with s3:PutObject permission @@ -21,17 +20,15 @@ aws_secret_access_key: # timeout: 15 # memory_size: 512 # concurrency: 500 -# -# Experimental Environment variables +# Lambda environment variables environment_variables: - SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} - SLACK_CLIENT_ID: ${SLACK_CLIENT_ID} - SLACK_CLIENT_SECRET: ${SLACK_CLIENT_SECRET} - SLACK_SCOPES: ${SLACK_SCOPES} - SLACK_INSTALLATION_S3_BUCKET_NAME: ${SLACK_INSTALLATION_S3_BUCKET_NAME} - SLACK_STATE_S3_BUCKET_NAME: ${SLACK_STATE_S3_BUCKET_NAME} - + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} + SLACK_CLIENT_ID: ${SLACK_CLIENT_ID} + SLACK_CLIENT_SECRET: ${SLACK_CLIENT_SECRET} + SLACK_SCOPES: ${SLACK_SCOPES} + SLACK_INSTALLATION_S3_BUCKET_NAME: ${SLACK_INSTALLATION_S3_BUCKET_NAME} + SLACK_STATE_S3_BUCKET_NAME: ${SLACK_STATE_S3_BUCKET_NAME} # If `tags` is uncommented then tags will be set at creation or update # time. During an update all other tags will be removed except the tags diff --git a/examples/aws_lambda/deploy.sh b/examples/aws_lambda/deploy.sh index 0a8f13197..54ca3abdc 100755 --- a/examples/aws_lambda/deploy.sh +++ b/examples/aws_lambda/deploy.sh @@ -1,6 +1,6 @@ #!/bin/bash rm -rf vendor && mkdir -p vendor/slack_bolt && cp -pr ../../slack_bolt/* vendor/slack_bolt/ -pip install python-lambda -U +pip install git+https://github.com/nficano/python-lambda lambda deploy \ --config-file aws_lambda_config.yaml \ - --requirements requirements.txt \ No newline at end of file + --requirements requirements.txt diff --git a/examples/aws_lambda/deploy_lazy.sh b/examples/aws_lambda/deploy_lazy.sh index 61a1e3ab4..a76c8165f 100755 --- a/examples/aws_lambda/deploy_lazy.sh +++ b/examples/aws_lambda/deploy_lazy.sh @@ -1,6 +1,6 @@ #!/bin/bash rm -rf slack_bolt && mkdir slack_bolt && cp -pr ../../slack_bolt/* slack_bolt/ -pip install python-lambda -U +pip install git+https://github.com/nficano/python-lambda lambda deploy \ --config-file lazy_aws_lambda_config.yaml \ --requirements requirements.txt diff --git a/examples/aws_lambda/deploy_oauth.sh b/examples/aws_lambda/deploy_oauth.sh index d8f05c501..266aae0f8 100755 --- a/examples/aws_lambda/deploy_oauth.sh +++ b/examples/aws_lambda/deploy_oauth.sh @@ -1,6 +1,6 @@ #!/bin/bash rm -rf slack_bolt && mkdir slack_bolt && cp -pr ../../slack_bolt/* slack_bolt/ -pip install python-lambda -U +pip install git+https://github.com/nficano/python-lambda lambda deploy \ --config-file aws_lambda_oauth_config.yaml \ --requirements requirements_oauth.txt diff --git a/examples/aws_lambda/lazy_aws_lambda.py b/examples/aws_lambda/lazy_aws_lambda.py index dcc47498f..185e33347 100644 --- a/examples/aws_lambda/lazy_aws_lambda.py +++ b/examples/aws_lambda/lazy_aws_lambda.py @@ -46,5 +46,5 @@ def handler(event, context): # export SLACK_BOT_TOKEN=xoxb-*** # rm -rf vendor && cp -pr ../../src/* vendor/ -# pip install python-lambda -# lambda deploy --config-file aws_lambda_config.yaml --requirements requirements.txt +# pip install git+https://github.com/nficano/python-lambda +# lambda deploy --config-file lazy_aws_lambda_config.yaml --requirements requirements.txt diff --git a/examples/aws_lambda/lazy_aws_lambda_config.yaml b/examples/aws_lambda/lazy_aws_lambda_config.yaml index f992f8684..a1ee748d3 100644 --- a/examples/aws_lambda/lazy_aws_lambda_config.yaml +++ b/examples/aws_lambda/lazy_aws_lambda_config.yaml @@ -4,9 +4,7 @@ function_name: bolt_py_function handler: lazy_aws_lambda.handler description: My first lambda function runtime: python3.8 -# role: lambda_basic_execution -# Have lambda:InvokeFunction & lambda:GetFunction in the allowed actions -role: bolt_python_lambda_invocation +role: bolt_python_lambda_invocation # S3 upload requires appropriate role with s3:PutObject permission # (ex. basic_s3_upload), a destination bucket, and the key prefix @@ -22,12 +20,11 @@ aws_secret_access_key: # timeout: 15 # memory_size: 512 # concurrency: 500 -# -# Experimental Environment variables +# Lambda environment variables environment_variables: - SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN} - SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} + SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN} + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} # If `tags` is uncommented then tags will be set at creation or update # time. During an update all other tags will be removed except the tags From f1fb1151ed812a31e9e5588a5ab52a01ca64573a Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 18 Dec 2024 18:08:21 +0000 Subject: [PATCH 710/865] chore: improve maintainers docs (#1226) * chore: improve maintainers docs * Update maintainers_guide.md * Update .github/maintainers_guide.md Co-authored-by: Eden Zimbelman * Update .github/maintainers_guide.md Co-authored-by: Eden Zimbelman --------- Co-authored-by: Eden Zimbelman --- .github/maintainers_guide.md | 48 +++++++++++++++++++------------- .github/pull_request_template.md | 12 ++++++-- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index dffd4913b..333ffa354 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -15,7 +15,7 @@ $ brew update $ brew install pyenv ``` -Install necessary Python runtimes for development/testing. You can rely on Travis CI builds for testing with various major versions. https://github.com/slackapi/bolt-python/blob/main/.travis.yml +Install necessary Python runtimes for development/testing. You can rely on GitHub Actions workflows for testing with various major versions. ```bash $ pyenv install -l | grep -v "-e[conda|stackless|pypy]" @@ -34,7 +34,7 @@ $ pyenv rehash Then, you can create a new Virtual Environment this way: -``` +```bash $ python -m venv env_3.8.5 $ source env_3.8.5/bin/activate ``` @@ -94,8 +94,9 @@ $ ngrok http 3000 --subdomain {your-domain} #### Develop Locally If you want to test the package locally you can. + 1. Build the package locally - - Run + - Run ```bash scripts/build_pypi_package.sh ``` @@ -106,8 +107,7 @@ If you want to test the package locally you can. ```bash pip install /dist/slack_bolt-1.2.3-py2.py3-none-any.whl ``` - - It is also possible to include `/dist/slack_bolt-1.2.3-py2.py3-none-any.whl` in a [requirements.txt](https://pip.pypa.io/en/stable/user_guide/#requirements-files) file - + - It is also possible to include `/dist/slack_bolt-1.2.3-py2.py3-none-any.whl` in a [requirements.txt](https://pip.pypa.io/en/stable/user_guide/#requirements-files) file ### Releasing @@ -121,19 +121,18 @@ If you want to test the package locally you can. ##### $HOME/.pypirc -``` +```toml [testpypi] username: {your username} password: {your password} ``` - #### Development Deployment 1. Create a branch in which the development release will live: - Bump the version number in adherence to [Semantic Versioning](http://semver.org/) and [Developmental Release](https://peps.python.org/pep-0440/#developmental-releases) in `slack_bolt/version.py` - Example the current version is `1.2.3` a proper development bump would be `1.3.0.dev0` - - `.dev` will indicate to pip that this is a [Development Release](https://peps.python.org/pep-0440/#developmental-releases) + - `.dev` will indicate to pip that this is a [Development Release](https://peps.python.org/pep-0440/#developmental-releases) - Note that the `dev` version can be bumped in development releases: `1.3.0.dev0` -> `1.3.0.dev1` - Commit with a message including the new version number. For example `1.3.0.dev0` & Push the commit to a branch where the development release will live (create it if it does not exist) - `git checkout -b future-release` @@ -150,26 +149,35 @@ password: {your password} 3. (Slack Internal) Communicate the release internally - #### Production Deployment 1. Create the commit for the release: - Bump the version number in adherence to [Semantic Versioning](http://semver.org/) in `slack_bolt/version.py` - Build the docs with `./scripts/generate_api_docs.sh`. - Commit with a message including the new version number. For example `1.2.3` & Push the commit to a branch and create a PR to sanity check. - - `git checkout -b v1.2.3-release` - - `git add --all` - - `git commit -m 'version 1.2.3'` - - `git push {your-fork} v1.2.3-release` - - Merge in release PR after getting an approval from at least one maintainer. - - Create a git tag for the release. For example `git tag v1.2.3`. - - Push the tag up to github with `git push origin --tags` + - `git checkout -b v1.2.3` + - `git commit -a -m 'version 1.2.3'` + - Open a PR and merge after receiving at least one approval from other maintainers. 2. Distribute the release - Use the latest stable Python runtime - - `python -m venv .venv` - - `./scripts/deploy_to_pypi_org.sh` - - Create a GitHub release - https://github.com/slackapi/bolt-python/releases + - `python --version` + - `python -m venv .venv` + - `./scripts/deploy_to_pypi_org.sh` + - Create a new GitHub Release from the [Releases page](https://github.com/slackapi/bolt-python/releases) by clicking the "Draft a new release" button. + - Enter the new version number updated from the commit (e.g. `v1.2.3`) into the "Choose a tag" input. + - Ensure the tag `Target` branch is `main` (e.g `Target:main`). + - Click the "Create a new tag: x.x.x on publish" button. This won't create your tag immediately. + - Name the release after the version number updated from the commit (e.g. `version 1.2.3`) + - Auto-generate the release notes by clicking the "Auto-generate release + notes" button. This will pull in changes that will be included in your + release. + - Edit the resulting notes to ensure they have decent messaging that are + understandable by non-contributors, but each commit should still have it's + own line. + - Ensure that this version adheres to [semantic versioning](http://semver.org/). See + [Versioning](#versioning-and-tags) for correct version format. Version tags + should match the following pattern: `v2.5.0`. ```markdown ## New Features @@ -232,7 +240,7 @@ reopening is great and better than creating a duplicate issue. ## Managing Documentation -See the [`/docs/README.md`](../docs/README.md) file for documentation instructions. +See the [`/docs/README.md`](../docs/README.md) file for documentation instructions. ## Everything else diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6ae896f2b..4dcfd152a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,12 @@ -(Describe the goal of this PR. Mention any related Issue numbers) +## Summary -### Category (place an `x` in each of the `[ ]`) + + +### Testing + + + +### Category * [ ] `slack_bolt.App` and/or its core components * [ ] `slack_bolt.async_app.AsyncApp` and/or its core components @@ -8,7 +14,7 @@ * [ ] Document pages under `/docs` * [ ] Others -## Requirements (place an `x` in each `[ ]`) +## Requirements Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. From 9390d0aee21ae99ca9c807108c0dec89e850974e Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 18 Dec 2024 18:13:49 +0000 Subject: [PATCH 711/865] version 1.22.0 (#1227) --- .../slack_bolt/adapter/aiohttp/index.html | 58 +- .../adapter/asgi/aiohttp/index.html | 69 +- .../adapter/asgi/async_handler.html | 69 +- .../slack_bolt/adapter/asgi/base_handler.html | 47 +- .../adapter/asgi/builtin/index.html | 59 +- .../slack_bolt/adapter/asgi/http_request.html | 127 +- .../adapter/asgi/http_response.html | 128 +- .../slack_bolt/adapter/asgi/index.html | 59 +- .../slack_bolt/adapter/asgi/utils.html | 17 +- .../adapter/aws_lambda/chalice_handler.html | 95 +- .../chalice_lazy_listener_runner.html | 20 +- .../adapter/aws_lambda/handler.html | 111 +- .../slack_bolt/adapter/aws_lambda/index.html | 65 +- .../adapter/aws_lambda/internals.html | 17 +- .../aws_lambda/lambda_s3_oauth_flow.html | 55 +- .../aws_lambda/lazy_listener_runner.html | 20 +- .../aws_lambda/local_lambda_client.html | 36 +- .../slack_bolt/adapter/bottle/handler.html | 67 +- .../slack_bolt/adapter/bottle/index.html | 43 +- .../slack_bolt/adapter/cherrypy/handler.html | 100 +- .../slack_bolt/adapter/cherrypy/index.html | 45 +- .../slack_bolt/adapter/django/handler.html | 108 +- .../slack_bolt/adapter/django/index.html | 39 +- .../adapter/falcon/async_resource.html | 58 +- .../slack_bolt/adapter/falcon/index.html | 56 +- .../slack_bolt/adapter/falcon/resource.html | 56 +- .../adapter/fastapi/async_handler.html | 47 +- .../slack_bolt/adapter/fastapi/index.html | 43 +- .../slack_bolt/adapter/flask/handler.html | 64 +- .../slack_bolt/adapter/flask/index.html | 39 +- .../google_cloud_functions/handler.html | 41 +- .../adapter/google_cloud_functions/index.html | 38 +- .../api-docs/slack_bolt/adapter/index.html | 17 +- .../slack_bolt/adapter/pyramid/handler.html | 77 +- .../slack_bolt/adapter/pyramid/index.html | 42 +- .../adapter/sanic/async_handler.html | 91 +- .../slack_bolt/adapter/sanic/index.html | 43 +- .../adapter/socket_mode/aiohttp/index.html | 79 +- .../socket_mode/async_base_handler.html | 76 +- .../adapter/socket_mode/async_handler.html | 33 +- .../adapter/socket_mode/async_internals.html | 56 +- .../adapter/socket_mode/base_handler.html | 82 +- .../adapter/socket_mode/builtin/index.html | 91 +- .../slack_bolt/adapter/socket_mode/index.html | 91 +- .../adapter/socket_mode/internals.html | 54 +- .../socket_mode/websocket_client/index.html | 83 +- .../adapter/socket_mode/websockets/index.html | 81 +- .../adapter/starlette/async_handler.html | 90 +- .../slack_bolt/adapter/starlette/handler.html | 86 +- .../slack_bolt/adapter/starlette/index.html | 43 +- .../adapter/tornado/async_handler.html | 92 +- .../slack_bolt/adapter/tornado/handler.html | 116 +- .../slack_bolt/adapter/tornado/index.html | 81 +- .../slack_bolt/adapter/wsgi/handler.html | 90 +- .../slack_bolt/adapter/wsgi/http_request.html | 233 +- .../adapter/wsgi/http_response.html | 76 +- .../slack_bolt/adapter/wsgi/index.html | 90 +- .../slack_bolt/adapter/wsgi/internals.html | 17 +- docs/static/api-docs/slack_bolt/app/app.html | 1204 ++++++++-- .../api-docs/slack_bolt/app/async_app.html | 1183 +++++++++- .../api-docs/slack_bolt/app/async_server.html | 99 +- .../static/api-docs/slack_bolt/app/index.html | 1148 +++++++++- .../static/api-docs/slack_bolt/async_app.html | 1872 +++++++++++++--- .../authorization/async_authorize.html | 43 +- .../authorization/async_authorize_args.html | 53 +- .../slack_bolt/authorization/authorize.html | 43 +- .../authorization/authorize_args.html | 53 +- .../authorization/authorize_result.html | 97 +- .../slack_bolt/authorization/index.html | 97 +- .../api-docs/slack_bolt/context/ack/ack.html | 21 +- .../slack_bolt/context/ack/async_ack.html | 21 +- .../slack_bolt/context/ack/index.html | 21 +- .../slack_bolt/context/ack/internals.html | 17 +- .../assistant/assistant_utilities.html | 48 +- .../assistant/async_assistant_utilities.html | 48 +- .../slack_bolt/context/assistant/index.html | 17 +- .../context/assistant/internals.html | 31 +- .../assistant/thread_context/index.html | 45 +- .../thread_context_store/async_store.html | 33 +- .../default_async_store.html | 52 +- .../thread_context_store/default_store.html | 52 +- .../thread_context_store/file/index.html | 45 +- .../assistant/thread_context_store/index.html | 17 +- .../assistant/thread_context_store/store.html | 33 +- .../slack_bolt/context/async_context.html | 228 +- .../slack_bolt/context/base_context.html | 102 +- .../context/complete/async_complete.html | 23 +- .../slack_bolt/context/complete/complete.html | 23 +- .../slack_bolt/context/complete/index.html | 23 +- .../api-docs/slack_bolt/context/context.html | 229 +- .../slack_bolt/context/fail/async_fail.html | 23 +- .../slack_bolt/context/fail/fail.html | 23 +- .../slack_bolt/context/fail/index.html | 23 +- .../async_get_thread_context.html | 29 +- .../get_thread_context.html | 29 +- .../context/get_thread_context/index.html | 29 +- .../api-docs/slack_bolt/context/index.html | 229 +- .../context/respond/async_respond.html | 25 +- .../slack_bolt/context/respond/index.html | 25 +- .../slack_bolt/context/respond/internals.html | 17 +- .../slack_bolt/context/respond/respond.html | 25 +- .../async_save_thread_context.html | 25 +- .../context/save_thread_context/index.html | 25 +- .../save_thread_context.html | 25 +- .../slack_bolt/context/say/async_say.html | 27 +- .../slack_bolt/context/say/index.html | 29 +- .../slack_bolt/context/say/internals.html | 17 +- .../api-docs/slack_bolt/context/say/say.html | 29 +- .../context/set_status/async_set_status.html | 25 +- .../slack_bolt/context/set_status/index.html | 25 +- .../context/set_status/set_status.html | 25 +- .../async_set_suggested_prompts.html | 25 +- .../context/set_suggested_prompts/index.html | 25 +- .../set_suggested_prompts.html | 25 +- .../context/set_title/async_set_title.html | 25 +- .../slack_bolt/context/set_title/index.html | 25 +- .../context/set_title/set_title.html | 25 +- .../api-docs/slack_bolt/error/index.html | 29 +- docs/static/api-docs/slack_bolt/index.html | 1977 ++++++++++++++--- .../slack_bolt/kwargs_injection/args.html | 67 +- .../kwargs_injection/async_args.html | 67 +- .../kwargs_injection/async_utils.html | 114 +- .../slack_bolt/kwargs_injection/index.html | 163 +- .../slack_bolt/kwargs_injection/utils.html | 113 +- .../lazy_listener/async_internals.html | 45 +- .../lazy_listener/async_runner.html | 53 +- .../lazy_listener/asyncio_runner.html | 30 +- .../slack_bolt/lazy_listener/index.html | 65 +- .../slack_bolt/lazy_listener/internals.html | 45 +- .../slack_bolt/lazy_listener/runner.html | 52 +- .../lazy_listener/thread_runner.html | 30 +- .../slack_bolt/listener/async_builtins.html | 51 +- .../slack_bolt/listener/async_listener.html | 170 +- .../async_listener_completion_handler.html | 41 +- .../async_listener_error_handler.html | 43 +- .../async_listener_start_handler.html | 41 +- .../slack_bolt/listener/asyncio_runner.html | 154 +- .../slack_bolt/listener/builtins.html | 51 +- .../slack_bolt/listener/custom_listener.html | 55 +- .../api-docs/slack_bolt/listener/index.html | 132 +- .../slack_bolt/listener/listener.html | 94 +- .../listener/listener_completion_handler.html | 41 +- .../listener/listener_error_handler.html | 43 +- .../listener/listener_start_handler.html | 45 +- .../slack_bolt/listener/thread_runner.html | 170 +- .../listener_matcher/async_builtins.html | 19 +- .../async_listener_matcher.html | 71 +- .../slack_bolt/listener_matcher/builtins.html | 475 +++- .../custom_listener_matcher.html | 27 +- .../slack_bolt/listener_matcher/index.html | 46 +- .../listener_matcher/listener_matcher.html | 36 +- .../api-docs/slack_bolt/logger/index.html | 44 +- .../api-docs/slack_bolt/logger/messages.html | 370 ++- .../middleware/assistant/assistant.html | 249 ++- .../middleware/assistant/async_assistant.html | 278 ++- .../middleware/assistant/index.html | 249 ++- .../slack_bolt/middleware/async_builtins.html | 94 +- .../middleware/async_custom_middleware.html | 27 +- .../middleware/async_middleware.html | 62 +- .../async_middleware_error_handler.html | 43 +- .../async_attaching_function_token.html | 19 +- .../attaching_function_token.html | 19 +- .../attaching_function_token/index.html | 19 +- .../authorization/async_authorization.html | 19 +- .../authorization/async_internals.html | 17 +- .../async_multi_teams_authorization.html | 47 +- .../async_single_team_authorization.html | 21 +- .../authorization/authorization.html | 19 +- .../middleware/authorization/index.html | 67 +- .../middleware/authorization/internals.html | 17 +- .../multi_teams_authorization.html | 47 +- .../single_team_authorization.html | 35 +- .../middleware/custom_middleware.html | 27 +- .../async_ignoring_self_events.html | 22 +- .../ignoring_self_events.html | 23 +- .../ignoring_self_events/index.html | 23 +- .../api-docs/slack_bolt/middleware/index.html | 192 +- .../async_message_listener_matches.html | 21 +- .../message_listener_matches/index.html | 21 +- .../message_listener_matches.html | 21 +- .../slack_bolt/middleware/middleware.html | 62 +- .../middleware/middleware_error_handler.html | 43 +- .../async_request_verification.html | 43 +- .../request_verification/index.html | 39 +- .../request_verification.html | 39 +- .../middleware/ssl_check/async_ssl_check.html | 41 +- .../middleware/ssl_check/index.html | 43 +- .../middleware/ssl_check/ssl_check.html | 43 +- .../async_url_verification.html | 33 +- .../middleware/url_verification/index.html | 33 +- .../url_verification/url_verification.html | 33 +- .../oauth/async_callback_options.html | 99 +- .../slack_bolt/oauth/async_internals.html | 56 +- .../slack_bolt/oauth/async_oauth_flow.html | 283 ++- .../oauth/async_oauth_settings.html | 167 +- .../slack_bolt/oauth/callback_options.html | 129 +- .../api-docs/slack_bolt/oauth/index.html | 284 ++- .../api-docs/slack_bolt/oauth/internals.html | 78 +- .../api-docs/slack_bolt/oauth/oauth_flow.html | 284 ++- .../slack_bolt/oauth/oauth_settings.html | 167 +- .../slack_bolt/request/async_internals.html | 72 +- .../slack_bolt/request/async_request.html | 77 +- .../api-docs/slack_bolt/request/index.html | 77 +- .../slack_bolt/request/internals.html | 418 +++- .../slack_bolt/request/payload_utils.html | 327 ++- .../api-docs/slack_bolt/request/request.html | 77 +- .../api-docs/slack_bolt/response/index.html | 65 +- .../slack_bolt/response/response.html | 65 +- .../api-docs/slack_bolt/util/async_utils.html | 28 +- .../api-docs/slack_bolt/util/index.html | 17 +- .../api-docs/slack_bolt/util/utils.html | 141 +- docs/static/api-docs/slack_bolt/version.html | 17 +- .../api-docs/slack_bolt/workflows/index.html | 17 +- .../slack_bolt/workflows/step/async_step.html | 363 ++- .../workflows/step/async_step_middleware.html | 19 +- .../slack_bolt/workflows/step/index.html | 241 +- .../slack_bolt/workflows/step/internals.html | 17 +- .../slack_bolt/workflows/step/step.html | 379 +++- .../workflows/step/step_middleware.html | 19 +- .../step/utilities/async_complete.html | 57 +- .../step/utilities/async_configure.html | 71 +- .../workflows/step/utilities/async_fail.html | 51 +- .../step/utilities/async_update.html | 89 +- .../workflows/step/utilities/complete.html | 57 +- .../workflows/step/utilities/configure.html | 71 +- .../workflows/step/utilities/fail.html | 51 +- .../workflows/step/utilities/index.html | 17 +- .../workflows/step/utilities/update.html | 89 +- slack_bolt/version.py | 2 +- 229 files changed, 19409 insertions(+), 4154 deletions(-) diff --git a/docs/static/api-docs/slack_bolt/adapter/aiohttp/index.html b/docs/static/api-docs/slack_bolt/adapter/aiohttp/index.html index 0b2b6e848..879b7a023 100644 --- a/docs/static/api-docs/slack_bolt/adapter/aiohttp/index.html +++ b/docs/static/api-docs/slack_bolt/adapter/aiohttp/index.html @@ -3,19 +3,30 @@ - + slack_bolt.adapter.aiohttp API documentation - + @@ -37,12 +48,53 @@

    Functions

    async def to_aiohttp_response(bolt_resp: BoltResponse) ‑> aiohttp.web_response.Response
    +
    + +Expand source code + +
    async def to_aiohttp_response(bolt_resp: BoltResponse) -> web.Response:
    +    content_type = bolt_resp.headers.pop(
    +        "content-type",
    +        ["application/json" if bolt_resp.body.startswith("{") else "text/plain"],
    +    )[0]
    +    content_type = re.sub(r";\s*charset=utf-8", "", content_type)
    +    resp = web.Response(
    +        status=bolt_resp.status,
    +        body=bolt_resp.body,
    +        headers=bolt_resp.first_headers_without_set_cookie(),
    +        content_type=content_type,
    +    )
    +    for cookie in bolt_resp.cookies():
    +        for name, c in cookie.items():
    +            resp.set_cookie(
    +                name=name,
    +                value=c.value,
    +                max_age=c.get("max-age"),
    +                expires=c.get("expires"),
    +                path=c.get("path"),  # type: ignore[arg-type]
    +                domain=c.get("domain"),
    +                secure=True,
    +                httponly=True,
    +            )
    +    return resp
    +
    async def to_bolt_request(request: aiohttp.web_request.Request) ‑> AsyncBoltRequest
    +
    + +Expand source code + +
    async def to_bolt_request(request: web.Request) -> AsyncBoltRequest:
    +    return AsyncBoltRequest(
    +        body=await request.text(),
    +        query=request.query_string,
    +        headers=request.headers,  # type: ignore[arg-type]
    +    )
    +

    @@ -70,7 +122,7 @@

    Functions

    diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/aiohttp/index.html b/docs/static/api-docs/slack_bolt/adapter/asgi/aiohttp/index.html index b36d86ed9..d598dc6cb 100644 --- a/docs/static/api-docs/slack_bolt/adapter/asgi/aiohttp/index.html +++ b/docs/static/api-docs/slack_bolt/adapter/asgi/aiohttp/index.html @@ -3,19 +3,30 @@ - + slack_bolt.adapter.asgi.aiohttp API documentation - + @@ -40,26 +51,6 @@

    Classes

    (app: AsyncApp,
    path: str = '/slack/events')
    -

    Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. -This can be used for production deployment.

    -

    With the default settings, http://localhost:3000/slack/events -Run Bolt with uvicron

    -
    # Python
    -app = AsyncApp()
    -api = SlackRequestHandler(app)
    -
    -# bash
    -export SLACK_SIGNING_SECRET=***
    -export SLACK_BOT_TOKEN=xoxb-***
    -uvicorn app:api --port 3000 --log-level debug
    -
    -

    Args

    -
    -
    app
    -
    Your bolt application
    -
    path
    -
    The path to handle request from Slack (Default: /slack/events)
    -
    Expand source code @@ -105,25 +96,40 @@

    Args

    AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers()) )
    +

    Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. +This can be used for production deployment.

    +

    With the default settings, http://localhost:3000/slack/events +Run Bolt with uvicron

    +
    # Python
    +app = AsyncApp()
    +api = SlackRequestHandler(app)
    +
    +# bash
    +export SLACK_SIGNING_SECRET=***
    +export SLACK_BOT_TOKEN=xoxb-***
    +uvicorn app:api --port 3000 --log-level debug
    +
    +

    Args

    +
    +
    app
    +
    Your bolt application
    +
    path
    +
    The path to handle request from Slack (Default: /slack/events)
    +

    Ancestors

    -

    Class variables

    -
    -
    var appAsyncApp
    -
    -
    -
    -

    Inherited members

    @@ -145,9 +151,6 @@

    Inherited members

    @@ -155,7 +158,7 @@

    diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/async_handler.html b/docs/static/api-docs/slack_bolt/adapter/asgi/async_handler.html index 9bf506f09..9ecdb6fd3 100644 --- a/docs/static/api-docs/slack_bolt/adapter/asgi/async_handler.html +++ b/docs/static/api-docs/slack_bolt/adapter/asgi/async_handler.html @@ -3,19 +3,30 @@ - + slack_bolt.adapter.asgi.async_handler API documentation - + @@ -40,26 +51,6 @@

    Classes

    (app: AsyncApp,
    path: str = '/slack/events')
    -

    Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. -This can be used for production deployment.

    -

    With the default settings, http://localhost:3000/slack/events -Run Bolt with uvicron

    -
    # Python
    -app = AsyncApp()
    -api = SlackRequestHandler(app)
    -
    -# bash
    -export SLACK_SIGNING_SECRET=***
    -export SLACK_BOT_TOKEN=xoxb-***
    -uvicorn app:api --port 3000 --log-level debug
    -
    -

    Args

    -
    -
    app
    -
    Your bolt application
    -
    path
    -
    The path to handle request from Slack (Default: /slack/events)
    -
    Expand source code @@ -105,25 +96,40 @@

    Args

    AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers()) )
    +

    Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. +This can be used for production deployment.

    +

    With the default settings, http://localhost:3000/slack/events +Run Bolt with uvicron

    +
    # Python
    +app = AsyncApp()
    +api = SlackRequestHandler(app)
    +
    +# bash
    +export SLACK_SIGNING_SECRET=***
    +export SLACK_BOT_TOKEN=xoxb-***
    +uvicorn app:api --port 3000 --log-level debug
    +
    +

    Args

    +
    +
    app
    +
    Your bolt application
    +
    path
    +
    The path to handle request from Slack (Default: /slack/events)
    +

    Ancestors

    -

    Class variables

    -
    -
    var appAsyncApp
    -
    -
    -
    -

    Inherited members

    @@ -145,9 +151,6 @@

    Inherited members

    @@ -155,7 +158,7 @@

    -

    Generated by pdoc 0.11.3.

    +

    Generated by pdoc 0.11.5.

    diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/base_handler.html b/docs/static/api-docs/slack_bolt/adapter/asgi/base_handler.html index 37deef2d1..2e194b12b 100644 --- a/docs/static/api-docs/slack_bolt/adapter/asgi/base_handler.html +++ b/docs/static/api-docs/slack_bolt/adapter/asgi/base_handler.html @@ -3,19 +3,30 @@ - + slack_bolt.adapter.asgi.base_handler API documentation - + @@ -39,7 +50,6 @@

    Classes

    class BaseSlackRequestHandler
    -
    Expand source code @@ -101,6 +111,7 @@

    Classes

    return raise TypeError(f"Unsupported scope type: {scope['type']!r}")
    +

    Subclasses

    • SlackRequestHandler
    • @@ -109,11 +120,11 @@

      Class variables

      var appApp | AsyncApp
      -
      +

      The type of the None singleton.

      var path : str
      -
      +

      The type of the None singleton.

      Methods

      @@ -122,18 +133,42 @@

      Methods

      async def dispatch(self,
      request: AsgiHttpRequest) ‑> BoltResponse
      +
      + +Expand source code + +
      async def dispatch(self, request: AsgiHttpRequest) -> BoltResponse:
      +    """Dispatches a request to the Bolt App"""
      +    raise NotImplementedError
      +

      Dispatches a request to the Bolt App

      async def handle_callback(self,
      request: AsgiHttpRequest) ‑> BoltResponse
      +
      + +Expand source code + +
      async def handle_callback(self, request: AsgiHttpRequest) -> BoltResponse:
      +    """Handles the callback of the OAuthFlow"""
      +    raise NotImplementedError
      +

      Handles the callback of the OAuthFlow

      async def handle_installation(self,
      request: AsgiHttpRequest) ‑> BoltResponse
      +
      + +Expand source code + +
      async def handle_installation(self, request: AsgiHttpRequest) -> BoltResponse:
      +    """Handles installation of the OAuthFlow"""
      +    raise NotImplementedError
      +

      Handles installation of the OAuthFlow

    @@ -169,7 +204,7 @@

    -

    Generated by pdoc 0.11.3.

    +

    Generated by pdoc 0.11.5.

    diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/builtin/index.html b/docs/static/api-docs/slack_bolt/adapter/asgi/builtin/index.html index 07f132169..4a0d0c777 100644 --- a/docs/static/api-docs/slack_bolt/adapter/asgi/builtin/index.html +++ b/docs/static/api-docs/slack_bolt/adapter/asgi/builtin/index.html @@ -3,19 +3,30 @@ - + slack_bolt.adapter.asgi.builtin API documentation - + @@ -40,26 +51,6 @@

    Classes

    (app: App,
    path: str = '/slack/events')
    -

    Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. -This can be used for production deployment.

    -

    With the default settings, http://localhost:3000/slack/events -Run Bolt with uvicron

    -
    # Python
    -app = App()
    -api = SlackRequestHandler(app)
    -
    -# bash
    -export SLACK_SIGNING_SECRET=***
    -export SLACK_BOT_TOKEN=xoxb-***
    -uvicorn app:api --port 3000 --log-level debug
    -
    -

    Args

    -
    -
    app
    -
    Your bolt application
    -
    path
    -
    The path to handle request from Slack (Default: /slack/events)
    -
    Expand source code @@ -103,6 +94,26 @@

    Args

    BoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers()) )
    +

    Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. +This can be used for production deployment.

    +

    With the default settings, http://localhost:3000/slack/events +Run Bolt with uvicron

    +
    # Python
    +app = App()
    +api = SlackRequestHandler(app)
    +
    +# bash
    +export SLACK_SIGNING_SECRET=***
    +export SLACK_BOT_TOKEN=xoxb-***
    +uvicorn app:api --port 3000 --log-level debug
    +
    +

    Args

    +
    +
    app
    +
    Your bolt application
    +
    path
    +
    The path to handle request from Slack (Default: /slack/events)
    +

    Ancestors

    • BaseSlackRequestHandler
    • @@ -115,9 +126,11 @@

      Inherited members

      @@ -146,7 +159,7 @@

      -

      Generated by pdoc 0.11.3.

      +

      Generated by pdoc 0.11.5.

      diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/http_request.html b/docs/static/api-docs/slack_bolt/adapter/asgi/http_request.html index e1f8a58d1..38ab574da 100644 --- a/docs/static/api-docs/slack_bolt/adapter/asgi/http_request.html +++ b/docs/static/api-docs/slack_bolt/adapter/asgi/http_request.html @@ -3,19 +3,30 @@ - + slack_bolt.adapter.asgi.http_request API documentation - + @@ -40,7 +51,6 @@

      Classes

      (scope: Dict[str, str | bytes | Iterable[Tuple[bytes, bytes]]],
      receive: Callable)
      -
      Expand source code @@ -69,18 +79,103 @@

      Classes

      break return bytes(chunks).decode(ENCODING)
      +

      Instance variables

      var query_string
      +
      + +Expand source code + +
      class AsgiHttpRequest:
      +    __slots__ = ("receive", "query_string", "raw_headers")
      +
      +    def __init__(self, scope: scope_type, receive: Callable):
      +        self.receive = receive
      +        self.query_string = str(scope["query_string"], ENCODING)  # type: ignore[arg-type]
      +        self.raw_headers: Iterable[Tuple[bytes, bytes]] = scope["headers"]  # type: ignore[assignment]
      +
      +    def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
      +        return {str(header[0], ENCODING): str(header[1], (ENCODING)) for header in self.raw_headers}
      +
      +    async def get_raw_body(self) -> str:
      +        chunks = bytearray()
      +        while True:
      +            chunk: Dict[str, Union[str, bytes]] = await self.receive()
      +
      +            if chunk["type"] != "http.request":
      +                raise Exception("Body chunks could not be received from asgi server")
      +
      +            chunks.extend(chunk.get("body", b""))  # type: ignore[arg-type]
      +            if not chunk.get("more_body", False):
      +                break
      +        return bytes(chunks).decode(ENCODING)
      +
      var raw_headers
      +
      + +Expand source code + +
      class AsgiHttpRequest:
      +    __slots__ = ("receive", "query_string", "raw_headers")
      +
      +    def __init__(self, scope: scope_type, receive: Callable):
      +        self.receive = receive
      +        self.query_string = str(scope["query_string"], ENCODING)  # type: ignore[arg-type]
      +        self.raw_headers: Iterable[Tuple[bytes, bytes]] = scope["headers"]  # type: ignore[assignment]
      +
      +    def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
      +        return {str(header[0], ENCODING): str(header[1], (ENCODING)) for header in self.raw_headers}
      +
      +    async def get_raw_body(self) -> str:
      +        chunks = bytearray()
      +        while True:
      +            chunk: Dict[str, Union[str, bytes]] = await self.receive()
      +
      +            if chunk["type"] != "http.request":
      +                raise Exception("Body chunks could not be received from asgi server")
      +
      +            chunks.extend(chunk.get("body", b""))  # type: ignore[arg-type]
      +            if not chunk.get("more_body", False):
      +                break
      +        return bytes(chunks).decode(ENCODING)
      +
      var receive
      +
      + +Expand source code + +
      class AsgiHttpRequest:
      +    __slots__ = ("receive", "query_string", "raw_headers")
      +
      +    def __init__(self, scope: scope_type, receive: Callable):
      +        self.receive = receive
      +        self.query_string = str(scope["query_string"], ENCODING)  # type: ignore[arg-type]
      +        self.raw_headers: Iterable[Tuple[bytes, bytes]] = scope["headers"]  # type: ignore[assignment]
      +
      +    def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
      +        return {str(header[0], ENCODING): str(header[1], (ENCODING)) for header in self.raw_headers}
      +
      +    async def get_raw_body(self) -> str:
      +        chunks = bytearray()
      +        while True:
      +            chunk: Dict[str, Union[str, bytes]] = await self.receive()
      +
      +            if chunk["type"] != "http.request":
      +                raise Exception("Body chunks could not be received from asgi server")
      +
      +            chunks.extend(chunk.get("body", b""))  # type: ignore[arg-type]
      +            if not chunk.get("more_body", False):
      +                break
      +        return bytes(chunks).decode(ENCODING)
      +
      @@ -90,12 +185,36 @@

      Methods

      def get_headers(self) ‑> Dict[str, str | Sequence[str]]
      +
      + +Expand source code + +
      def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
      +    return {str(header[0], ENCODING): str(header[1], (ENCODING)) for header in self.raw_headers}
      +
      async def get_raw_body(self) ‑> str
      +
      + +Expand source code + +
      async def get_raw_body(self) -> str:
      +    chunks = bytearray()
      +    while True:
      +        chunk: Dict[str, Union[str, bytes]] = await self.receive()
      +
      +        if chunk["type"] != "http.request":
      +            raise Exception("Body chunks could not be received from asgi server")
      +
      +        chunks.extend(chunk.get("body", b""))  # type: ignore[arg-type]
      +        if not chunk.get("more_body", False):
      +            break
      +    return bytes(chunks).decode(ENCODING)
      +

    @@ -131,7 +250,7 @@

    -

    Generated by pdoc 0.11.3.

    +

    Generated by pdoc 0.11.5.

    diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/http_response.html b/docs/static/api-docs/slack_bolt/adapter/asgi/http_response.html index fa51ddb6c..93c929224 100644 --- a/docs/static/api-docs/slack_bolt/adapter/asgi/http_response.html +++ b/docs/static/api-docs/slack_bolt/adapter/asgi/http_response.html @@ -3,19 +3,30 @@ - + slack_bolt.adapter.asgi.http_response API documentation - + @@ -40,7 +51,6 @@

    Classes

    (status: int, headers: Dict[str, Sequence[str]] = {}, body: str = '')
    -
    Expand source code @@ -70,18 +80,106 @@

    Classes

    "more_body": False, }
    +

    Instance variables

    var body
    +
    + +Expand source code + +
    class AsgiHttpResponse:
    +    __slots__ = ("status", "raw_headers", "body")
    +
    +    def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
    +        self.status: int = status
    +        self.raw_headers: List[Tuple[bytes, bytes]] = [
    +            (bytes(key, ENCODING), bytes(value[0], ENCODING)) for key, value in headers.items()
    +        ]
    +        self.raw_headers.append((b"content-length", bytes(str(len(body)), ENCODING)))
    +        self.body: bytes = bytes(body, ENCODING)
    +
    +    def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]:
    +        return {
    +            "type": "http.response.start",
    +            "status": self.status,
    +            "headers": self.raw_headers,
    +        }
    +
    +    def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]:
    +        return {
    +            "type": "http.response.body",
    +            "body": self.body,
    +            "more_body": False,
    +        }
    +
    var raw_headers
    +
    + +Expand source code + +
    class AsgiHttpResponse:
    +    __slots__ = ("status", "raw_headers", "body")
    +
    +    def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
    +        self.status: int = status
    +        self.raw_headers: List[Tuple[bytes, bytes]] = [
    +            (bytes(key, ENCODING), bytes(value[0], ENCODING)) for key, value in headers.items()
    +        ]
    +        self.raw_headers.append((b"content-length", bytes(str(len(body)), ENCODING)))
    +        self.body: bytes = bytes(body, ENCODING)
    +
    +    def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]:
    +        return {
    +            "type": "http.response.start",
    +            "status": self.status,
    +            "headers": self.raw_headers,
    +        }
    +
    +    def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]:
    +        return {
    +            "type": "http.response.body",
    +            "body": self.body,
    +            "more_body": False,
    +        }
    +
    var status
    +
    + +Expand source code + +
    class AsgiHttpResponse:
    +    __slots__ = ("status", "raw_headers", "body")
    +
    +    def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
    +        self.status: int = status
    +        self.raw_headers: List[Tuple[bytes, bytes]] = [
    +            (bytes(key, ENCODING), bytes(value[0], ENCODING)) for key, value in headers.items()
    +        ]
    +        self.raw_headers.append((b"content-length", bytes(str(len(body)), ENCODING)))
    +        self.body: bytes = bytes(body, ENCODING)
    +
    +    def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]:
    +        return {
    +            "type": "http.response.start",
    +            "status": self.status,
    +            "headers": self.raw_headers,
    +        }
    +
    +    def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]:
    +        return {
    +            "type": "http.response.body",
    +            "body": self.body,
    +            "more_body": False,
    +        }
    +
    @@ -91,12 +189,34 @@

    Methods

    def get_response_body(self) ‑> Dict[str, str | bytes | bool]
    +
    + +Expand source code + +
    def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]:
    +    return {
    +        "type": "http.response.body",
    +        "body": self.body,
    +        "more_body": False,
    +    }
    +
    def get_response_start(self) ‑> Dict[str, str | int | Iterable[Tuple[bytes, bytes]]]
    +
    + +Expand source code + +
    def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]:
    +    return {
    +        "type": "http.response.start",
    +        "status": self.status,
    +        "headers": self.raw_headers,
    +    }
    +

    @@ -132,7 +252,7 @@

    diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/index.html b/docs/static/api-docs/slack_bolt/adapter/asgi/index.html index 3e6dc537b..295ead704 100644 --- a/docs/static/api-docs/slack_bolt/adapter/asgi/index.html +++ b/docs/static/api-docs/slack_bolt/adapter/asgi/index.html @@ -3,19 +3,30 @@ - + slack_bolt.adapter.asgi API documentation - + @@ -71,26 +82,6 @@

    Classes

    (app: App,
    path: str = '/slack/events')
    -

    Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. -This can be used for production deployment.

    -

    With the default settings, http://localhost:3000/slack/events -Run Bolt with uvicron

    -
    # Python
    -app = App()
    -api = SlackRequestHandler(app)
    -
    -# bash
    -export SLACK_SIGNING_SECRET=***
    -export SLACK_BOT_TOKEN=xoxb-***
    -uvicorn app:api --port 3000 --log-level debug
    -
    -

    Args

    -
    -
    app
    -
    Your bolt application
    -
    path
    -
    The path to handle request from Slack (Default: /slack/events)
    -
    Expand source code @@ -134,6 +125,26 @@

    Args

    BoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers()) )
    +

    Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. +This can be used for production deployment.

    +

    With the default settings, http://localhost:3000/slack/events +Run Bolt with uvicron

    +
    # Python
    +app = App()
    +api = SlackRequestHandler(app)
    +
    +# bash
    +export SLACK_SIGNING_SECRET=***
    +export SLACK_BOT_TOKEN=xoxb-***
    +uvicorn app:api --port 3000 --log-level debug
    +
    +

    Args

    +
    +
    app
    +
    Your bolt application
    +
    path
    +
    The path to handle request from Slack (Default: /slack/events)
    +

    Ancestors

    • BaseSlackRequestHandler
    • @@ -146,9 +157,11 @@

      Inherited members

      @@ -188,7 +201,7 @@

      -

      Generated by pdoc 0.11.3.

      +

      Generated by pdoc 0.11.5.

      diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/utils.html b/docs/static/api-docs/slack_bolt/adapter/asgi/utils.html index ce55c6dc3..031deda6c 100644 --- a/docs/static/api-docs/slack_bolt/adapter/asgi/utils.html +++ b/docs/static/api-docs/slack_bolt/adapter/asgi/utils.html @@ -3,19 +3,30 @@ - + slack_bolt.adapter.asgi.utils API documentation - + @@ -49,7 +60,7 @@

      Module slack_bolt.adapter.asgi.utils

      diff --git a/docs/static/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html b/docs/static/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html index b5cbb0a1e..e8bd162ec 100644 --- a/docs/static/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html +++ b/docs/static/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html @@ -3,19 +3,30 @@ - + slack_bolt.adapter.aws_lambda.chalice_handler API documentation - + @@ -37,18 +48,51 @@

      Functions

      def not_found() ‑> chalice.app.Response
      +
      + +Expand source code + +
      def not_found() -> Response:
      +    return Response(
      +        status_code=404,
      +        body="Not Found",
      +        headers={},
      +    )
      +
      def to_bolt_request(request: chalice.app.Request, body: str) ‑> BoltRequest
      +
      + +Expand source code + +
      def to_bolt_request(request: Request, body: str) -> BoltRequest:
      +    return BoltRequest(
      +        body=body,
      +        query=request.query_params,  # type: ignore[arg-type]
      +        headers=request.headers,  # type: ignore[arg-type]
      +    )
      +
      def to_chalice_response(resp: BoltResponse) ‑> chalice.app.Response
      +
      + +Expand source code + +
      def to_chalice_response(resp: BoltResponse) -> Response:
      +    return Response(
      +        status_code=resp.status,
      +        body=resp.body,
      +        headers=resp.first_headers(),  # type: ignore[arg-type]
      +    )
      +

    @@ -61,7 +105,6 @@

    Classes

    (app: App,
    chalice: chalice.app.Chalice,
    lambda_client: botocore.client.BaseClient | None = None)
    -
    Expand source code @@ -137,6 +180,7 @@

    Classes

    return not_found()
    +

    Static methods

    @@ -152,6 +196,49 @@

    Methods

    def handle(self, request: chalice.app.Request)
    +
    + +Expand source code + +
    def handle(self, request: Request):
    +    body: str = request.raw_body.decode("utf-8") if request.raw_body else ""  # type: ignore[union-attr]
    +    self.logger.debug(f"Incoming request: {request.to_dict()}, body: {body}")
    +
    +    method = request.method
    +    if method is None:
    +        return not_found()
    +    if method == "GET":
    +        if self.app.oauth_flow is not None:
    +            oauth_flow: OAuthFlow = self.app.oauth_flow
    +            bolt_req: BoltRequest = to_bolt_request(request, body)
    +            query = bolt_req.query
    +            is_callback = query is not None and (
    +                (_first_value(query, "code") is not None and _first_value(query, "state") is not None)
    +                or _first_value(query, "error") is not None
    +            )
    +            if is_callback:
    +                bolt_resp = oauth_flow.handle_callback(bolt_req)
    +                return to_chalice_response(bolt_resp)
    +            else:
    +                bolt_resp = oauth_flow.handle_installation(bolt_req)
    +                return to_chalice_response(bolt_resp)
    +    elif method == "POST":
    +        bolt_req = to_bolt_request(request, body)
    +        # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html
    +        aws_lambda_function_name = self.chalice.lambda_context.function_name
    +        bolt_req.context["aws_lambda_function_name"] = aws_lambda_function_name
    +        bolt_req.context["chalice_request"] = request.to_dict()
    +        bolt_resp = self.app.dispatch(bolt_req)
    +        aws_response = to_chalice_response(bolt_resp)
    +        return aws_response
    +    elif method == "NONE":
    +        bolt_req = to_bolt_request(request, body)
    +        bolt_resp = self.app.dispatch(bolt_req)
    +        aws_response = to_chalice_response(bolt_resp)
    +        return aws_response
    +
    +    return not_found()
    +
    @@ -191,7 +278,7 @@

    -

    Generated by pdoc 0.11.3.

    +

    Generated by pdoc 0.11.5.

    diff --git a/docs/static/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html b/docs/static/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html index 4a882d5bd..8bd6428c1 100644 --- a/docs/static/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html +++ b/docs/static/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html @@ -3,19 +3,30 @@ - + slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner API documentation - + @@ -40,7 +51,6 @@

    Classes

    (logger: logging.Logger,
    lambda_client: botocore.client.BaseClient | None = None)
    -
    Expand source code @@ -74,6 +84,7 @@

    Classes

    Payload=json.dumps(payload), )
    +

    Ancestors

    class Update @@ -329,7 +329,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepFailed API method. - Refer to https://api.slack.com/methods/workflows.updateStep for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, client: WebClient, body: dict): @@ -377,7 +377,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepFailed API method. -Refer to https://api.slack.com/methods/workflows.updateStep for details.

    +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    class WorkflowStep @@ -411,7 +411,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps Args: callback_id: The callback_id for this step from app @@ -453,7 +453,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps """ return WorkflowStepBuilder( callback_id, @@ -546,7 +546,7 @@

    Classes

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    Args

    callback_id
    @@ -598,7 +598,7 @@

    Static methods

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/step.html b/docs/static/api-docs/slack_bolt/workflows/step/step.html index c1500beaf..efaaad899 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/step.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/step.html @@ -78,7 +78,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps Args: callback_id: The callback_id for this step from app @@ -120,7 +120,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps """ return WorkflowStepBuilder( callback_id, @@ -213,7 +213,7 @@

    Classes

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    Args

    callback_id
    @@ -265,7 +265,7 @@

    Static methods

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    @@ -280,7 +280,7 @@

    Static methods

    class WorkflowStepBuilder:
         """Steps from apps
    -    Refer to https://api.slack.com/workflows/steps for details.
    +    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.
         """
     
         callback_id: Union[str, Pattern]
    @@ -298,7 +298,7 @@ 

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps This builder is supposed to be used as decorator. @@ -340,7 +340,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps Registers a new edit listener with details. @@ -394,7 +394,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps Registers a new save listener with details. @@ -447,7 +447,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps Registers a new execute listener with details. @@ -494,7 +494,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -584,10 +584,10 @@

    Static methods

    return _middleware

    Steps from apps -Refer to https://api.slack.com/workflows/steps for details.

    +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    This builder is supposed to be used as decorator.

    my_step = WorkflowStep.builder("my_step")
     @my_step.edit
    @@ -703,7 +703,7 @@ 

    Methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -729,7 +729,7 @@

    Methods

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object.

    Returns

    @@ -753,7 +753,7 @@

    Returns

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps Registers a new edit listener with details. @@ -799,7 +799,7 @@

    Returns

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    Registers a new edit listener with details.

    You can use this method as decorator as well.

    @my_step.edit
    @@ -844,7 +844,7 @@ 

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps Registers a new execute listener with details. @@ -889,7 +889,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    Registers a new execute listener with details.

    You can use this method as decorator as well.

    @my_step.execute
    @@ -934,7 +934,7 @@ 

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps Registers a new save listener with details. @@ -979,7 +979,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    Registers a new save listener with details.

    You can use this method as decorator as well.

    @my_step.save
    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_complete.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_complete.html
    index 7dc7744a7..8a8790900 100644
    --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_complete.html
    +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_complete.html
    @@ -76,7 +76,7 @@ 

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepCompleted API method. - Refer to https://api.slack.com/methods/workflows.stepCompleted for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, client: AsyncWebClient, body: dict): @@ -108,7 +108,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepCompleted API method. -Refer to https://api.slack.com/methods/workflows.stepCompleted for details.

    +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_configure.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_configure.html index 1e0395e92..3151e71bd 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_configure.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_configure.html @@ -83,7 +83,7 @@

    Classes

    ) app.step(ws) - Refer to https://api.slack.com/workflows/steps for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, callback_id: str, client: AsyncWebClient, body: dict): @@ -131,7 +131,7 @@

    Classes

    ) app.step(ws)
    -

    Refer to https://api.slack.com/workflows/steps for details.

    +

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_fail.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_fail.html index 683174686..1206c45a6 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_fail.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_fail.html @@ -73,7 +73,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepFailed API method. - Refer to https://api.slack.com/methods/workflows.stepFailed for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, client: AsyncWebClient, body: dict): @@ -106,7 +106,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepFailed API method. -Refer to https://api.slack.com/methods/workflows.stepFailed for details.

    +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_update.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_update.html index 8b8282fae..0d1cad162 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_update.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_update.html @@ -92,7 +92,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepFailed API method. - Refer to https://api.slack.com/methods/workflows.updateStep for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, client: AsyncWebClient, body: dict): @@ -140,7 +140,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepFailed API method. -Refer to https://api.slack.com/methods/workflows.updateStep for details.

    +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/complete.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/complete.html index 685a3bd63..c15d51e9c 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/complete.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/complete.html @@ -76,7 +76,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepCompleted API method. - Refer to https://api.slack.com/methods/workflows.stepCompleted for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, client: WebClient, body: dict): @@ -108,7 +108,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepCompleted API method. -Refer to https://api.slack.com/methods/workflows.stepCompleted for details.

    +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/configure.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/configure.html index 8199f9ea4..2c1aeadbf 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/configure.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/configure.html @@ -83,7 +83,7 @@

    Classes

    ) app.step(ws) - Refer to https://api.slack.com/workflows/steps for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, callback_id: str, client: WebClient, body: dict): @@ -128,7 +128,7 @@

    Classes

    ) app.step(ws) -

    Refer to https://api.slack.com/workflows/steps for details.

    +

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/fail.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/fail.html index 071bbbfed..4c4091fd5 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/fail.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/fail.html @@ -73,7 +73,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepFailed API method. - Refer to https://api.slack.com/methods/workflows.stepFailed for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, client: WebClient, body: dict): @@ -106,7 +106,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepFailed API method. -Refer to https://api.slack.com/methods/workflows.stepFailed for details.

    +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/update.html b/docs/static/api-docs/slack_bolt/workflows/step/utilities/update.html index f24c07836..c93fc7f21 100644 --- a/docs/static/api-docs/slack_bolt/workflows/step/utilities/update.html +++ b/docs/static/api-docs/slack_bolt/workflows/step/utilities/update.html @@ -92,7 +92,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepFailed API method. - Refer to https://api.slack.com/methods/workflows.updateStep for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, client: WebClient, body: dict): @@ -140,7 +140,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepFailed API method. -Refer to https://api.slack.com/methods/workflows.updateStep for details.

    +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    From 1b3e3beadd9be0b925cf0c44c5746b82bc1713df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Apr 2025 09:44:28 -0400 Subject: [PATCH 754/865] chore(deps): bump http-proxy-middleware from 2.0.7 to 2.0.9 in /docs (#1299) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index e65be5252..ec9caf660 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -8868,9 +8868,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", - "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", "license": "MIT", "dependencies": { "@types/http-proxy": "^1.17.8", From f1c00487ae70eae7fc8ba21f057d78c56c724406 Mon Sep 17 00:00:00 2001 From: Haley Elmendorf <31392893+haleychaas@users.noreply.github.com> Date: Wed, 30 Apr 2025 10:25:33 -0500 Subject: [PATCH 755/865] Docs: Fixed incorrect link (#1300) --- docs/content/tutorial/custom-steps-for-jira.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/tutorial/custom-steps-for-jira.md b/docs/content/tutorial/custom-steps-for-jira.md index d0fbe0979..b38f9337c 100644 --- a/docs/content/tutorial/custom-steps-for-jira.md +++ b/docs/content/tutorial/custom-steps-for-jira.md @@ -21,7 +21,7 @@ If you'd rather skip the tutorial and just head straight to the code, you can us 1. Navigate to the [app creation page](https://api.slack.com/apps/new) and select **From a manifest**. 2. Select the workspace you want to install the application in, then click **Next**. -3. Copy the contents of the [`manifest.json`](https://github.com/slack-samples/bolt-python-ai-chatbot/blob/main/manifest.json) file below into the text box that says **Paste your manifest code here** (within the **JSON** tab), then click **Next**: +3. Copy the contents of the [`manifest.json`](https://github.com/slack-samples/bolt-python-jira-functions/blob/main/manifest.json) file below into the text box that says **Paste your manifest code here** (within the **JSON** tab), then click **Next**: ```js reference title="manifest.json" https://github.com/slack-samples/bolt-python-jira-functions/blob/main/manifest.json From a750470e2f08b85a10f75eeaf277f61d295e6ba0 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 20 May 2025 18:27:31 -0700 Subject: [PATCH 756/865] ci: pin actions workflow step hashes and use minimum permissions (#1303) --- .github/workflows/codecov.yml | 13 +++++++++---- .github/workflows/docs-deploy.yml | 15 +++++++++------ .github/workflows/flake8.yml | 11 ++++++++--- .github/workflows/mypy.yml | 11 ++++++++--- .github/workflows/tests.yml | 13 +++++++++---- .github/workflows/triage-issues.yml | 15 +++++++-------- 6 files changed, 50 insertions(+), 28 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 79fd440b2..391c135c6 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -2,7 +2,8 @@ name: Run codecov on: push: - branches: [main] + branches: + - main pull_request: jobs: @@ -12,12 +13,16 @@ jobs: strategy: matrix: python-version: ["3.13"] + permissions: + contents: read env: BOLT_PYTHON_CODECOV_RUNNING: "1" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -31,7 +36,7 @@ jobs: run: | pytest --cov=./slack_bolt/ --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 with: fail_ci_if_error: true verbose: true diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 54523819e..ed18c4b1d 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -5,23 +5,26 @@ on: branches: - main paths: - - 'docs/**' + - "docs/**" push: branches: - main paths: - - 'docs/**' + - "docs/**" workflow_dispatch: jobs: build: name: Build Docusaurus runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - - uses: actions/setup-node@v4 + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 cache: npm @@ -36,7 +39,7 @@ jobs: working-directory: ./docs - name: Upload Build Artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 with: path: ./docs/build @@ -59,4 +62,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 \ No newline at end of file + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index c64484b1b..87f3496e1 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -2,7 +2,8 @@ name: Run flake8 validation on: push: - branches: [main] + branches: + - main pull_request: jobs: @@ -12,10 +13,14 @@ jobs: strategy: matrix: python-version: ["3.13"] + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} - name: Run flake8 verification diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index a592bd8cd..f333756b5 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -2,7 +2,8 @@ name: Run mypy validation on: push: - branches: [main] + branches: + - main pull_request: jobs: @@ -12,10 +13,14 @@ jobs: strategy: matrix: python-version: ["3.13"] + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} - name: Run mypy verification diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bb35112c3..86fa4621c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,8 @@ name: Run all the unit tests on: push: - branches: [main] + branches: + - main pull_request: jobs: @@ -20,10 +21,14 @@ jobs: - "3.11" - "3.12" - "3.13" + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} - name: Install synchronous dependencies @@ -68,7 +73,7 @@ jobs: pytest tests/scenario_tests_async/ --junitxml=reports/test_scenario_async.xml - name: Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 + uses: codecov/test-results-action@f2dba722c67b86c6caa034178c6e4d35335f6706 # v1.1.0 with: directory: ./reports/ flags: ${{ matrix.python-version }} diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index d1275a94d..b37c13422 100644 --- a/.github/workflows/triage-issues.yml +++ b/.github/workflows/triage-issues.yml @@ -4,20 +4,19 @@ name: Close stale issues and PRs -on: +on: workflow_dispatch: schedule: - - cron: '0 0 * * 1' - -permissions: - issues: write - pull-requests: write + - cron: "0 0 * * 1" jobs: stale: runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write steps: - - uses: actions/stale@v9.1.0 + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 with: days-before-issue-stale: 30 days-before-issue-close: 10 @@ -30,4 +29,4 @@ jobs: exempt-all-milestones: true remove-stale-when-updated: true enable-statistics: true - operations-per-run: 60 \ No newline at end of file + operations-per-run: 60 From accf85d4a46650b6a51a6ad49538dbf160fc743d Mon Sep 17 00:00:00 2001 From: Tracy Rericha <108959677+technically-tracy@users.noreply.github.com> Date: Tue, 27 May 2025 12:09:34 -0400 Subject: [PATCH 757/865] Docs: moved over custom steps dynamic options page. (#1307) --- .../concepts/custom-steps-dynamic-options.md | 247 ++++++++++++++++++ docs/sidebars.js | 1 + 2 files changed, 248 insertions(+) create mode 100644 docs/content/concepts/custom-steps-dynamic-options.md diff --git a/docs/content/concepts/custom-steps-dynamic-options.md b/docs/content/concepts/custom-steps-dynamic-options.md new file mode 100644 index 000000000..cab3f7a61 --- /dev/null +++ b/docs/content/concepts/custom-steps-dynamic-options.md @@ -0,0 +1,247 @@ +# Custom Steps dynamic options for Workflow Builder + +## Background {#background} + +[Legacy steps from apps](https://docs.slack.dev/changelog/2023-08-workflow-steps-from-apps-step-back) previously enabled Slack apps to create and process custom workflow steps, which could then be shared and used by anyone in Workflow Builder. To support your transition away from them, custom steps used as dynamic options are available. These allow you to use data defined when referencing the step in Workflow Builder as inputs to the step. + +## Example use case {#use-case} + +Let's say a builder wants to add a custom step in Workflow Builder that creates an issue in an external issue-tracking system. First, they'll need to specify a project. Once a project is selected, a project-specific list of fields can be presented to them to choose from when creating the issue. + +As a developer, dynamic options allow you to supply data to input parameters of custom steps so that you can provide builders with varying sets of fields based on the builders' selections. + +In this example, the primary step would invoke a separate project selection step that retrieves the list of available projects. The builder-selected item from the retrieved list would then be used as the input to the secondary issue creation step. + +There are two parts necessary for Slack apps to support dynamic options: custom step definitions, and handling custom step dynamic options. We'll take a look at both in the following sections. + +## Custom step definitions {#custom-step-definitions} + +When defining an input to a custom step intended to be dynamic (rather than explicitly defining a set of input parameters up front), you'll define a `dynamic_options` property that points to another custom step designed to return the set of dynamic elements once this step is added to a workflow from Workflow Builder. + +An input parameter for a custom step can reference a different custom step that defines what data is available for it to return. One Slack app could even use another Slack app’s custom step to define dynamic options for one of its inputs. + +The following code snippet from our issue creation example discussed above shows a `create-issue` custom step that will be used as a workflow step. Another custom step, the `get-projects` step, will dynamically populate the project input parameter to be configured by a builder. This `get-projects` step provides an `array` containing projects fetched dynamically from the external issue-tracking system. + +```js + "functions": { + "create-issue": { + "title": "Create Issue", + "description": "", + "input_parameters": { + "support_channel": { + "type": "slack#/types/channel_id", + "title": "Support Channel", + "description": "", + "name": "support_channel" + }, + "project": { + "type": "string", + "title": "Project", + "description": "A project from the issue tracking system", + "is_required": true, + "dynamic_options": { + "function": "#/functions/get-projects", + "inputs": {} + } + }, + }, + "output_parameters": {} + }, + "get-projects": { + "title": "Get Projects", + "description": "Get the available project from the issue tracking system", + "input_parameters": {}, + "output_parameters": { + "options": { + "type": "slack#/types/options_select", + "title": "Project Options", + } + } + } + }, +``` +### Defining the `function` and `inputs` attributes {#define-attributes} + +Defining the `function` and `inputs` attributes of the `dynamic_options` property would look as follows: + +``` +"dynamic_options": { + "function": "#/functions/get-projects", + "inputs": {} +} +``` + +The `function` attribute specifies the step reference used to resolve the options of the input parameter. For example: `"#/functions/get-projects"`. + +The `inputs` attribute defines the parameters to be passed as inputs to the step referenced by the `function` attribute. For example: + +``` +"inputs": { + "selected_user_id": { + "value": "{{input_parameters.user_id}}" + }, + "query": { + "value": "{{client.query}}" + } +} +``` + +The following format can be used to reference any input parameter defined by the step: `{{input_parameters.}}`. + +In addition, the `{{client.query}}` parameter can be used as a placeholder for an input value. The `{{client.builder_context}}` parameter will inject the [`slack#/types/user_context`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types/#usercontext) of the user building the workflow as the value to the input parameter. + +### Types of dynamic options UIs {#dynamic-option-UIs} + +The above example demonstrates one possible UI to be rendered for builders: a single-select drop-down menu of dynamic options. However, dynamic options in Workflow Builder can be rendered in one of two ways: as a drop-down menu (single-select or multi-select), or as a set of fields. + +The type is dictated by the output parameter of the custom step used as a dynamic option. In order to use a custom step in a dynamic option context, its output must adhere to a defined interface, that is, it must have an `options` parameter of type [`options_select`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_select) or [`options_field`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_field), as shown in the following code snippet. + +```js +"output_parameters": { + "options": { + "type": "slack#/types/options_select" or "slack#/types/options_field", + "title": "Custom Options", + "description": "Options to be used in a dynamic context", + } + ... +} +``` + +#### Drop-down menus {#drop-down} + +Your dynamic input parameter can be rendered as a drop-down menu, which will use the options obtained from a custom step with an `options` output parameter of the type [`options_select`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_select). + +The drop-down menu UI component can be rendered in two ways: single-select, or multi-select. To render the dynamic input as a single-select menu, the input parameter defining the dynamic option must be of the type [`string`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#string). + +```js +"step-with-dynamic-input": { + "title": "Step that uses a dynamic input", + "description": "This step uses a dynamic input rendered as a single-select menu", + "input_parameters": { + "dynamic_single_select": { + "type": "string", // this must be of type string for single-select + "title": "dynamic single select drop-down menu", + "description": "A dynamically-populated single-select drop-down menu", + "is_required": true, + "dynamic_options": { + "function": "#/functions/get-options", + "inputs": {}, + }, + } + }, + "output_parameters": {} +} +``` + +To render the dynamic input as a multi-select menu, the input parameter defining the dynamic option must be of the type [`array`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#array), and its `items` must be of type [`string`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#string). + +```js +"step-with-dynamic-input": { + "title": "Step that uses a dynamic input", + "description": "This step uses a dynamic input rendered as a multi-select menu", + "input_parameters": { + "dynamic_multi_select": { + "type": "array", // this must be of type array for multi-select + "items": { + "type": "string" + }, + "title": "dynamic single select drop-down menu", + "description": "A dynamically-populated multi-select drop-down menu", + "dynamic_options": { + "function": "#/functions/get-options", + "inputs": {}, + }, + } + }, + "output_parameters": {} +} +``` + +#### Fields {#fields} + +In the code snippet below, the input parameter is rendered as a set of fields with keys and values. The option fields are obtained from a custom step with an `options` output parameter of type [`options_field`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_field). + +The input parameter that defines the dynamic option must be of type [`object`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#object), as the completed set of fields in Workflow Builder will be passed to the custom step as an [untyped object](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#untyped-object) during workflow execution. + +```js +"test-field-dynamic-options": { + "title": "Test dynamic field options", + "description": "", + "input_parameters": { + "dynamic_fields": { + "type": "object", + "title": "Dynamic custom field options", + "description": "A dynamically-populated section of input fields", + "dynamic_options": { + "function": "#/functions/get-field-options", + "inputs": {} + "selection_type": "key-value", + } + } + }, + "output_parameters": {} +} +``` + +### Dynamic option types {#dynamic-option-types} + +As mentioned earlier, in order to use a custom step as a dynamic option, its output must adhere to a defined interface: it must have an `options` output parameter of the type either [`options_select`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_select) or [`options_field`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_field). + +To take a look at these in more detail, refer to our [Options Slack type](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options) documentation. + +## Dynamic options handler {#dynamic-option-handler} + +Each custom step defined in the manifest needs a corresponding handler in your Slack app. Although implemented similarly to existing function execution event handlers, there are two key differences between regular custom step invocations and those used for dynamic options: + +* The custom step must have an `options` output parameter that is of type [`options_select`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_select) or [`options_field`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_field). +* The [`function_executed`](https://docs.slack.dev/reference/events/function_executed) event must be handled synchronously. This optimizes the response time of returned dynamic options and provides a crisp builder experience. + +### Asynchronous event handling {#async} + +By default, the [Bolt family of frameworks](https://tools.slack.dev/) handles `function_executed` events asynchronously. + +For example, the various modal-related API methods provide two ways to update a view: synchronously using a `response_action` HTTP response, or asynchronously using a separate HTTP API call. Using the asynchronous approach allows developers to handle events free of timeouts, but this isn't desired for dynamic options as it introduces delays and violates our stated goal of providing a crisp builder experience. + +### Synchronous event handling {#sync} + +Dynamic options support synchronous handling of `function_executed` events. By ensuring that the function execution’s state is complete with output parameters provided before responding to the `function_executed` event, Slack can quickly provide Workflow Builder with the requisite dynamic options. + +### Implementation {#implementation} + +To optimize the response time of dynamic options, you must acknowledge the incoming event after calling the [`function.completeSuccess`](https://docs.slack.dev/reference/methods/functions.completeSuccess) or [`function.completeError`](https://docs.slack.dev/reference/methods/functions.completeError) API methods, minimizing asynchronous latency. The `function.completeSuccess` and `function.completeError` API methods are invoked in the complete and fail helper functions. ([For example](https://github.com/slackapi/bolt-python?tab=readme-ov-file#making-things-happen)). + +A new `auto_acknowledge` flag allows you more granular control over whether specific event handlers should operate in synchronous or asynchronous response modes in order to enable a smooth dynamic options experience. + +#### Example {#bolt-py} + +In [Bolt for Python](https://tools.slack.dev/bolt-python/), you can set `auto_acknowledge=False` on a specific function decorator. This allows you to manually control when the `ack()` event acknowledgement helper function is executed. It flips Bolt to synchronous `function_executed` event handling mode for the specific handler. + +```py +@app.function("get-projects", auto_acknowledge=False) +def handle_get_projects(ack: Ack, complete: Complete): + try: + complete( + outputs={ + "options": [ + { + "text": { + "type": "plain_text", + "text": "Secret Squirrel Project", + }, + "value": "p1", + }, + { + "text": { + "type": "plain_text", + "text": "Public Kangaroo Project", + }, + "value": "p2", + }, + ] + } + ) + finally: + ack() +``` + +✨ **To learn more about the Bolt family of frameworks and tools**, check out our [Slack Developer Tools](https://tools.slack.dev/). diff --git a/docs/sidebars.js b/docs/sidebars.js index 752b61787..82209d428 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -41,6 +41,7 @@ const sidebars = { }, "concepts/ai-apps", "concepts/custom-steps", + "concepts/custom-steps-dynamic-options", { type: "category", label: "App Configuration", From dd39ed5350fb4dc3984fb7f8d9e36f321153b8dc Mon Sep 17 00:00:00 2001 From: Tracy Rericha <108959677+technically-tracy@users.noreply.github.com> Date: Tue, 27 May 2025 12:36:17 -0400 Subject: [PATCH 758/865] Docs: Updated links to match Bolt JS. (#1308) --- docs/content/tutorial/custom-steps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/tutorial/custom-steps.md b/docs/content/tutorial/custom-steps.md index 79b02089b..2486a49ef 100644 --- a/docs/content/tutorial/custom-steps.md +++ b/docs/content/tutorial/custom-steps.md @@ -69,7 +69,7 @@ Field | Type | Description `type` | String | Defines the data type and can fall into one of two categories: primitives or Slack-specific. `title` | String | The label that appears in Workflow Builder when a user sets up this step in their workflow. `description` | String | The description that accompanies the input when a user sets up this step in their workflow. -`dynamic_options` | Object | For custom steps dynamic options in Workflow Builder, define this property and point to a custom step designed to return the set of dynamic elements once the step is added to a workflow within Workflow Builder. Dynamic options in Workflow Builder can be rendered in one of two ways: as a drop-down menu (single-select or multi-select), or as a set of fields. Refer to [custom steps dynamic options in Workflow Builder](/automation/runonslack/custom-steps-dynamic-options) for more details. +`dynamic_options` | Object | For custom steps dynamic options in Workflow Builder, define this property and point to a custom step designed to return the set of dynamic elements once the step is added to a workflow within Workflow Builder. Dynamic options in Workflow Builder can be rendered in one of two ways: as a drop-down menu (single-select or multi-select), or as a set of fields. Refer to custom steps dynamic options for Workflow Builder using [Bolt for JavaScript](https://tools.slack.dev/bolt-js/concepts/custom-steps-dynamic-options/) or [Bolt for Python](https://tools.slack.dev/bolt-python/concepts/custom-steps-dynamic-options/) for more details. `is_required` | Boolean | Indicates whether or not the input is required by the step in order to run. If it’s required and not provided, the user will not be able to save the configuration nor use the step in their workflow. This property is available only in v1 of the manifest. We recommend v2, using the `required` array as noted in the example above. `hint` | String | Helper text that appears below the input when a user sets up this step in their workflow. From e2896fdff75bf65fd8d729cdfe6180e33ecf092c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Jun 2025 18:51:59 -0700 Subject: [PATCH 759/865] chore(deps): bump codecov/test-results-action from 1.1.0 to 1.1.1 (#1310) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 86fa4621c..a1135f265 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -73,7 +73,7 @@ jobs: pytest tests/scenario_tests_async/ --junitxml=reports/test_scenario_async.xml - name: Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/test-results-action@f2dba722c67b86c6caa034178c6e4d35335f6706 # v1.1.0 + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 with: directory: ./reports/ flags: ${{ matrix.python-version }} From 79fb18add72b40599dbb1322c41ddf2812c4b2e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Jun 2025 18:55:58 -0700 Subject: [PATCH 760/865] chore(deps): bump the docusaurus group in /docs with 5 updates (#1311) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Michael Brooks --- docs/package-lock.json | 1990 ++++++++++++++++------------------------ docs/package.json | 10 +- 2 files changed, 778 insertions(+), 1222 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index ec9caf660..303f5487a 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -8,9 +8,9 @@ "name": "website", "version": "2024.08.01", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/plugin-client-redirects": "^3.7.0", - "@docusaurus/preset-classic": "3.7.0", + "@docusaurus/core": "3.8.0", + "@docusaurus/plugin-client-redirects": "^3.8.0", + "@docusaurus/preset-classic": "3.8.0", "@mdx-js/react": "^3.1.0", "clsx": "^2.0.0", "docusaurus-theme-github-codeblock": "^2.0.2", @@ -19,8 +19,8 @@ "react-dom": "^19.1.0" }, "devDependencies": { - "@docusaurus/module-type-aliases": "3.7.0", - "@docusaurus/types": "3.7.0" + "@docusaurus/module-type-aliases": "3.8.0", + "@docusaurus/types": "3.8.0" }, "engines": { "node": ">=20.0" @@ -72,99 +72,99 @@ } }, "node_modules/@algolia/client-abtesting": { - "version": "5.20.4", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.20.4.tgz", - "integrity": "sha512-OZ3Xvvf+k7NMcwmmioIVX+76E/KKtN607NCMNsBEKe+uHqktZ+I5bmi/EVr2m5VF59Gnh9MTlJCdXtBiGjruxw==", + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.25.0.tgz", + "integrity": "sha512-1pfQulNUYNf1Tk/svbfjfkLBS36zsuph6m+B6gDkPEivFmso/XnRgwDvjAx80WNtiHnmeNjIXdF7Gos8+OLHqQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.20.4", - "@algolia/requester-browser-xhr": "5.20.4", - "@algolia/requester-fetch": "5.20.4", - "@algolia/requester-node-http": "5.20.4" + "@algolia/client-common": "5.25.0", + "@algolia/requester-browser-xhr": "5.25.0", + "@algolia/requester-fetch": "5.25.0", + "@algolia/requester-node-http": "5.25.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.20.4", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.20.4.tgz", - "integrity": "sha512-8pM5zQpHonCIBxKmMyBLgQoaSKUNBE5u741VEIjn2ArujolhoKRXempRAlLwEg5hrORKl9XIlit00ff4g6LWvA==", + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.25.0.tgz", + "integrity": "sha512-AFbG6VDJX/o2vDd9hqncj1B6B4Tulk61mY0pzTtzKClyTDlNP0xaUiEKhl6E7KO9I/x0FJF5tDCm0Hn6v5x18A==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.20.4", - "@algolia/requester-browser-xhr": "5.20.4", - "@algolia/requester-fetch": "5.20.4", - "@algolia/requester-node-http": "5.20.4" + "@algolia/client-common": "5.25.0", + "@algolia/requester-browser-xhr": "5.25.0", + "@algolia/requester-fetch": "5.25.0", + "@algolia/requester-node-http": "5.25.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.20.4", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.20.4.tgz", - "integrity": "sha512-OCGa8hKAP6kQKBwi+tu9flTXshz4qeCK5P8J6bI1qq8KYs+/TU1xSotT+E7hO+uyDanGU6dT6soiMSi4A38JgA==", + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.25.0.tgz", + "integrity": "sha512-il1zS/+Rc6la6RaCdSZ2YbJnkQC6W1wiBO8+SH+DE6CPMWBU6iDVzH0sCKSAtMWl9WBxoN6MhNjGBnCv9Yy2bA==", "license": "MIT", "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-insights": { - "version": "5.20.4", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.20.4.tgz", - "integrity": "sha512-MroyJStJFLf/cYeCbguCRdrA2U6miDVqbi3t9ZGovBWWTef7BZwVQG0mLyInzp4MIjBfwqu3xTrhxsiiOavX3A==", + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.25.0.tgz", + "integrity": "sha512-blbjrUH1siZNfyCGeq0iLQu00w3a4fBXm0WRIM0V8alcAPo7rWjLbMJMrfBtzL9X5ic6wgxVpDADXduGtdrnkw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.20.4", - "@algolia/requester-browser-xhr": "5.20.4", - "@algolia/requester-fetch": "5.20.4", - "@algolia/requester-node-http": "5.20.4" + "@algolia/client-common": "5.25.0", + "@algolia/requester-browser-xhr": "5.25.0", + "@algolia/requester-fetch": "5.25.0", + "@algolia/requester-node-http": "5.25.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.20.4", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.20.4.tgz", - "integrity": "sha512-bVR5sxFfgCQ+G0ZegGVhBqtaDd7jCfr33m5mGuT43U+bH//xeqAHQyIS4abcmRulwqeIAHNm5Yl2J7grT3z//A==", + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.25.0.tgz", + "integrity": "sha512-aywoEuu1NxChBcHZ1pWaat0Plw7A8jDMwjgRJ00Mcl7wGlwuPt5dJ/LTNcg3McsEUbs2MBNmw0ignXBw9Tbgow==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.20.4", - "@algolia/requester-browser-xhr": "5.20.4", - "@algolia/requester-fetch": "5.20.4", - "@algolia/requester-node-http": "5.20.4" + "@algolia/client-common": "5.25.0", + "@algolia/requester-browser-xhr": "5.25.0", + "@algolia/requester-fetch": "5.25.0", + "@algolia/requester-node-http": "5.25.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.20.4", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.20.4.tgz", - "integrity": "sha512-ZHsV0vceNDR87wIVaz7VjxilwCUCkzbuy4QnqIdnQs3NnC43is7KKbEtKueuNw+YGMdx+wmD5kRI2XKip1R93A==", + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.25.0.tgz", + "integrity": "sha512-a/W2z6XWKjKjIW1QQQV8PTTj1TXtaKx79uR3NGBdBdGvVdt24KzGAaN7sCr5oP8DW4D3cJt44wp2OY/fZcPAVA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.20.4", - "@algolia/requester-browser-xhr": "5.20.4", - "@algolia/requester-fetch": "5.20.4", - "@algolia/requester-node-http": "5.20.4" + "@algolia/client-common": "5.25.0", + "@algolia/requester-browser-xhr": "5.25.0", + "@algolia/requester-fetch": "5.25.0", + "@algolia/requester-node-http": "5.25.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.20.4", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.20.4.tgz", - "integrity": "sha512-hXM2LpwTzG5kGQSyq3feIijzzl6vkjYPP+LF3ru1relNUIh7fWJ4uYQay2NMNbWX5LWQzF8Vr9qlIA139doQXg==", + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.25.0.tgz", + "integrity": "sha512-9rUYcMIBOrCtYiLX49djyzxqdK9Dya/6Z/8sebPn94BekT+KLOpaZCuc6s0Fpfq7nx5J6YY5LIVFQrtioK9u0g==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.20.4", - "@algolia/requester-browser-xhr": "5.20.4", - "@algolia/requester-fetch": "5.20.4", - "@algolia/requester-node-http": "5.20.4" + "@algolia/client-common": "5.25.0", + "@algolia/requester-browser-xhr": "5.25.0", + "@algolia/requester-fetch": "5.25.0", + "@algolia/requester-node-http": "5.25.0" }, "engines": { "node": ">= 14.0.0" @@ -177,81 +177,81 @@ "license": "MIT" }, "node_modules/@algolia/ingestion": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.20.4.tgz", - "integrity": "sha512-idAe53XsTlLSSQ7pJcjscUEmc67vEM+VohYkr78Ebfb43vtfKH0ik8ux9OGQpLRNGntaHqpe/lfU5PDRi5/92w==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.25.0.tgz", + "integrity": "sha512-jJeH/Hk+k17Vkokf02lkfYE4A+EJX+UgnMhTLR/Mb+d1ya5WhE+po8p5a/Nxb6lo9OLCRl6w3Hmk1TX1e9gVbQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.20.4", - "@algolia/requester-browser-xhr": "5.20.4", - "@algolia/requester-fetch": "5.20.4", - "@algolia/requester-node-http": "5.20.4" + "@algolia/client-common": "5.25.0", + "@algolia/requester-browser-xhr": "5.25.0", + "@algolia/requester-fetch": "5.25.0", + "@algolia/requester-node-http": "5.25.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.20.4.tgz", - "integrity": "sha512-O6HjdSWtyu5LhHR7gdU83oWbl1vVVRwoTxkENHF61Ar7l9C1Ok91VtnK7RtXB9pJL1kpIMDExwZOT5sEN2Ppfw==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.25.0.tgz", + "integrity": "sha512-Ls3i1AehJ0C6xaHe7kK9vPmzImOn5zBg7Kzj8tRYIcmCWVyuuFwCIsbuIIz/qzUf1FPSWmw0TZrGeTumk2fqXg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.20.4", - "@algolia/requester-browser-xhr": "5.20.4", - "@algolia/requester-fetch": "5.20.4", - "@algolia/requester-node-http": "5.20.4" + "@algolia/client-common": "5.25.0", + "@algolia/requester-browser-xhr": "5.25.0", + "@algolia/requester-fetch": "5.25.0", + "@algolia/requester-node-http": "5.25.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.20.4", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.20.4.tgz", - "integrity": "sha512-p8M78pQjPrN6PudO2TnkWiOJbyp/IPhgCFBW8aZrLshhZpPkV9N4u0YsU/w6OoeYDKSxmXntWQrKYiU1dVRWfg==", + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.25.0.tgz", + "integrity": "sha512-79sMdHpiRLXVxSjgw7Pt4R1aNUHxFLHiaTDnN2MQjHwJ1+o3wSseb55T9VXU4kqy3m7TUme3pyRhLk5ip/S4Mw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.20.4", - "@algolia/requester-browser-xhr": "5.20.4", - "@algolia/requester-fetch": "5.20.4", - "@algolia/requester-node-http": "5.20.4" + "@algolia/client-common": "5.25.0", + "@algolia/requester-browser-xhr": "5.25.0", + "@algolia/requester-fetch": "5.25.0", + "@algolia/requester-node-http": "5.25.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.20.4", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.20.4.tgz", - "integrity": "sha512-Y8GThjDVdhFUurZKKDdzAML/LNKOA/BOydEcaFeb/g4Iv4Iq0qQJs6aIbtdsngUU6cu74qH/2P84kr2h16uVvQ==", + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.25.0.tgz", + "integrity": "sha512-JLaF23p1SOPBmfEqozUAgKHQrGl3z/Z5RHbggBu6s07QqXXcazEsub5VLonCxGVqTv6a61AAPr8J1G5HgGGjEw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.20.4" + "@algolia/client-common": "5.25.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.20.4", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.20.4.tgz", - "integrity": "sha512-OrAUSrvbFi46U7AxOXkyl9QQiaW21XWpixWmcx3D2S65P/DCIGOVE6K2741ZE+WiKIqp+RSYkyDFj3BiFHzLTg==", + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.25.0.tgz", + "integrity": "sha512-rtzXwqzFi1edkOF6sXxq+HhmRKDy7tz84u0o5t1fXwz0cwx+cjpmxu/6OQKTdOJFS92JUYHsG51Iunie7xbqfQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.20.4" + "@algolia/client-common": "5.25.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.20.4", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.20.4.tgz", - "integrity": "sha512-Jc/bofGBw4P9nBii4oCzCqqusv8DAFFORfUD2Ce1cZk3fvUPk+q/Qnu7i9JpTSHjMc0MWzqApLdq7Nwh1gelLg==", + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.25.0.tgz", + "integrity": "sha512-ZO0UKvDyEFvyeJQX0gmZDQEvhLZ2X10K+ps6hViMo1HgE2V8em00SwNsQ+7E/52a+YiBkVWX61pJJJE44juDMQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.20.4" + "@algolia/client-common": "5.25.0" }, "engines": { "node": ">= 14.0.0" @@ -270,13 +270,14 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -328,12 +329,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", - "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.3.tgz", + "integrity": "sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q==", + "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.2", - "@babel/types": "^7.26.0", + "@babel/parser": "^7.27.3", + "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -441,9 +443,10 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", - "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz", + "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==", + "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", @@ -468,12 +471,13 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -507,9 +511,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -572,17 +576,19 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -622,12 +628,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", - "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.4.tgz", + "integrity": "sha512-BRmLHGwpUqLFR2jzx9orBuX/ABDkj2jLKOXrHDTN2aOKL+jFDDKaRNo9nyYsIl9h/UE/7lMKdDjKQQyxKKDZ7g==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.10" + "@babel/types": "^7.27.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -1370,12 +1376,12 @@ } }, "node_modules/@babel/plugin-transform-react-constant-elements": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.25.9.tgz", - "integrity": "sha512-Ncw2JFsJVuvfRsa2lSHiC55kETQVLSnsYGQ1JDDwkUeWGTL/8Tom8aLTnlqgoeuopWrbbGndrc9AlLYrIosrow==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz", + "integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1490,15 +1496,15 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.26.9.tgz", - "integrity": "sha512-Jf+8y9wXQbbxvVYTM8gO5oEF2POdNji0NMltEkG7FtmzD9PVz7/lxpqSdTvwsjTMU5HIHuDVNf2SOxLkWi+wPQ==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.27.4.tgz", + "integrity": "sha512-D68nR5zxU64EUzV8i7T3R5XP0Xhrou/amNnddsRQssx6GrTLdZl1rLxyjtVZBd+v/NVX4AbTPOB5aU8thAZV1A==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-corejs3": "^0.11.0", "babel-plugin-polyfill-regenerator": "^0.6.1", "semver": "^6.3.1" }, @@ -1509,6 +1515,19 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", + "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3", + "core-js-compat": "^3.40.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -1819,42 +1838,42 @@ } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.26.10.tgz", - "integrity": "sha512-uITFQYO68pMEYR46AHgQoyBg7KPPJDAbGn4jUTIRgCFJIp88MIBUianVOplhZDEec07bp9zIyr4Kp0FCyQzmWg==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.27.4.tgz", + "integrity": "sha512-H7QhL0ucCGOObsUETNbB2PuzF4gAvN8p32P6r91bX7M/hk4bx+3yz2hTwHL9d/Efzwu1upeb4/cd7oSxCzup3w==", "license": "MIT", "dependencies": { - "core-js-pure": "^3.30.2", - "regenerator-runtime": "^0.14.0" + "core-js-pure": "^3.30.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", - "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", + "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/generator": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/template": "^7.25.9", - "@babel/types": "^7.25.9", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1863,13 +1882,13 @@ } }, "node_modules/@babel/types": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", - "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz", + "integrity": "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1886,9 +1905,9 @@ } }, "node_modules/@csstools/cascade-layer-name-parser": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.4.tgz", - "integrity": "sha512-7DFHlPuIxviKYZrOiwVU/PiHLm3lLUR23OMuEEtfEOQTOp9hzQ2JjdY6X5H18RVuUPJqSCI+qNnD5iOLMVE0bA==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz", + "integrity": "sha512-p1ko5eHgV+MgXFVa4STPKpvPxr6ReS8oS2jzTukjR74i5zJNyWO1ZM1m8YKBXnzDKWfBN1ztLYlHxbVemDD88A==", "funding": [ { "type": "github", @@ -1904,8 +1923,8 @@ "node": ">=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/color-helpers": { @@ -1928,9 +1947,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.2.tgz", - "integrity": "sha512-TklMyb3uBB28b5uQdxjReG4L80NxAqgrECqLZFQbyLekwwlcDDS8r3f07DKqeo8C4926Br0gf/ZDe17Zv4wIuw==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "funding": [ { "type": "github", @@ -1946,14 +1965,14 @@ "node": ">=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/css-color-parser": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.8.tgz", - "integrity": "sha512-pdwotQjCCnRPuNi06jFuP68cykU1f3ZWExLe/8MQ1LOs8Xq+fTkYgd+2V8mWUWMrOn9iS2HftPVaMZDaXzGbhQ==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", "funding": [ { "type": "github", @@ -1967,20 +1986,20 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^5.0.2", - "@csstools/css-calc": "^2.1.2" + "@csstools/css-calc": "^2.1.4" }, "engines": { "node": ">=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", - "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "funding": [ { "type": "github", @@ -1996,13 +2015,13 @@ "node": ">=18" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/css-tokenizer": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz", - "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "funding": [ { "type": "github", @@ -2019,9 +2038,9 @@ } }, "node_modules/@csstools/media-query-list-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.2.tgz", - "integrity": "sha512-EUos465uvVvMJehckATTlNqGj4UJWkTmdWuDMjqvSUkjGpmOyFZBVwb4knxCm/k2GMTXY+c/5RkdndzFYWeX5A==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", + "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", "funding": [ { "type": "github", @@ -2037,8 +2056,8 @@ "node": ">=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/postcss-cascade-layers": { @@ -2103,9 +2122,9 @@ } }, "node_modules/@csstools/postcss-color-function": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-4.0.8.tgz", - "integrity": "sha512-9dUvP2qpZI6PlGQ/sob+95B3u5u7nkYt9yhZFCC7G9HBRHBxj+QxS/wUlwaMGYW0waf+NIierI8aoDTssEdRYw==", + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-4.0.10.tgz", + "integrity": "sha512-4dY0NBu7NVIpzxZRgh/Q/0GPSz/jLSw0i/u3LTUor0BkQcz/fNhN10mSWBDsL0p9nDb0Ky1PD6/dcGbhACuFTQ==", "funding": [ { "type": "github", @@ -2118,10 +2137,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.8", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.0", + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2132,9 +2151,38 @@ } }, "node_modules/@csstools/postcss-color-mix-function": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-3.0.8.tgz", - "integrity": "sha512-yuZpgWUzqZWQhEqfvtJufhl28DgO9sBwSbXbf/59gejNuvZcoUTRGQZhzhwF4ccqb53YAGB+u92z9+eSKoB4YA==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-3.0.10.tgz", + "integrity": "sha512-P0lIbQW9I4ShE7uBgZRib/lMTf9XMjJkFl/d6w4EMNHu2qvQ6zljJGEcBkw/NsBtq/6q3WrmgxSS8kHtPMkK4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-color-mix-variadic-function-arguments": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-variadic-function-arguments/-/postcss-color-mix-variadic-function-arguments-1.0.0.tgz", + "integrity": "sha512-Z5WhouTyD74dPFPrVE7KydgNS9VvnjB8qcdes9ARpCOItb4jTnm7cHp4FhxCRUoyhabD0WVv43wbkJ4p8hLAlQ==", "funding": [ { "type": "github", @@ -2147,10 +2195,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.8", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.0", + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2161,9 +2209,9 @@ } }, "node_modules/@csstools/postcss-content-alt-text": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-content-alt-text/-/postcss-content-alt-text-2.0.4.tgz", - "integrity": "sha512-YItlZUOuZJCBlRaCf8Aucc1lgN41qYGALMly0qQllrxYJhiyzlI6RxOTMUvtWk+KhS8GphMDsDhKQ7KTPfEMSw==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@csstools/postcss-content-alt-text/-/postcss-content-alt-text-2.0.6.tgz", + "integrity": "sha512-eRjLbOjblXq+byyaedQRSrAejKGNAFued+LcbzT+LCL78fabxHkxYjBbxkroONxHHYu2qxhFK2dBStTLPG3jpQ==", "funding": [ { "type": "github", @@ -2176,9 +2224,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2189,9 +2237,9 @@ } }, "node_modules/@csstools/postcss-exponential-functions": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@csstools/postcss-exponential-functions/-/postcss-exponential-functions-2.0.7.tgz", - "integrity": "sha512-XTb6Mw0v2qXtQYRW9d9duAjDnoTbBpsngD7sRNLmYDjvwU2ebpIHplyxgOeo6jp/Kr52gkLi5VaK5RDCqzMzZQ==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-exponential-functions/-/postcss-exponential-functions-2.0.9.tgz", + "integrity": "sha512-abg2W/PI3HXwS/CZshSa79kNWNZHdJPMBXeZNyPQFbbj8sKO3jXxOt/wF7juJVjyDTc6JrvaUZYFcSBZBhaxjw==", "funding": [ { "type": "github", @@ -2204,9 +2252,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^2.1.2", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" }, "engines": { "node": ">=18" @@ -2242,9 +2290,9 @@ } }, "node_modules/@csstools/postcss-gamut-mapping": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-2.0.8.tgz", - "integrity": "sha512-/K8u9ZyGMGPjmwCSIjgaOLKfic2RIGdFHHes84XW5LnmrvdhOTVxo255NppHi3ROEvoHPW7MplMJgjZK5Q+TxA==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-2.0.10.tgz", + "integrity": "sha512-QDGqhJlvFnDlaPAfCYPsnwVA6ze+8hhrwevYWlnUeSjkkZfBpcCO42SaUD8jiLlq7niouyLgvup5lh+f1qessg==", "funding": [ { "type": "github", @@ -2257,9 +2305,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.8", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" }, "engines": { "node": ">=18" @@ -2269,9 +2317,9 @@ } }, "node_modules/@csstools/postcss-gradients-interpolation-method": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-5.0.8.tgz", - "integrity": "sha512-CoHQ/0UXrvxLovu0ZeW6c3/20hjJ/QRg6lyXm3dZLY/JgvRU6bdbQZF/Du30A4TvowfcgvIHQmP1bNXUxgDrAw==", + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-5.0.10.tgz", + "integrity": "sha512-HHPauB2k7Oits02tKFUeVFEU2ox/H3OQVrP3fSOKDxvloOikSal+3dzlyTZmYsb9FlY9p5EUpBtz0//XBmy+aw==", "funding": [ { "type": "github", @@ -2284,10 +2332,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.8", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.0", + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2298,9 +2346,9 @@ } }, "node_modules/@csstools/postcss-hwb-function": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-4.0.8.tgz", - "integrity": "sha512-LpFKjX6hblpeqyych1cKmk+3FJZ19QmaJtqincySoMkbkG/w2tfbnO5oE6mlnCTXcGUJ0rCEuRHvTqKK0nHYUQ==", + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-4.0.10.tgz", + "integrity": "sha512-nOKKfp14SWcdEQ++S9/4TgRKchooLZL0TUFdun3nI4KPwCjETmhjta1QT4ICQcGVWQTvrsgMM/aLB5We+kMHhQ==", "funding": [ { "type": "github", @@ -2313,10 +2361,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.8", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.0", + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2327,9 +2375,9 @@ } }, "node_modules/@csstools/postcss-ic-unit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-4.0.0.tgz", - "integrity": "sha512-9QT5TDGgx7wD3EEMN3BSUG6ckb6Eh5gSPT5kZoVtUuAonfPmLDJyPhqR4ntPpMYhUKAMVKAg3I/AgzqHMSeLhA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-4.0.2.tgz", + "integrity": "sha512-lrK2jjyZwh7DbxaNnIUjkeDmU8Y6KyzRBk91ZkI5h8nb1ykEfZrtIVArdIjX4DHMIBGpdHrgP0n4qXDr7OHaKA==", "funding": [ { "type": "github", @@ -2342,7 +2390,7 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, @@ -2437,9 +2485,9 @@ } }, "node_modules/@csstools/postcss-light-dark-function": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-2.0.7.tgz", - "integrity": "sha512-ZZ0rwlanYKOHekyIPaU+sVm3BEHCe+Ha0/px+bmHe62n0Uc1lL34vbwrLYn6ote8PHlsqzKeTQdIejQCJ05tfw==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-2.0.9.tgz", + "integrity": "sha512-1tCZH5bla0EAkFAI2r0H33CDnIBeLUaJh1p+hvvsylJ4svsv2wOmJjJn+OXwUZLXef37GYbRIVKX+X+g6m+3CQ==", "funding": [ { "type": "github", @@ -2452,9 +2500,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2556,9 +2604,9 @@ } }, "node_modules/@csstools/postcss-logical-viewport-units": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-3.0.3.tgz", - "integrity": "sha512-OC1IlG/yoGJdi0Y+7duz/kU/beCwO+Gua01sD6GtOtLi7ByQUpcIqs7UE/xuRPay4cHgOMatWdnDdsIDjnWpPw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-3.0.4.tgz", + "integrity": "sha512-q+eHV1haXA4w9xBwZLKjVKAWn3W2CMqmpNpZUk5kRprvSiBEGMgrNH3/sJZ8UA3JgyHaOt3jwT9uFa4wLX4EqQ==", "funding": [ { "type": "github", @@ -2571,7 +2619,7 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-tokenizer": "^3.0.3", + "@csstools/css-tokenizer": "^3.0.4", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2582,9 +2630,9 @@ } }, "node_modules/@csstools/postcss-media-minmax": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@csstools/postcss-media-minmax/-/postcss-media-minmax-2.0.7.tgz", - "integrity": "sha512-LB6tIP7iBZb5CYv8iRenfBZmbaG3DWNEziOnPjGoQX5P94FBPvvTBy68b/d9NnS5PELKwFmmOYsAEIgEhDPCHA==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-minmax/-/postcss-media-minmax-2.0.9.tgz", + "integrity": "sha512-af9Qw3uS3JhYLnCbqtZ9crTvvkR+0Se+bBqSr7ykAnl9yKhk6895z9rf+2F4dClIDJWxgn0iZZ1PSdkhrbs2ig==", "funding": [ { "type": "github", @@ -2597,10 +2645,10 @@ ], "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.2", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/media-query-list-parser": "^4.0.2" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3" }, "engines": { "node": ">=18" @@ -2610,9 +2658,9 @@ } }, "node_modules/@csstools/postcss-media-queries-aspect-ratio-number-values": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-3.0.4.tgz", - "integrity": "sha512-AnGjVslHMm5xw9keusQYvjVWvuS7KWK+OJagaG0+m9QnIjZsrysD2kJP/tr/UJIyYtMCtu8OkUd+Rajb4DqtIQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-3.0.5.tgz", + "integrity": "sha512-zhAe31xaaXOY2Px8IYfoVTB3wglbJUVigGphFLj6exb7cjZRH9A6adyE22XfFK3P2PzwRk0VDeTJmaxpluyrDg==", "funding": [ { "type": "github", @@ -2625,9 +2673,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/media-query-list-parser": "^4.0.2" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3" }, "engines": { "node": ">=18" @@ -2688,9 +2736,9 @@ } }, "node_modules/@csstools/postcss-oklab-function": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-4.0.8.tgz", - "integrity": "sha512-+5aPsNWgxohXoYNS1f+Ys0x3Qnfehgygv3qrPyv+Y25G0yX54/WlVB+IXprqBLOXHM1gsVF+QQSjlArhygna0Q==", + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-4.0.10.tgz", + "integrity": "sha512-ZzZUTDd0fgNdhv8UUjGCtObPD8LYxMH+MJsW9xlZaWTV8Ppr4PtxlHYNMmF4vVWGl0T6f8tyWAKjoI6vePSgAg==", "funding": [ { "type": "github", @@ -2703,10 +2751,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.8", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.0", + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2717,9 +2765,9 @@ } }, "node_modules/@csstools/postcss-progressive-custom-properties": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-4.0.0.tgz", - "integrity": "sha512-XQPtROaQjomnvLUSy/bALTR5VCtTVUFwYs1SblvYgLSeTo2a/bMNwUwo2piXw5rTv/FEYiy5yPSXBqg9OKUx7Q==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-4.1.0.tgz", + "integrity": "sha512-YrkI9dx8U4R8Sz2EJaoeD9fI7s7kmeEBfmO+UURNeL6lQI7VxF6sBE+rSqdCBn4onwqmxFdBU3lTwyYb/lCmxA==", "funding": [ { "type": "github", @@ -2742,9 +2790,9 @@ } }, "node_modules/@csstools/postcss-random-function": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-random-function/-/postcss-random-function-1.0.3.tgz", - "integrity": "sha512-dbNeEEPHxAwfQJ3duRL5IPpuD77QAHtRl4bAHRs0vOVhVbHrsL7mHnwe0irYjbs9kYwhAHZBQTLBgmvufPuRkA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-random-function/-/postcss-random-function-2.0.1.tgz", + "integrity": "sha512-q+FQaNiRBhnoSNo+GzqGOIBKoHQ43lYz0ICrV+UudfWnEF6ksS6DsBIJSISKQT2Bvu3g4k6r7t0zYrk5pDlo8w==", "funding": [ { "type": "github", @@ -2757,9 +2805,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^2.1.2", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" }, "engines": { "node": ">=18" @@ -2769,9 +2817,9 @@ } }, "node_modules/@csstools/postcss-relative-color-syntax": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-3.0.8.tgz", - "integrity": "sha512-eGE31oLnJDoUysDdjS9MLxNZdtqqSxjDXMdISpLh80QMaYrKs7VINpid34tWQ+iU23Wg5x76qAzf1Q/SLLbZVg==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-3.0.10.tgz", + "integrity": "sha512-8+0kQbQGg9yYG8hv0dtEpOMLwB9M+P7PhacgIzVzJpixxV4Eq9AUQtQw8adMmAJU1RBBmIlpmtmm3XTRd/T00g==", "funding": [ { "type": "github", @@ -2784,10 +2832,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.8", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.0", + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2836,9 +2884,9 @@ } }, "node_modules/@csstools/postcss-sign-functions": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-sign-functions/-/postcss-sign-functions-1.1.2.tgz", - "integrity": "sha512-4EcAvXTUPh7n6UoZZkCzgtCf/wPzMlTNuddcKg7HG8ozfQkUcHsJ2faQKeLmjyKdYPyOUn4YA7yDPf8K/jfIxw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/postcss-sign-functions/-/postcss-sign-functions-1.1.4.tgz", + "integrity": "sha512-P97h1XqRPcfcJndFdG95Gv/6ZzxUBBISem0IDqPZ7WMvc/wlO+yU0c5D/OCpZ5TJoTt63Ok3knGk64N+o6L2Pg==", "funding": [ { "type": "github", @@ -2851,9 +2899,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^2.1.2", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" }, "engines": { "node": ">=18" @@ -2863,9 +2911,9 @@ } }, "node_modules/@csstools/postcss-stepped-value-functions": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-4.0.7.tgz", - "integrity": "sha512-rdrRCKRnWtj5FyRin0u/gLla7CIvZRw/zMGI1fVJP0Sg/m1WGicjPVHRANL++3HQtsiXKAbPrcPr+VkyGck0IA==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-4.0.9.tgz", + "integrity": "sha512-h9btycWrsex4dNLeQfyU3y3w40LMQooJWFMm/SK9lrKguHDcFl4VMkncKKoXi2z5rM9YGWbUQABI8BT2UydIcA==", "funding": [ { "type": "github", @@ -2878,9 +2926,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^2.1.2", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" }, "engines": { "node": ">=18" @@ -2916,9 +2964,9 @@ } }, "node_modules/@csstools/postcss-trigonometric-functions": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-4.0.7.tgz", - "integrity": "sha512-qTrZgLju3AV7Djhzuh2Bq/wjFqbcypnk0FhHjxW8DWJQcZLS1HecIus4X2/RLch1ukX7b+YYCdqbEnpIQO5ccg==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-4.0.9.tgz", + "integrity": "sha512-Hnh5zJUdpNrJqK9v1/E3BbrQhaDTj5YiX7P61TOvUhoDHnUmsNNxcDAgkQ32RrcWx9GVUvfUNPcUkn8R3vIX6A==", "funding": [ { "type": "github", @@ -2931,9 +2979,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^2.1.2", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" }, "engines": { "node": ">=18" @@ -3034,9 +3082,9 @@ } }, "node_modules/@docusaurus/babel": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.7.0.tgz", - "integrity": "sha512-0H5uoJLm14S/oKV3Keihxvh8RV+vrid+6Gv+2qhuzbqHanawga8tYnsdpjEyt36ucJjqlby2/Md2ObWjA02UXQ==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.8.0.tgz", + "integrity": "sha512-9EJwSgS6TgB8IzGk1L8XddJLhZod8fXT4ULYMx6SKqyCBqCFpVCEjR/hNXXhnmtVM2irDuzYoVLGWv7srG/VOA==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.9", @@ -3049,8 +3097,8 @@ "@babel/runtime": "^7.25.9", "@babel/runtime-corejs3": "^7.25.9", "@babel/traverse": "^7.25.9", - "@docusaurus/logger": "3.7.0", - "@docusaurus/utils": "3.7.0", + "@docusaurus/logger": "3.8.0", + "@docusaurus/utils": "3.8.0", "babel-plugin-dynamic-import-node": "^2.3.3", "fs-extra": "^11.1.1", "tslib": "^2.6.0" @@ -3060,17 +3108,17 @@ } }, "node_modules/@docusaurus/bundler": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.7.0.tgz", - "integrity": "sha512-CUUT9VlSGukrCU5ctZucykvgCISivct+cby28wJwCC/fkQFgAHRp/GKv2tx38ZmXb7nacrKzFTcp++f9txUYGg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.8.0.tgz", + "integrity": "sha512-Rq4Z/MSeAHjVzBLirLeMcjLIAQy92pF1OI+2rmt18fSlMARfTGLWRE8Vb+ljQPTOSfJxwDYSzsK6i7XloD2rNA==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.9", - "@docusaurus/babel": "3.7.0", - "@docusaurus/cssnano-preset": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", + "@docusaurus/babel": "3.8.0", + "@docusaurus/cssnano-preset": "3.8.0", + "@docusaurus/logger": "3.8.0", + "@docusaurus/types": "3.8.0", + "@docusaurus/utils": "3.8.0", "babel-loader": "^9.2.1", "clean-css": "^5.3.2", "copy-webpack-plugin": "^11.0.0", @@ -3084,7 +3132,6 @@ "postcss": "^8.4.26", "postcss-loader": "^7.3.3", "postcss-preset-env": "^10.1.0", - "react-dev-utils": "^12.0.1", "terser-webpack-plugin": "^5.3.9", "tslib": "^2.6.0", "url-loader": "^4.1.1", @@ -3104,18 +3151,18 @@ } }, "node_modules/@docusaurus/core": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.7.0.tgz", - "integrity": "sha512-b0fUmaL+JbzDIQaamzpAFpTviiaU4cX3Qz8cuo14+HGBCwa0evEK0UYCBFY3n4cLzL8Op1BueeroUD2LYAIHbQ==", - "license": "MIT", - "dependencies": { - "@docusaurus/babel": "3.7.0", - "@docusaurus/bundler": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/mdx-loader": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.8.0.tgz", + "integrity": "sha512-c7u6zFELmSGPEP9WSubhVDjgnpiHgDqMh1qVdCB7rTflh4Jx0msTYmMiO91Ez0KtHj4sIsDsASnjwfJ2IZp3Vw==", + "license": "MIT", + "dependencies": { + "@docusaurus/babel": "3.8.0", + "@docusaurus/bundler": "3.8.0", + "@docusaurus/logger": "3.8.0", + "@docusaurus/mdx-loader": "3.8.0", + "@docusaurus/utils": "3.8.0", + "@docusaurus/utils-common": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "boxen": "^6.2.1", "chalk": "^4.1.2", "chokidar": "^3.5.3", @@ -3123,19 +3170,19 @@ "combine-promises": "^1.1.0", "commander": "^5.1.0", "core-js": "^3.31.1", - "del": "^6.1.1", "detect-port": "^1.5.1", "escape-html": "^1.0.3", "eta": "^2.2.0", "eval": "^0.1.8", + "execa": "5.1.1", "fs-extra": "^11.1.1", "html-tags": "^3.3.1", "html-webpack-plugin": "^5.6.0", "leven": "^3.1.0", "lodash": "^4.17.21", + "open": "^8.4.0", "p-map": "^4.0.0", "prompts": "^2.4.2", - "react-dev-utils": "^12.0.1", "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", "react-loadable": "npm:@docusaurus/react-loadable@6.0.0", "react-loadable-ssr-addon-v5-slorber": "^1.0.1", @@ -3144,7 +3191,7 @@ "react-router-dom": "^5.3.4", "semver": "^7.5.4", "serve-handler": "^6.1.6", - "shelljs": "^0.8.5", + "tinypool": "^1.0.2", "tslib": "^2.6.0", "update-notifier": "^6.0.2", "webpack": "^5.95.0", @@ -3165,9 +3212,9 @@ } }, "node_modules/@docusaurus/cssnano-preset": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.7.0.tgz", - "integrity": "sha512-X9GYgruZBSOozg4w4dzv9uOz8oK/EpPVQXkp0MM6Tsgp/nRIU9hJzJ0Pxg1aRa3xCeEQTOimZHcocQFlLwYajQ==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.8.0.tgz", + "integrity": "sha512-UJ4hAS2T0R4WNy+phwVff2Q0L5+RXW9cwlH6AEphHR5qw3m/yacfWcSK7ort2pMMbDn8uGrD38BTm4oLkuuNoQ==", "license": "MIT", "dependencies": { "cssnano-preset-advanced": "^6.1.2", @@ -3180,9 +3227,9 @@ } }, "node_modules/@docusaurus/logger": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.7.0.tgz", - "integrity": "sha512-z7g62X7bYxCYmeNNuO9jmzxLQG95q9QxINCwpboVcNff3SJiHJbGrarxxOVMVmAh1MsrSfxWkVGv4P41ktnFsA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.8.0.tgz", + "integrity": "sha512-7eEMaFIam5Q+v8XwGqF/n0ZoCld4hV4eCCgQkfcN9Mq5inoZa6PHHW9Wu6lmgzoK5Kx3keEeABcO2SxwraoPDQ==", "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -3193,21 +3240,21 @@ } }, "node_modules/@docusaurus/mdx-loader": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.7.0.tgz", - "integrity": "sha512-OFBG6oMjZzc78/U3WNPSHs2W9ZJ723ewAcvVJaqS0VgyeUfmzUV8f1sv+iUHA0DtwiR5T5FjOxj6nzEE8LY6VA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.8.0.tgz", + "integrity": "sha512-mDPSzssRnpjSdCGuv7z2EIAnPS1MHuZGTaRLwPn4oQwszu4afjWZ/60sfKjTnjBjI8Vl4OgJl2vMmfmiNDX4Ng==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/logger": "3.8.0", + "@docusaurus/utils": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "@mdx-js/mdx": "^3.0.0", "@slorber/remark-comment": "^1.0.0", "escape-html": "^1.0.3", "estree-util-value-to-estree": "^3.0.1", "file-loader": "^6.2.0", "fs-extra": "^11.1.1", - "image-size": "^1.0.2", + "image-size": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-to-string": "^4.0.0", "rehype-raw": "^7.0.0", @@ -3232,17 +3279,17 @@ } }, "node_modules/@docusaurus/module-type-aliases": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.7.0.tgz", - "integrity": "sha512-g7WdPqDNaqA60CmBrr0cORTrsOit77hbsTj7xE2l71YhBn79sxdm7WMK7wfhcaafkbpIh7jv5ef5TOpf1Xv9Lg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.8.0.tgz", + "integrity": "sha512-/uMb4Ipt5J/QnD13MpnoC/A4EYAe6DKNWqTWLlGrqsPJwJv73vSwkA25xnYunwfqWk0FlUQfGv/Swdh5eCCg7g==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.7.0", + "@docusaurus/types": "3.8.0", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", "@types/react-router-dom": "*", - "react-helmet-async": "npm:@slorber/react-helmet-async@*", + "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", "react-loadable": "npm:@docusaurus/react-loadable@6.0.0" }, "peerDependencies": { @@ -3251,16 +3298,16 @@ } }, "node_modules/@docusaurus/plugin-client-redirects": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.7.0.tgz", - "integrity": "sha512-6B4XAtE5ZVKOyhPgpgMkb7LwCkN+Hgd4vOnlbwR8nCdTQhLjz8MHbGlwwvZ/cay2SPNRX5KssqKAlcHVZP2m8g==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.8.0.tgz", + "integrity": "sha512-J8f5qzAlO61BnG1I91+N5WH1b/lPWqn6ifTxf/Bluz9JVe1bhFNSl0yW03p+Ff3AFOINDy2ofX70al9nOnOLyw==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.0", + "@docusaurus/logger": "3.8.0", + "@docusaurus/utils": "3.8.0", + "@docusaurus/utils-common": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "eta": "^2.2.0", "fs-extra": "^11.1.1", "lodash": "^4.17.21", @@ -3275,24 +3322,24 @@ } }, "node_modules/@docusaurus/plugin-content-blog": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.7.0.tgz", - "integrity": "sha512-EFLgEz6tGHYWdPU0rK8tSscZwx+AsyuBW/r+tNig2kbccHYGUJmZtYN38GjAa3Fda4NU+6wqUO5kTXQSRBQD3g==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/mdx-loader": "3.7.0", - "@docusaurus/theme-common": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.8.0.tgz", + "integrity": "sha512-0SlOTd9R55WEr1GgIXu+hhTT0hzARYx3zIScA5IzpdekZQesI/hKEa5LPHBd415fLkWMjdD59TaW/3qQKpJ0Lg==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.8.0", + "@docusaurus/logger": "3.8.0", + "@docusaurus/mdx-loader": "3.8.0", + "@docusaurus/theme-common": "3.8.0", + "@docusaurus/types": "3.8.0", + "@docusaurus/utils": "3.8.0", + "@docusaurus/utils-common": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "cheerio": "1.0.0-rc.12", "feed": "^4.2.2", "fs-extra": "^11.1.1", "lodash": "^4.17.21", - "reading-time": "^1.5.0", + "schema-dts": "^1.1.2", "srcset": "^4.0.0", "tslib": "^2.6.0", "unist-util-visit": "^5.0.0", @@ -3309,25 +3356,26 @@ } }, "node_modules/@docusaurus/plugin-content-docs": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.7.0.tgz", - "integrity": "sha512-GXg5V7kC9FZE4FkUZA8oo/NrlRb06UwuICzI6tcbzj0+TVgjq/mpUXXzSgKzMS82YByi4dY2Q808njcBCyy6tQ==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/mdx-loader": "3.7.0", - "@docusaurus/module-type-aliases": "3.7.0", - "@docusaurus/theme-common": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.0.tgz", + "integrity": "sha512-fRDMFLbUN6eVRXcjP8s3Y7HpAt9pzPYh1F/7KKXOCxvJhjjCtbon4VJW0WndEPInVz4t8QUXn5QZkU2tGVCE2g==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.8.0", + "@docusaurus/logger": "3.8.0", + "@docusaurus/mdx-loader": "3.8.0", + "@docusaurus/module-type-aliases": "3.8.0", + "@docusaurus/theme-common": "3.8.0", + "@docusaurus/types": "3.8.0", + "@docusaurus/utils": "3.8.0", + "@docusaurus/utils-common": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "@types/react-router-config": "^5.0.7", "combine-promises": "^1.1.0", "fs-extra": "^11.1.1", "js-yaml": "^4.1.0", "lodash": "^4.17.21", + "schema-dts": "^1.1.2", "tslib": "^2.6.0", "utility-types": "^3.10.0", "webpack": "^5.88.1" @@ -3341,16 +3389,16 @@ } }, "node_modules/@docusaurus/plugin-content-pages": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.7.0.tgz", - "integrity": "sha512-YJSU3tjIJf032/Aeao8SZjFOrXJbz/FACMveSMjLyMH4itQyZ2XgUIzt4y+1ISvvk5zrW4DABVT2awTCqBkx0Q==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.8.0.tgz", + "integrity": "sha512-39EDx2y1GA0Pxfion5tQZLNJxL4gq6susd1xzetVBjVIQtwpCdyloOfQBAgX0FylqQxfJrYqL0DIUuq7rd7uBw==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/mdx-loader": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.0", + "@docusaurus/mdx-loader": "3.8.0", + "@docusaurus/types": "3.8.0", + "@docusaurus/utils": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "fs-extra": "^11.1.1", "tslib": "^2.6.0", "webpack": "^5.88.1" @@ -3363,48 +3411,51 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@docusaurus/plugin-debug": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.7.0.tgz", - "integrity": "sha512-Qgg+IjG/z4svtbCNyTocjIwvNTNEwgRjSXXSJkKVG0oWoH0eX/HAPiu+TS1HBwRPQV+tTYPWLrUypYFepfujZA==", + "node_modules/@docusaurus/plugin-css-cascade-layers": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.8.0.tgz", + "integrity": "sha512-/VBTNymPIxQB8oA3ZQ4GFFRYdH4ZxDRRBECxyjRyv486mfUPXfcdk+im4S5mKWa6EK2JzBz95IH/Wu0qQgJ5yQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", - "fs-extra": "^11.1.1", - "react-json-view-lite": "^1.2.0", + "@docusaurus/core": "3.8.0", + "@docusaurus/types": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "tslib": "^2.6.0" }, "engines": { "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@docusaurus/plugin-debug/node_modules/react-json-view-lite": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-1.5.0.tgz", - "integrity": "sha512-nWqA1E4jKPklL2jvHWs6s+7Na0qNgw9HCP6xehdQJeg6nPBTFZgGwyko9Q0oj+jQWKTTVRS30u0toM5wiuL3iw==", + "node_modules/@docusaurus/plugin-debug": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.8.0.tgz", + "integrity": "sha512-teonJvJsDB9o2OnG6ifbhblg/PXzZvpUKHFgD8dOL1UJ58u0lk8o0ZOkvaYEBa9nDgqzoWrRk9w+e3qaG2mOhQ==", "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.8.0", + "@docusaurus/types": "3.8.0", + "@docusaurus/utils": "3.8.0", + "fs-extra": "^11.1.1", + "react-json-view-lite": "^2.3.0", + "tslib": "^2.6.0" + }, "engines": { - "node": ">=14" + "node": ">=18.0" }, "peerDependencies": { - "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" } }, "node_modules/@docusaurus/plugin-google-analytics": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.7.0.tgz", - "integrity": "sha512-otIqiRV/jka6Snjf+AqB360XCeSv7lQC+DKYW+EUZf6XbuE8utz5PeUQ8VuOcD8Bk5zvT1MC4JKcd5zPfDuMWA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.8.0.tgz", + "integrity": "sha512-aKKa7Q8+3xRSRESipNvlFgNp3FNPELKhuo48Cg/svQbGNwidSHbZT03JqbW4cBaQnyyVchO1ttk+kJ5VC9Gx0w==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.0", + "@docusaurus/types": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "tslib": "^2.6.0" }, "engines": { @@ -3416,14 +3467,14 @@ } }, "node_modules/@docusaurus/plugin-google-gtag": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.7.0.tgz", - "integrity": "sha512-M3vrMct1tY65ModbyeDaMoA+fNJTSPe5qmchhAbtqhDD/iALri0g9LrEpIOwNaoLmm6lO88sfBUADQrSRSGSWA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.8.0.tgz", + "integrity": "sha512-ugQYMGF4BjbAW/JIBtVcp+9eZEgT9HRdvdcDudl5rywNPBA0lct+lXMG3r17s02rrhInMpjMahN3Yc9Cb3H5/g==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.0", + "@docusaurus/types": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "@types/gtag.js": "^0.0.12", "tslib": "^2.6.0" }, @@ -3436,14 +3487,14 @@ } }, "node_modules/@docusaurus/plugin-google-tag-manager": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.7.0.tgz", - "integrity": "sha512-X8U78nb8eiMiPNg3jb9zDIVuuo/rE1LjGDGu+5m5CX4UBZzjMy+klOY2fNya6x8ACyE/L3K2erO1ErheP55W/w==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.8.0.tgz", + "integrity": "sha512-9juRWxbwZD3SV02Jd9QB6yeN7eu+7T4zB0bvJLcVQwi+am51wAxn2CwbdL0YCCX+9OfiXbADE8D8Q65Hbopu/w==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.0", + "@docusaurus/types": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "tslib": "^2.6.0" }, "engines": { @@ -3455,17 +3506,17 @@ } }, "node_modules/@docusaurus/plugin-sitemap": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.7.0.tgz", - "integrity": "sha512-bTRT9YLZ/8I/wYWKMQke18+PF9MV8Qub34Sku6aw/vlZ/U+kuEuRpQ8bTcNOjaTSfYsWkK4tTwDMHK2p5S86cA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.8.0.tgz", + "integrity": "sha512-fGpOIyJvNiuAb90nSJ2Gfy/hUOaDu6826e5w5UxPmbpCIc7KlBHNAZ5g4L4ZuHhc4hdfq4mzVBsQSnne+8Ze1g==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.0", + "@docusaurus/logger": "3.8.0", + "@docusaurus/types": "3.8.0", + "@docusaurus/utils": "3.8.0", + "@docusaurus/utils-common": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "fs-extra": "^11.1.1", "sitemap": "^7.1.1", "tslib": "^2.6.0" @@ -3479,15 +3530,15 @@ } }, "node_modules/@docusaurus/plugin-svgr": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.7.0.tgz", - "integrity": "sha512-HByXIZTbc4GV5VAUkZ2DXtXv1Qdlnpk3IpuImwSnEzCDBkUMYcec5282hPjn6skZqB25M1TYCmWS91UbhBGxQg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.8.0.tgz", + "integrity": "sha512-kEDyry+4OMz6BWLG/lEqrNsL/w818bywK70N1gytViw4m9iAmoxCUT7Ri9Dgs7xUdzCHJ3OujolEmD88Wy44OA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.0", + "@docusaurus/types": "3.8.0", + "@docusaurus/utils": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "@svgr/core": "8.1.0", "@svgr/webpack": "^8.1.0", "tslib": "^2.6.0", @@ -3502,25 +3553,26 @@ } }, "node_modules/@docusaurus/preset-classic": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.7.0.tgz", - "integrity": "sha512-nPHj8AxDLAaQXs+O6+BwILFuhiWbjfQWrdw2tifOClQoNfuXDjfjogee6zfx6NGHWqshR23LrcN115DmkHC91Q==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/plugin-content-blog": "3.7.0", - "@docusaurus/plugin-content-docs": "3.7.0", - "@docusaurus/plugin-content-pages": "3.7.0", - "@docusaurus/plugin-debug": "3.7.0", - "@docusaurus/plugin-google-analytics": "3.7.0", - "@docusaurus/plugin-google-gtag": "3.7.0", - "@docusaurus/plugin-google-tag-manager": "3.7.0", - "@docusaurus/plugin-sitemap": "3.7.0", - "@docusaurus/plugin-svgr": "3.7.0", - "@docusaurus/theme-classic": "3.7.0", - "@docusaurus/theme-common": "3.7.0", - "@docusaurus/theme-search-algolia": "3.7.0", - "@docusaurus/types": "3.7.0" + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.8.0.tgz", + "integrity": "sha512-qOu6tQDOWv+rpTlKu+eJATCJVGnABpRCPuqf7LbEaQ1mNY//N/P8cHQwkpAU+aweQfarcZ0XfwCqRHJfjeSV/g==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.8.0", + "@docusaurus/plugin-content-blog": "3.8.0", + "@docusaurus/plugin-content-docs": "3.8.0", + "@docusaurus/plugin-content-pages": "3.8.0", + "@docusaurus/plugin-css-cascade-layers": "3.8.0", + "@docusaurus/plugin-debug": "3.8.0", + "@docusaurus/plugin-google-analytics": "3.8.0", + "@docusaurus/plugin-google-gtag": "3.8.0", + "@docusaurus/plugin-google-tag-manager": "3.8.0", + "@docusaurus/plugin-sitemap": "3.8.0", + "@docusaurus/plugin-svgr": "3.8.0", + "@docusaurus/theme-classic": "3.8.0", + "@docusaurus/theme-common": "3.8.0", + "@docusaurus/theme-search-algolia": "3.8.0", + "@docusaurus/types": "3.8.0" }, "engines": { "node": ">=18.0" @@ -3531,24 +3583,24 @@ } }, "node_modules/@docusaurus/theme-classic": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.7.0.tgz", - "integrity": "sha512-MnLxG39WcvLCl4eUzHr0gNcpHQfWoGqzADCly54aqCofQX6UozOS9Th4RK3ARbM9m7zIRv3qbhggI53dQtx/hQ==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/mdx-loader": "3.7.0", - "@docusaurus/module-type-aliases": "3.7.0", - "@docusaurus/plugin-content-blog": "3.7.0", - "@docusaurus/plugin-content-docs": "3.7.0", - "@docusaurus/plugin-content-pages": "3.7.0", - "@docusaurus/theme-common": "3.7.0", - "@docusaurus/theme-translations": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.8.0.tgz", + "integrity": "sha512-nQWFiD5ZjoT76OaELt2n33P3WVuuCz8Dt5KFRP2fCBo2r9JCLsp2GJjZpnaG24LZ5/arRjv4VqWKgpK0/YLt7g==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.8.0", + "@docusaurus/logger": "3.8.0", + "@docusaurus/mdx-loader": "3.8.0", + "@docusaurus/module-type-aliases": "3.8.0", + "@docusaurus/plugin-content-blog": "3.8.0", + "@docusaurus/plugin-content-docs": "3.8.0", + "@docusaurus/plugin-content-pages": "3.8.0", + "@docusaurus/theme-common": "3.8.0", + "@docusaurus/theme-translations": "3.8.0", + "@docusaurus/types": "3.8.0", + "@docusaurus/utils": "3.8.0", + "@docusaurus/utils-common": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "copy-text-to-clipboard": "^3.2.0", @@ -3572,15 +3624,15 @@ } }, "node_modules/@docusaurus/theme-common": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.7.0.tgz", - "integrity": "sha512-8eJ5X0y+gWDsURZnBfH0WabdNm8XMCXHv8ENy/3Z/oQKwaB/EHt5lP9VsTDTf36lKEp0V6DjzjFyFIB+CetL0A==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.8.0.tgz", + "integrity": "sha512-YqV2vAWpXGLA+A3PMLrOMtqgTHJLDcT+1Caa6RF7N4/IWgrevy5diY8oIHFkXR/eybjcrFFjUPrHif8gSGs3Tw==", "license": "MIT", "dependencies": { - "@docusaurus/mdx-loader": "3.7.0", - "@docusaurus/module-type-aliases": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", + "@docusaurus/mdx-loader": "3.8.0", + "@docusaurus/module-type-aliases": "3.8.0", + "@docusaurus/utils": "3.8.0", + "@docusaurus/utils-common": "3.8.0", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -3600,19 +3652,19 @@ } }, "node_modules/@docusaurus/theme-search-algolia": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.7.0.tgz", - "integrity": "sha512-Al/j5OdzwRU1m3falm+sYy9AaB93S1XF1Lgk9Yc6amp80dNxJVplQdQTR4cYdzkGtuQqbzUA8+kaoYYO0RbK6g==", - "license": "MIT", - "dependencies": { - "@docsearch/react": "^3.8.1", - "@docusaurus/core": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/plugin-content-docs": "3.7.0", - "@docusaurus/theme-common": "3.7.0", - "@docusaurus/theme-translations": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.8.0.tgz", + "integrity": "sha512-GBZ5UOcPgiu6nUw153+0+PNWvFKweSnvKIL6Rp04H9olKb475jfKjAwCCtju5D2xs5qXHvCMvzWOg5o9f6DtuQ==", + "license": "MIT", + "dependencies": { + "@docsearch/react": "^3.9.0", + "@docusaurus/core": "3.8.0", + "@docusaurus/logger": "3.8.0", + "@docusaurus/plugin-content-docs": "3.8.0", + "@docusaurus/theme-common": "3.8.0", + "@docusaurus/theme-translations": "3.8.0", + "@docusaurus/utils": "3.8.0", + "@docusaurus/utils-validation": "3.8.0", "algoliasearch": "^5.17.1", "algoliasearch-helper": "^3.22.6", "clsx": "^2.0.0", @@ -3631,9 +3683,9 @@ } }, "node_modules/@docusaurus/theme-translations": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.7.0.tgz", - "integrity": "sha512-Ewq3bEraWDmienM6eaNK7fx+/lHMtGDHQyd1O+4+3EsDxxUmrzPkV7Ct3nBWTuE0MsoZr3yNwQVKjllzCMuU3g==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.8.0.tgz", + "integrity": "sha512-1DTy/snHicgkCkryWq54fZvsAglTdjTx4qjOXgqnXJ+DIty1B+aPQrAVUu8LiM+6BiILfmNxYsxhKTj+BS3PZg==", "license": "MIT", "dependencies": { "fs-extra": "^11.1.1", @@ -3644,9 +3696,9 @@ } }, "node_modules/@docusaurus/types": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.7.0.tgz", - "integrity": "sha512-kOmZg5RRqJfH31m+6ZpnwVbkqMJrPOG5t0IOl4i/+3ruXyNfWzZ0lVtVrD0u4ONc/0NOsS9sWYaxxWNkH1LdLQ==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.8.0.tgz", + "integrity": "sha512-RDEClpwNxZq02c+JlaKLWoS13qwWhjcNsi2wG1UpzmEnuti/z1Wx4SGpqbUqRPNSd8QWWePR8Cb7DvG0VN/TtA==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.0.0", @@ -3679,15 +3731,16 @@ } }, "node_modules/@docusaurus/utils": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.7.0.tgz", - "integrity": "sha512-e7zcB6TPnVzyUaHMJyLSArKa2AG3h9+4CfvKXKKWNx6hRs+p0a+u7HHTJBgo6KW2m+vqDnuIHK4X+bhmoghAFA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.8.0.tgz", + "integrity": "sha512-2wvtG28ALCN/A1WCSLxPASFBFzXCnP0YKCAFIPcvEb6imNu1wg7ni/Svcp71b3Z2FaOFFIv4Hq+j4gD7gA0yfQ==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils-common": "3.7.0", + "@docusaurus/logger": "3.8.0", + "@docusaurus/types": "3.8.0", + "@docusaurus/utils-common": "3.8.0", "escape-string-regexp": "^4.0.0", + "execa": "5.1.1", "file-loader": "^6.2.0", "fs-extra": "^11.1.1", "github-slugger": "^1.5.0", @@ -3697,9 +3750,9 @@ "js-yaml": "^4.1.0", "lodash": "^4.17.21", "micromatch": "^4.0.5", + "p-queue": "^6.6.2", "prompts": "^2.4.2", "resolve-pathname": "^3.0.0", - "shelljs": "^0.8.5", "tslib": "^2.6.0", "url-loader": "^4.1.1", "utility-types": "^3.10.0", @@ -3710,12 +3763,12 @@ } }, "node_modules/@docusaurus/utils-common": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.7.0.tgz", - "integrity": "sha512-IZeyIfCfXy0Mevj6bWNg7DG7B8G+S6o6JVpddikZtWyxJguiQ7JYr0SIZ0qWd8pGNuMyVwriWmbWqMnK7Y5PwA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.8.0.tgz", + "integrity": "sha512-3TGF+wVTGgQ3pAc9+5jVchES4uXUAhAt9pwv7uws4mVOxL4alvU3ue/EZ+R4XuGk94pDy7CNXjRXpPjlfZXQfw==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.7.0", + "@docusaurus/types": "3.8.0", "tslib": "^2.6.0" }, "engines": { @@ -3723,14 +3776,14 @@ } }, "node_modules/@docusaurus/utils-validation": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.7.0.tgz", - "integrity": "sha512-w8eiKk8mRdN+bNfeZqC4nyFoxNyI1/VExMKAzD9tqpJfLLbsa46Wfn5wcKH761g9WkKh36RtFV49iL9lh1DYBA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.8.0.tgz", + "integrity": "sha512-MrnEbkigr54HkdFeg8e4FKc4EF+E9dlVwsY3XQZsNkbv3MKZnbHQ5LsNJDIKDROFe8PBf5C4qCAg5TPBpsjrjg==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", + "@docusaurus/logger": "3.8.0", + "@docusaurus/utils": "3.8.0", + "@docusaurus/utils-common": "3.8.0", "fs-extra": "^11.2.0", "joi": "^17.9.2", "js-yaml": "^4.1.0", @@ -4531,12 +4584,6 @@ "@types/node": "*" } }, - "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" - }, "node_modules/@types/prismjs": { "version": "1.26.4", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.4.tgz", @@ -4969,33 +5016,33 @@ } }, "node_modules/algoliasearch": { - "version": "5.20.4", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.20.4.tgz", - "integrity": "sha512-wjfzqruxovJyDqga8M6Xk5XtfuVg3igrWjhjgkRya87+WwfEa1kg+IluujBLzgAiMSd6rO6jqRb7czjgeeSYgQ==", - "license": "MIT", - "dependencies": { - "@algolia/client-abtesting": "5.20.4", - "@algolia/client-analytics": "5.20.4", - "@algolia/client-common": "5.20.4", - "@algolia/client-insights": "5.20.4", - "@algolia/client-personalization": "5.20.4", - "@algolia/client-query-suggestions": "5.20.4", - "@algolia/client-search": "5.20.4", - "@algolia/ingestion": "1.20.4", - "@algolia/monitoring": "1.20.4", - "@algolia/recommend": "5.20.4", - "@algolia/requester-browser-xhr": "5.20.4", - "@algolia/requester-fetch": "5.20.4", - "@algolia/requester-node-http": "5.20.4" + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.25.0.tgz", + "integrity": "sha512-n73BVorL4HIwKlfJKb4SEzAYkR3Buwfwbh+MYxg2mloFph2fFGV58E90QTzdbfzWrLn4HE5Czx/WTjI8fcHaMg==", + "license": "MIT", + "dependencies": { + "@algolia/client-abtesting": "5.25.0", + "@algolia/client-analytics": "5.25.0", + "@algolia/client-common": "5.25.0", + "@algolia/client-insights": "5.25.0", + "@algolia/client-personalization": "5.25.0", + "@algolia/client-query-suggestions": "5.25.0", + "@algolia/client-search": "5.25.0", + "@algolia/ingestion": "1.25.0", + "@algolia/monitoring": "1.25.0", + "@algolia/recommend": "5.25.0", + "@algolia/requester-browser-xhr": "5.25.0", + "@algolia/requester-fetch": "5.25.0", + "@algolia/requester-node-http": "5.25.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/algoliasearch-helper": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.24.2.tgz", - "integrity": "sha512-vBw/INZDfyh/THbVeDy8On8lZqd2qiUAHde5N4N1ygL4SoeLqLGJ4GHneHrDAYsjikRwTTtodEP0fiXl5MxHFQ==", + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.25.0.tgz", + "integrity": "sha512-vQoK43U6HXA9/euCqLjvyNdM4G2Fiu/VFp4ae0Gau9sZeIKBPvUPnXfLYAe65Bg7PFuw03coeu5K6lTPSXRObw==", "license": "MIT", "dependencies": { "@algolia/events": "^4.0.1" @@ -5141,19 +5188,10 @@ "astring": "bin/astring" } }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "license": "ISC", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", "funding": [ { "type": "opencollective", @@ -5170,11 +5208,11 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", + "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -5403,9 +5441,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", + "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", "funding": [ { "type": "opencollective", @@ -5422,10 +5460,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", + "caniuse-lite": "^1.0.30001718", + "electron-to-chromium": "^1.5.160", "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -5564,9 +5602,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001702", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz", - "integrity": "sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA==", + "version": "1.0.30001720", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001720.tgz", + "integrity": "sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==", "funding": [ { "type": "opencollective", @@ -6010,9 +6048,9 @@ } }, "node_modules/consola": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz", - "integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -6147,11 +6185,12 @@ } }, "node_modules/core-js-compat": { - "version": "3.39.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", - "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", + "version": "3.42.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz", + "integrity": "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ==", + "license": "MIT", "dependencies": { - "browserslist": "^4.24.2" + "browserslist": "^4.24.4" }, "funding": { "type": "opencollective", @@ -6159,9 +6198,9 @@ } }, "node_modules/core-js-pure": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.41.0.tgz", - "integrity": "sha512-71Gzp96T9YPk63aUvE5Q5qP+DryB4ZloUZPSOebGM88VNw8VNfvdA7z6kGA8iGOTEzAomsRidp4jXSmUIJsL+Q==", + "version": "3.42.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.42.0.tgz", + "integrity": "sha512-007bM04u91fF4kMgwom2I5cQxAFIy8jVulgr9eozILl/SZE53QOqnW/+vviC+wQWLv+AunBG+8Q0TLoeSsSxRQ==", "hasInstallScript": true, "license": "MIT", "funding": { @@ -6493,9 +6532,9 @@ } }, "node_modules/cssdb": { - "version": "8.2.3", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.2.3.tgz", - "integrity": "sha512-9BDG5XmJrJQQnJ51VFxXCAtpZ5ebDlAREmO8sxMOVU0aSxN/gocbctjIG5LMh3WBUq+xTlb/jw2LoljBEqraTA==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.3.0.tgz", + "integrity": "sha512-c7bmItIg38DgGjSwDPZOYF/2o0QU/sSgkWOMyl8votOfgFuyiFKWPesmCGEsrGLxEA9uL540cp8LdaGEjUGsZQ==", "funding": [ { "type": "opencollective", @@ -6726,6 +6765,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -6794,28 +6834,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/del": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", - "integrity": "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==", - "license": "MIT", - "dependencies": { - "globby": "^11.0.1", - "graceful-fs": "^4.2.4", - "is-glob": "^4.0.1", - "is-path-cwd": "^2.2.0", - "is-path-inside": "^3.0.2", - "p-map": "^4.0.0", - "rimraf": "^3.0.2", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6866,38 +6884,6 @@ "node": ">= 4.0.0" } }, - "node_modules/detect-port-alt": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", - "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", - "license": "MIT", - "dependencies": { - "address": "^1.0.1", - "debug": "^2.6.0" - }, - "bin": { - "detect": "bin/detect-port", - "detect-port": "bin/detect-port" - }, - "engines": { - "node": ">= 4.2.1" - } - }, - "node_modules/detect-port-alt/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/detect-port-alt/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -7067,9 +7053,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.112", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.112.tgz", - "integrity": "sha512-oen93kVyqSb3l+ziUgzIOlWt/oOuy4zRmpwestMn4rhFWAoFJeFuCVte9F2fASjeZZo7l/Cif9TiyrdW4CwEMA==", + "version": "1.5.161", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.161.tgz", + "integrity": "sha512-hwtetwfKNZo/UlwHIVBlKZVdy7o8bIZxxKs0Mv/ROPiQQQmDgdm5a+KvKtBsxM8ZjFzTaCeLoodZ8jiBE3o9rA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -7702,15 +7688,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/filesize": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", - "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -7815,134 +7792,6 @@ } } }, - "node_modules/fork-ts-checker-webpack-plugin": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.3.tgz", - "integrity": "sha512-SbH/l9ikmMWycd5puHJKTkZJKddF4iRLyW3DeZ08HTI7NGyLS38MXd/KGgeWumQO7YNQbW2u/NtPT2YowbPaGQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.8.3", - "@types/json-schema": "^7.0.5", - "chalk": "^4.1.0", - "chokidar": "^3.4.2", - "cosmiconfig": "^6.0.0", - "deepmerge": "^4.2.2", - "fs-extra": "^9.0.0", - "glob": "^7.1.6", - "memfs": "^3.1.2", - "minimatch": "^3.0.4", - "schema-utils": "2.7.0", - "semver": "^7.3.2", - "tapable": "^1.0.0" - }, - "engines": { - "node": ">=10", - "yarn": ">=1.0.0" - }, - "peerDependencies": { - "eslint": ">= 6", - "typescript": ">= 2.7", - "vue-template-compiler": "*", - "webpack": ">= 4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - }, - "vue-template-compiler": { - "optional": true - } - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", - "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", - "license": "MIT", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.1.0", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.7.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", - "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.4", - "ajv": "^6.12.2", - "ajv-keywords": "^3.4.1" - }, - "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/tapable": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", - "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/form-data-encoder": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", @@ -8152,55 +8001,17 @@ "engines": { "node": ">=10" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/global-dirs/node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/global-modules": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", - "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", - "license": "MIT", - "dependencies": { - "global-prefix": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/global-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", - "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", - "license": "MIT", - "dependencies": { - "ini": "^1.3.5", - "kind-of": "^6.0.2", - "which": "^1.3.1" - }, - "engines": { - "node": ">=6" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/global-prefix/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "node_modules/global-dirs/node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" + "engines": { + "node": ">=10" } }, "node_modules/globals": { @@ -8958,13 +8769,10 @@ } }, "node_modules/image-size": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", - "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", "license": "MIT", - "dependencies": { - "queue": "6.0.2" - }, "bin": { "image-size": "bin/image-size.js" }, @@ -8972,16 +8780,6 @@ "node": ">=16.x" } }, - "node_modules/immer": { - "version": "9.0.21", - "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", - "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -9059,14 +8857,6 @@ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==" }, - "node_modules/interpret": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", - "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -9264,15 +9054,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-path-cwd": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", - "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -9321,15 +9102,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-root": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", - "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -12355,6 +12127,15 @@ "node": ">=12.20" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", @@ -12400,6 +12181,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -12413,13 +12210,16 @@ "node": ">=8" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, "engines": { - "node": ">=6" + "node": ">=8" } }, "node_modules/package-json": { @@ -12645,79 +12445,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "license": "MIT", - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-up/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "license": "MIT", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "license": "MIT", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-up/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/postcss": { "version": "8.4.39", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", @@ -12815,9 +12542,9 @@ } }, "node_modules/postcss-color-functional-notation": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-7.0.8.tgz", - "integrity": "sha512-S/TpMKVKofNvsxfau/+bw+IA6cSfB6/kmzFj5szUofHOVnFFMB2WwK+Zu07BeMD8T0n+ZnTO5uXiMvAKe2dPkA==", + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-7.0.10.tgz", + "integrity": "sha512-k9qX+aXHBiLTRrWoCJuUFI6F1iF6QJQUXNVWJVSbqZgj57jDhBlOvD8gNUGl35tgqDivbGLhZeW3Ongz4feuKA==", "funding": [ { "type": "github", @@ -12830,10 +12557,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.8", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.0", + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -12930,9 +12657,9 @@ } }, "node_modules/postcss-custom-media": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-11.0.5.tgz", - "integrity": "sha512-SQHhayVNgDvSAdX9NQ/ygcDQGEY+aSF4b/96z7QUX6mqL5yl/JgG/DywcF6fW9XbnCRE+aVYk+9/nqGuzOPWeQ==", + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-11.0.6.tgz", + "integrity": "sha512-C4lD4b7mUIw+RZhtY7qUbf4eADmb7Ey8BFA2px9jUbwg7pjTZDl4KY4bvlUV+/vXQvzQRfiGEVJyAbtOsCMInw==", "funding": [ { "type": "github", @@ -12945,10 +12672,10 @@ ], "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^2.0.4", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/media-query-list-parser": "^4.0.2" + "@csstools/cascade-layer-name-parser": "^2.0.5", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3" }, "engines": { "node": ">=18" @@ -12958,9 +12685,9 @@ } }, "node_modules/postcss-custom-properties": { - "version": "14.0.4", - "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-14.0.4.tgz", - "integrity": "sha512-QnW8FCCK6q+4ierwjnmXF9Y9KF8q0JkbgVfvQEMa93x1GT8FvOiUevWCN2YLaOWyByeDX8S6VFbZEeWoAoXs2A==", + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-14.0.5.tgz", + "integrity": "sha512-UWf/vhMapZatv+zOuqlfLmYXeOhhHLh8U8HAKGI2VJ00xLRYoAJh4xv8iX6FB6+TLXeDnm0DBLMi00E0hodbQw==", "funding": [ { "type": "github", @@ -12973,9 +12700,9 @@ ], "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^2.0.4", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", + "@csstools/cascade-layer-name-parser": "^2.0.5", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, @@ -12987,9 +12714,9 @@ } }, "node_modules/postcss-custom-selectors": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-8.0.4.tgz", - "integrity": "sha512-ASOXqNvDCE0dAJ/5qixxPeL1aOVGHGW2JwSy7HyjWNbnWTQCl+fDc968HY1jCmZI0+BaYT5CxsOiUhavpG/7eg==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-8.0.5.tgz", + "integrity": "sha512-9PGmckHQswiB2usSO6XMSswO2yFWVoCAuih1yl9FVcwkscLjRKjwsjM3t+NIWpSU2Jx3eOiK2+t4vVTQaoCHHg==", "funding": [ { "type": "github", @@ -13002,9 +12729,9 @@ ], "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^2.0.4", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", + "@csstools/cascade-layer-name-parser": "^2.0.5", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", "postcss-selector-parser": "^7.0.0" }, "engines": { @@ -13129,9 +12856,9 @@ } }, "node_modules/postcss-double-position-gradients": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-6.0.0.tgz", - "integrity": "sha512-JkIGah3RVbdSEIrcobqj4Gzq0h53GG4uqDPsho88SgY84WnpkTpI0k50MFK/sX7XqVisZ6OqUfFnoUO6m1WWdg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-6.0.2.tgz", + "integrity": "sha512-7qTqnL7nfLRyJK/AHSVrrXOuvDDzettC+wGoienURV8v2svNbu6zJC52ruZtHaO6mfcagFmuTGFdzRsJKB3k5Q==", "funding": [ { "type": "github", @@ -13144,7 +12871,7 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, @@ -13289,9 +13016,9 @@ } }, "node_modules/postcss-lab-function": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-7.0.8.tgz", - "integrity": "sha512-plV21I86Hg9q8omNz13G9fhPtLopIWH06bt/Cb5cs1XnaGU2kUtEitvVd4vtQb/VqCdNUHK5swKn3QFmMRbpDg==", + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-7.0.10.tgz", + "integrity": "sha512-tqs6TCEv9tC1Riq6fOzHuHcZyhg4k3gIAMB8GGY/zA1ssGdm6puHMVE7t75aOSoFg7UD2wyrFFhbldiCMyyFTQ==", "funding": [ { "type": "github", @@ -13304,10 +13031,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.8", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.0", + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -13878,9 +13605,9 @@ } }, "node_modules/postcss-preset-env": { - "version": "10.1.5", - "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-10.1.5.tgz", - "integrity": "sha512-LQybafF/K7H+6fAs4SIkgzkSCixJy0/h0gubDIAP3Ihz+IQBRwsjyvBnAZ3JUHD+A/ITaxVRPDxn//a3Qy4pDw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-10.2.0.tgz", + "integrity": "sha512-cl13sPBbSqo1Q7Ryb19oT5NZO5IHFolRbIMdgDq4f9w1MHYiL6uZS7uSsjXJ1KzRIcX5BMjEeyxmAevVXENa3Q==", "funding": [ { "type": "github", @@ -13894,59 +13621,60 @@ "license": "MIT-0", "dependencies": { "@csstools/postcss-cascade-layers": "^5.0.1", - "@csstools/postcss-color-function": "^4.0.8", - "@csstools/postcss-color-mix-function": "^3.0.8", - "@csstools/postcss-content-alt-text": "^2.0.4", - "@csstools/postcss-exponential-functions": "^2.0.7", + "@csstools/postcss-color-function": "^4.0.10", + "@csstools/postcss-color-mix-function": "^3.0.10", + "@csstools/postcss-color-mix-variadic-function-arguments": "^1.0.0", + "@csstools/postcss-content-alt-text": "^2.0.6", + "@csstools/postcss-exponential-functions": "^2.0.9", "@csstools/postcss-font-format-keywords": "^4.0.0", - "@csstools/postcss-gamut-mapping": "^2.0.8", - "@csstools/postcss-gradients-interpolation-method": "^5.0.8", - "@csstools/postcss-hwb-function": "^4.0.8", - "@csstools/postcss-ic-unit": "^4.0.0", + "@csstools/postcss-gamut-mapping": "^2.0.10", + "@csstools/postcss-gradients-interpolation-method": "^5.0.10", + "@csstools/postcss-hwb-function": "^4.0.10", + "@csstools/postcss-ic-unit": "^4.0.2", "@csstools/postcss-initial": "^2.0.1", "@csstools/postcss-is-pseudo-class": "^5.0.1", - "@csstools/postcss-light-dark-function": "^2.0.7", + "@csstools/postcss-light-dark-function": "^2.0.9", "@csstools/postcss-logical-float-and-clear": "^3.0.0", "@csstools/postcss-logical-overflow": "^2.0.0", "@csstools/postcss-logical-overscroll-behavior": "^2.0.0", "@csstools/postcss-logical-resize": "^3.0.0", - "@csstools/postcss-logical-viewport-units": "^3.0.3", - "@csstools/postcss-media-minmax": "^2.0.7", - "@csstools/postcss-media-queries-aspect-ratio-number-values": "^3.0.4", + "@csstools/postcss-logical-viewport-units": "^3.0.4", + "@csstools/postcss-media-minmax": "^2.0.9", + "@csstools/postcss-media-queries-aspect-ratio-number-values": "^3.0.5", "@csstools/postcss-nested-calc": "^4.0.0", "@csstools/postcss-normalize-display-values": "^4.0.0", - "@csstools/postcss-oklab-function": "^4.0.8", - "@csstools/postcss-progressive-custom-properties": "^4.0.0", - "@csstools/postcss-random-function": "^1.0.3", - "@csstools/postcss-relative-color-syntax": "^3.0.8", + "@csstools/postcss-oklab-function": "^4.0.10", + "@csstools/postcss-progressive-custom-properties": "^4.1.0", + "@csstools/postcss-random-function": "^2.0.1", + "@csstools/postcss-relative-color-syntax": "^3.0.10", "@csstools/postcss-scope-pseudo-class": "^4.0.1", - "@csstools/postcss-sign-functions": "^1.1.2", - "@csstools/postcss-stepped-value-functions": "^4.0.7", + "@csstools/postcss-sign-functions": "^1.1.4", + "@csstools/postcss-stepped-value-functions": "^4.0.9", "@csstools/postcss-text-decoration-shorthand": "^4.0.2", - "@csstools/postcss-trigonometric-functions": "^4.0.7", + "@csstools/postcss-trigonometric-functions": "^4.0.9", "@csstools/postcss-unset-value": "^4.0.0", - "autoprefixer": "^10.4.19", - "browserslist": "^4.24.4", + "autoprefixer": "^10.4.21", + "browserslist": "^4.24.5", "css-blank-pseudo": "^7.0.1", "css-has-pseudo": "^7.0.2", "css-prefers-color-scheme": "^10.0.0", - "cssdb": "^8.2.3", + "cssdb": "^8.3.0", "postcss-attribute-case-insensitive": "^7.0.1", "postcss-clamp": "^4.1.0", - "postcss-color-functional-notation": "^7.0.8", + "postcss-color-functional-notation": "^7.0.10", "postcss-color-hex-alpha": "^10.0.0", "postcss-color-rebeccapurple": "^10.0.0", - "postcss-custom-media": "^11.0.5", - "postcss-custom-properties": "^14.0.4", - "postcss-custom-selectors": "^8.0.4", + "postcss-custom-media": "^11.0.6", + "postcss-custom-properties": "^14.0.5", + "postcss-custom-selectors": "^8.0.5", "postcss-dir-pseudo-class": "^9.0.1", - "postcss-double-position-gradients": "^6.0.0", + "postcss-double-position-gradients": "^6.0.2", "postcss-focus-visible": "^10.0.1", "postcss-focus-within": "^9.0.1", "postcss-font-variant": "^5.0.0", "postcss-gap-properties": "^6.0.0", "postcss-image-set-function": "^7.0.0", - "postcss-lab-function": "^7.0.8", + "postcss-lab-function": "^7.0.10", "postcss-logical": "^8.1.0", "postcss-nesting": "^13.0.1", "postcss-opacity-percentage": "^3.0.0", @@ -14307,15 +14035,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", - "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", - "license": "MIT", - "dependencies": { - "inherits": "~2.0.3" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -14421,132 +14140,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-dev-utils": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", - "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.16.0", - "address": "^1.1.2", - "browserslist": "^4.18.1", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.3", - "detect-port-alt": "^1.1.6", - "escape-string-regexp": "^4.0.0", - "filesize": "^8.0.6", - "find-up": "^5.0.0", - "fork-ts-checker-webpack-plugin": "^6.5.0", - "global-modules": "^2.0.0", - "globby": "^11.0.4", - "gzip-size": "^6.0.0", - "immer": "^9.0.7", - "is-root": "^2.1.0", - "loader-utils": "^3.2.0", - "open": "^8.4.0", - "pkg-up": "^3.1.0", - "prompts": "^2.4.2", - "react-error-overlay": "^6.0.11", - "recursive-readdir": "^2.2.2", - "shell-quote": "^1.7.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/react-dev-utils/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react-dev-utils/node_modules/loader-utils": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", - "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/react-dev-utils/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react-dev-utils/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react-dev-utils/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react-dev-utils/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/react-dev-utils/node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/react-dom": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", @@ -14559,12 +14152,6 @@ "react": "^19.1.0" } }, - "node_modules/react-error-overlay": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.1.0.tgz", - "integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ==", - "license": "MIT" - }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -14594,6 +14181,18 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-json-view-lite": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-2.4.1.tgz", + "integrity": "sha512-fwFYknRIBxjbFm0kBDrzgBy1xa5tDg2LyXXBepC5f1b+MY3BUClMCsvanMPn089JbV1Eg3nZcrp0VCuH43aXnA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-loadable": { "name": "@docusaurus/react-loadable", "version": "6.0.0", @@ -14697,35 +14296,6 @@ "node": ">=8.10.0" } }, - "node_modules/reading-time": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz", - "integrity": "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==", - "license": "MIT" - }, - "node_modules/rechoir": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", - "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", - "dependencies": { - "resolve": "^1.1.6" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/recursive-readdir": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", - "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==", - "license": "MIT", - "dependencies": { - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -15256,6 +14826,12 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, + "node_modules/schema-dts": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.5.tgz", + "integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==", + "license": "Apache-2.0" + }, "node_modules/schema-utils": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", @@ -15598,22 +15174,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/shelljs": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", - "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", - "dependencies": { - "glob": "^7.0.0", - "interpret": "^1.0.0", - "rechoir": "^0.6.2" - }, - "bin": { - "shjs": "bin/shjs" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -15885,9 +15445,9 @@ } }, "node_modules/std-env": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz", - "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "license": "MIT" }, "node_modules/string_decoder": { @@ -16230,12 +15790,6 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "license": "MIT" - }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -16252,6 +15806,15 @@ "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" }, + "node_modules/tinypool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.0.tgz", + "integrity": "sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==", + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -16363,6 +15926,7 @@ "version": "5.5.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", + "optional": true, "peer": true, "bin": { "tsc": "bin/tsc", @@ -16561,9 +16125,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "funding": [ { "type": "opencollective", @@ -16578,9 +16142,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -17445,19 +17010,10 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/yocto-queue": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", - "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", "license": "MIT", "engines": { "node": ">=12.20" diff --git a/docs/package.json b/docs/package.json index 7c155bd9e..2bb440711 100644 --- a/docs/package.json +++ b/docs/package.json @@ -14,9 +14,9 @@ "write-heading-ids": "docusaurus write-heading-ids" }, "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/plugin-client-redirects": "^3.7.0", - "@docusaurus/preset-classic": "3.7.0", + "@docusaurus/core": "3.8.0", + "@docusaurus/plugin-client-redirects": "^3.8.0", + "@docusaurus/preset-classic": "3.8.0", "@mdx-js/react": "^3.1.0", "clsx": "^2.0.0", "docusaurus-theme-github-codeblock": "^2.0.2", @@ -25,8 +25,8 @@ "react-dom": "^19.1.0" }, "devDependencies": { - "@docusaurus/module-type-aliases": "3.7.0", - "@docusaurus/types": "3.7.0" + "@docusaurus/module-type-aliases": "3.8.0", + "@docusaurus/types": "3.8.0" }, "browserslist": { "production": [ From cbd8f3a870580e55892e2e207da8d45bcc2007ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Jun 2025 11:57:19 -0400 Subject: [PATCH 761/865] chore(deps): bump mypy from 1.15.0 to 1.16.0 (#1309) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: William Bergamin Co-authored-by: William Bergamin --- requirements/tools.txt | 2 +- slack_bolt/adapter/falcon/async_resource.py | 2 +- slack_bolt/adapter/falcon/resource.py | 4 ++-- slack_bolt/app/app.py | 2 +- slack_bolt/app/async_app.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/tools.txt b/requirements/tools.txt index 0fd423ad2..0e6090497 100644 --- a/requirements/tools.txt +++ b/requirements/tools.txt @@ -1,3 +1,3 @@ -mypy==1.15.0 +mypy==1.16.0 flake8==7.2.0 black==24.8.0 # Until we drop Python 3.6 support, we have to stay with this version diff --git a/slack_bolt/adapter/falcon/async_resource.py b/slack_bolt/adapter/falcon/async_resource.py index eece0a323..8d03b456c 100644 --- a/slack_bolt/adapter/falcon/async_resource.py +++ b/slack_bolt/adapter/falcon/async_resource.py @@ -42,7 +42,7 @@ async def on_get(self, req: Request, resp: Response): resp.status = "404" # Falcon 4.x w/ mypy fails to correctly infer the str type here - resp.body = "The page is not found..." # type: ignore[assignment] + resp.body = "The page is not found..." async def on_post(self, req: Request, resp: Response): bolt_req = await self._to_bolt_request(req) diff --git a/slack_bolt/adapter/falcon/resource.py b/slack_bolt/adapter/falcon/resource.py index baf0f9745..53792775f 100644 --- a/slack_bolt/adapter/falcon/resource.py +++ b/slack_bolt/adapter/falcon/resource.py @@ -36,7 +36,7 @@ def on_get(self, req: Request, resp: Response): resp.status = "404" # Falcon 4.x w/ mypy fails to correctly infer the str type here - resp.body = "The page is not found..." # type: ignore[assignment] + resp.body = "The page is not found..." def on_post(self, req: Request, resp: Response): bolt_req = self._to_bolt_request(req) @@ -53,7 +53,7 @@ def _to_bolt_request(self, req: Request) -> BoltRequest: def _write_response(self, bolt_resp: BoltResponse, resp: Response): if falcon_version.__version__.startswith("2."): # Falcon 4.x w/ mypy fails to correctly infer the str type here - resp.body = bolt_resp.body # type: ignore[assignment] + resp.body = bolt_resp.body else: resp.text = bolt_resp.body diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 69da0a0d8..c117740a1 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -1448,7 +1448,7 @@ def _register_listener( CustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, + lazy_functions=functions, # type:ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 3fcc3d955..c04326291 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -1487,7 +1487,7 @@ def _register_listener( AsyncCustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, + lazy_functions=functions, # type:ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, From 260e9b9c34ac930c99769077ee682dda9e0f1426 Mon Sep 17 00:00:00 2001 From: Tracy Rericha <108959677+technically-tracy@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:09:24 -0400 Subject: [PATCH 762/865] docs: updated nav (#1313) --- docs/sidebars.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/sidebars.js b/docs/sidebars.js index 82209d428..65c5e85f4 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -40,8 +40,14 @@ const sidebars = { ], }, "concepts/ai-apps", - "concepts/custom-steps", - "concepts/custom-steps-dynamic-options", + { + type: 'category', + label: 'Custom Steps', + items: [ + 'concepts/custom-steps', + 'concepts/custom-steps-dynamic-options', + ] + }, { type: "category", label: "App Configuration", From 5778bf59c6c28379faa2ffee62c0b1e862b2a3f0 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 26 Jun 2025 13:22:57 -0400 Subject: [PATCH 763/865] fix: sanic dependencies for tests (#1320) --- .github/workflows/tests.yml | 14 +++++++------- requirements/adapter.txt | 5 +++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a1135f265..1d16d1a1b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,20 +53,20 @@ jobs: - name: Install async dependencies run: | pip install -r requirements/async.txt - - name: Run tests for HTTP Mode adapters (ASGI) - run: | - # Requires async test dependencies - pytest tests/adapter_tests/asgi/ --junitxml=reports/test_adapter_asgi.xml - name: Run tests for Socket Mode adapters run: | # Requires async test dependencies pytest tests/adapter_tests/socket_mode/ --junitxml=reports/test_adapter_socket_mode.xml - - name: Run tests for HTTP Mode adapters (asyncio-based libraries) - run: | - pytest tests/adapter_tests_async/ --junitxml=reports/test_adapter_async.xml - name: Install all dependencies run: | pip install -r requirements/testing.txt + - name: Run tests for HTTP Mode adapters (ASGI) + run: | + # Requires async test dependencies + pytest tests/adapter_tests/asgi/ --junitxml=reports/test_adapter_asgi.xml + - name: Run tests for HTTP Mode adapters (asyncio-based libraries) + run: | + pytest tests/adapter_tests_async/ --junitxml=reports/test_adapter_async.xml - name: Run asynchronous tests run: | pytest tests/slack_bolt_async/ --junitxml=reports/test_slack_bolt_async.xml diff --git a/requirements/adapter.txt b/requirements/adapter.txt index 6618f2b6f..b8cadb510 100644 --- a/requirements/adapter.txt +++ b/requirements/adapter.txt @@ -13,9 +13,14 @@ fastapi>=0.70.0,<1 Flask>=1,<4 Werkzeug>=2,<4 pyramid>=1,<3 + +# Sanic and its dependencies +# Note: Sanic imports tracerite with wild card versions +tracerite<1.1.2; python_version<="3.8" # older versions of python are not compatible with tracerite>1.1.2 sanic>=20,<21; python_version=="3.6" sanic>=21,<24; python_version>"3.6" and python_version<="3.8" sanic>=21,<26; python_version>"3.8" + starlette>=0.19.1,<1 tornado>=6,<7 uvicorn<1 # The oldest version can vary among Python runtime versions From 7f9ae9cdb68c4f98995f557c2fec6bbbfaf62239 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 16:06:07 -0700 Subject: [PATCH 764/865] chore(deps): bump brace-expansion from 1.1.11 to 1.1.12 in /docs (#1321) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 303f5487a..4e7d7b445 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -5421,9 +5421,10 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" From 35295329f4118c16d2cc3e4545661a98eac7b192 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 27 Jun 2025 12:59:44 -0700 Subject: [PATCH 765/865] ci: run unit tests on main once a day (#1319) Co-authored-by: William Bergamin --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1d16d1a1b..748ddcd30 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,8 @@ on: branches: - main pull_request: + schedule: + - cron: "0 0 * * *" jobs: build: From 079552dc861d710b846a4bdb4044cb6431a5f304 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 27 Jun 2025 16:29:56 -0400 Subject: [PATCH 766/865] chore: run CI tests from the Github UI (#1322) --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 748ddcd30..3b396b201 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,6 +7,7 @@ on: pull_request: schedule: - cron: "0 0 * * *" + workflow_dispatch: jobs: build: From d3ca0550196a2211c991f5db34ff40945e980095 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 27 Jun 2025 14:10:07 -0700 Subject: [PATCH 767/865] ci: send a notification of failing tests on the main branch (#1323) Co-authored-by: William Bergamin --- .github/workflows/tests.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3b396b201..4fe757b1a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -82,3 +82,18 @@ jobs: flags: ${{ matrix.python-version }} token: ${{ secrets.CODECOV_TOKEN }} verbose: true + notifications: + name: Regression notifications + runs-on: ubuntu-latest + needs: build + if: failure() && github.ref == 'refs/heads/main' && github.event_name != 'workflow_dispatch' + steps: + - name: Send notifications of failing tests + uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 + with: + errors: true + webhook: ${{ secrets.SLACK_REGRESSION_FAILURES_WEBHOOK_URL }} + webhook-type: webhook-trigger + payload: | + action_url: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + repository: "${{ github.repository }}" From b9c9999b1d3777997af1c8f57a4d59aa9e148256 Mon Sep 17 00:00:00 2001 From: Tracy Rericha <108959677+technically-tracy@users.noreply.github.com> Date: Tue, 1 Jul 2025 06:57:34 -0400 Subject: [PATCH 768/865] docs: add quick start guide using the slack cli and terminal (#1317) --- docs/content/building-an-app.md | 483 +++++++++++++++++++++++++++++ docs/content/getting-started.md | 527 +++++++++++--------------------- docs/docusaurus.config.js | 1 + docs/sidebars.js | 6 +- 4 files changed, 662 insertions(+), 355 deletions(-) create mode 100644 docs/content/building-an-app.md diff --git a/docs/content/building-an-app.md b/docs/content/building-an-app.md new file mode 100644 index 000000000..deb3146b9 --- /dev/null +++ b/docs/content/building-an-app.md @@ -0,0 +1,483 @@ +--- +title: Building an App with Bolt for Python +sidebar_label: Building an App +--- + +# Building an App with Bolt for Python + +This guide is meant to walk you through getting up and running with a Slack app using Bolt for Python. Along the way, we’ll create a new Slack app, set up your local environment, and develop an app that listens and responds to messages from a Slack workspace. + +When you're finished, you'll have created the [Getting Started app](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started) to run, modify, and make your own. ⚡️ + +--- + +### Create an app {#create-an-app} +First thing's first: before you start developing with Bolt, you'll want to [create a Slack app](https://api.slack.com/apps/new). + +:::tip[A place to test and learn] + +We recommend using a workspace where you won't disrupt real work getting done — [you can create a new one for free](https://slack.com/get-started#create). + +::: + +After you fill out an app name (_you can change it later_) and pick a workspace to install it to, hit the `Create App` button and you'll land on your app's **Basic Information** page. + +This page contains an overview of your app in addition to important credentials you'll need later. + +![Basic Information page](/img/boltpy/basic-information-page.png "Basic Information page") + +Look around, add an app icon and description, and then let's start configuring your app 🔩 + +--- + +### Tokens and installing apps {#tokens-and-installing-apps} +Slack apps use [OAuth to manage access to Slack's APIs](https://docs.slack.dev/authentication/installing-with-oauth). When an app is installed, you'll receive a token that the app can use to call API methods. + +There are three main token types available to a Slack app: user (`xoxp`), bot (`xoxb`), and app-level (`xapp`) tokens. +- [User tokens](https://docs.slack.dev/authentication/tokens#user) allow you to call API methods on behalf of users after they install or authenticate the app. There may be several user tokens for a single workspace. +- [Bot tokens](https://docs.slack.dev/authentication/tokens#bot) are associated with bot users, and are only granted once in a workspace where someone installs the app. The bot token your app uses will be the same no matter which user performed the installation. Bot tokens are the token type that _most_ apps use. +- [App-level tokens](https://docs.slack.dev/authentication/tokens#app-level) represent your app across organizations, including installations by all individual users on all workspaces in a given organization and are commonly used for creating WebSocket connections to your app. + +We're going to use bot and app-level tokens for this guide. + +1. Navigate to **OAuth & Permissions** on the left sidebar and scroll down to the **Bot Token Scopes** section. Click **Add an OAuth Scope**. + +2. For now, we'll just add one scope: [`chat:write`](https://docs.slack.dev/reference/scopes/chat.write). This grants your app the permission to post messages in channels it's a member of. + +3. Scroll up to the top of the **OAuth & Permissions** page and click **Install App to Workspace**. You'll be led through Slack's OAuth UI, where you should allow your app to be installed to your development workspace. + +4. Once you authorize the installation, you'll land on the **OAuth & Permissions** page and see a **Bot User OAuth Access Token**. + +![OAuth Tokens](/img/boltpy/bot-token.png "Bot OAuth Token") + +5. Head over to **Basic Information** and scroll down under the App Token section and click **Generate Token and Scopes** to generate an app-level token. Add the `connections:write` scope to this token and save the generated `xapp` token. + +6. Navigate to **Socket Mode** on the left side menu and toggle to enable. + +:::tip[Not sharing is sometimes caring] + +Treat your tokens like passwords and [keep them safe](https://docs.slack.dev/authentication/best-practices-for-security). Your app uses tokens to post and retrieve information from Slack workspaces. + +::: + +--- + +### Setting up your project {#setting-up-your-project} + +With the initial configuration handled, it's time to set up a new Bolt project. This is where you'll write the code that handles the logic for your app. + +If you don’t already have a project, let’s create a new one. Create an empty directory: + +```sh +$ mkdir first-bolt-app +$ cd first-bolt-app +``` + +Next, we recommend using a [Python virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment) to manage your project's dependencies. This is a great way to prevent conflicts with your system's Python packages. Let's create and activate a new virtual environment with [Python 3.6 or later](https://www.python.org/downloads/): + +```sh +$ python3 -m venv .venv +$ source .venv/bin/activate +$ pip install -r requirements.txt +``` + +We can confirm that the virtual environment is active by checking that the path to `python3` is _inside_ your project ([a similar command is available on Windows](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#activating-a-virtual-environment)): + +```sh +$ which python3 +# Output: /path/to/first-bolt-app/.venv/bin/python3 +``` + +Before we install the Bolt for Python package to your new project, let's save the **bot token** and **app-level token** that were generated when you configured your app. + +1. **Copy your bot (xoxb) token from the OAuth & Permissions page** and store it in a new environment variable. The following example works on Linux and macOS; but [similar commands are available on Windows](https://superuser.com/questions/212150/how-to-set-env-variable-in-windows-cmd-line/212153#212153). + +```sh +$ export SLACK_BOT_TOKEN=xoxb- +``` + +2. **Copy your app-level (xapp) token from the Basic Information page** and then store it in a new environment variable. + +```sh +$ export SLACK_APP_TOKEN= +``` + +:::warning[Keep it secret. Keep it safe.] + +Remember to keep your tokens secure. At a minimum, you should avoid checking them into public version control, and access them via environment variables as we've done above. Check out the API documentation for more on [best practices for app security](https://docs.slack.dev/authentication/best-practices-for-security). + +::: + +Now, let's create your app. Install the `slack_bolt` Python package to your virtual environment using the following command: + +```sh +$ pip install slack_bolt +``` + +Create a new file called `app.py` in this directory and add the following code: + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# Initializes your app with your bot token and socket mode handler +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) + +# Start your app +if __name__ == "__main__": + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() +``` + +Your tokens are enough to create your first Bolt app. Save your `app.py` file then on the command line run the following: + +```sh +$ python3 app.py +``` + +Your app should let you know that it's up and running. 🎉 + +--- + +### Setting up events {#setting-up-events} +Your app behaves similarly to people on your team — it can post messages, add emoji reactions, and listen and respond to events. + +To listen for events happening in a Slack workspace (like when a message is posted or when a reaction is posted to a message) you'll use the [Events API to subscribe to event types](https://docs.slack.dev/apis/events-api/). + +For those just starting, we recommend using [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode). Socket Mode allows your app to use the Events API and interactive features without exposing a public HTTP Request URL. This can be helpful during development, or if you're receiving requests from behind a firewall. + +That being said, you're welcome to set up an app with a public HTTP Request URL. HTTP is more useful for apps being deployed to hosting environments to respond within a large corporate Slack workspaces/organization, or apps intended for distribution via the Slack Marketplace. + +We've provided instructions for both ways in this guide. + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + + + + +1. Head to your app's configuration page (click on the app [from your app settings page](https://api.slack.com/apps)). Navigate to **Socket Mode** on the left side menu and toggle to enable. + +2. Go to **Basic Information** and scroll down under the App-Level Tokens section and click **Generate Token and Scopes** to generate an app-level token. Add the `connections:write` scope to this token and save the generated `xapp` token, we'll use that in just a moment. + +3. Finally, it's time to tell Slack what events we'd like to listen for. Under **Event Subscriptions**, toggle the switch labeled **Enable Events**. + +When an event occurs, Slack will send your app some information about the event, like the user that triggered it and the channel it occurred in. Your app will process the details and can respond accordingly. + + + + +1. Go back to your app configuration page (click on the app [from your app management page](https://api.slack.com/apps)). Click **Event Subscriptions** on the left sidebar. Toggle the switch labeled **Enable Events**. + +2. Add your Request URL. Slack will send HTTP POST requests corresponding to events to this [Request URL](https://docs.slack.dev/apis/events-api/#subscribing) endpoint. Bolt uses the `/slack/events` path to listen to all incoming requests (whether shortcuts, events, or interactivity payloads). When configuring your Request URL within your app configuration, you'll append `/slack/events`, e.g. `https:///slack/events`. 💡 As long as your Bolt app is still running, your URL should become verified. + +:::tip[Using proxy services] + +For local development, you can use a proxy service like ngrok to create a public URL and tunnel requests to your development environment. Refer to [ngrok's getting started guide](https://ngrok.com/docs#getting-started-expose) on how to create this tunnel. And when you get to hosting your app, we've collected some of the most common hosting providers Slack developers use to host their apps [on our API site](https://docs.slack.dev/distribution/hosting-slack-apps/). + +::: + + + + +Navigate to **Event Subscriptions** on the left sidebar and toggle to enable. Under **Subscribe to Bot Events**, you can add events for your bot to respond to. There are four events related to messages: +- [`message.channels`](https://docs.slack.dev/reference/events/message.channels) listens for messages in public channels that your app is added to. +- [`message.groups`](https://docs.slack.dev/reference/events/message.groups) listens for messages in 🔒 private channels that your app is added to. +- [`message.im`](https://docs.slack.dev/reference/events/message.im) listens for messages in your app's DMs with users. +- [`message.mpim`](https://docs.slack.dev/reference/events/message.mpim) listens for messages in multi-person DMs that your app is added to. + +If you want your bot to listen to messages from everywhere it is added to, choose all four message events. After you’ve selected the events you want your bot to listen to, click the green **Save Changes** button. + +--- + +### Listening and responding to a message {#listening-and-responding-to-a-message} +Your app is now ready for some logic. Let's start by using the `message()` method to attach a listener for messages. + +The following example listens and responds to all messages in channels/DMs where your app has been added that contain the word "hello": + + + + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# Initializes your app with your bot token and socket mode handler +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) + +# Listens to incoming messages that contain "hello" +# To learn available listener arguments, +# visit https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say(f"Hey there <@{message['user']}>!") + +# Start your app +if __name__ == "__main__": + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() +``` + + + + +```python +import os +from slack_bolt import App + +# Initializes your app with your bot token and signing secret +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# Listens to incoming messages that contain "hello" +# To learn available listener arguments, +# visit https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say(f"Hey there <@{message['user']}>!") + +# Start your app +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + + + + +If you restart your app, so long as your bot user has been added to the channel or DM conversation, when you send any message that contains "hello", it will respond. + +This is a basic example, but it gives you a place to start customizing your app based on your own goals. Let's try something a little more interactive by sending a button rather than plain text. + +--- + +### Sending and responding to actions {#sending-and-responding-to-actions} + +To use features like buttons, select menus, datepickers, modals, and shortcuts, you’ll need to enable interactivity. Head over to **Interactivity & Shortcuts** in your app configuration. + + + + +With Socket Mode on, basic interactivity is enabled by default, so no further action is needed. + + + + +Similar to events, you'll need to specify a URL for Slack to send the action (such as _user clicked a button_). Back on your app configuration page, click on **Interactivity & Shortcuts** on the left side. You'll see that there's another **Request URL** box. + +:::tip + +By default, Bolt is configured to use the same endpoint for interactive components that it uses for events, so use the same request URL as above (for example, `https://8e8ec2d7.ngrok.io/slack/events`). Press the **Save Changes** button in the lower right hand corner, and that's it. Your app is set up to handle interactivity! + +::: + + + + +When interactivity is enabled, interactions with shortcuts, modals, or interactive components (such as buttons, select menus, and datepickers) will be sent to your app as events. + +Now, let's go back to your app's code and add logic to handle those events: +- First, we'll send a message that contains an interactive component (in this case a button). +- Next, we'll listen for the action of a user clicking the button before responding. + +Below, the code from the last section is modified to send a message containing a button rather than just a string: + + + + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# Initializes your app with your bot token and socket mode handler +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + # signing_secret=os.environ.get("SLACK_SIGNING_SECRET") # not required for socket mode +) + +# Listens to incoming messages that contain "hello" +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +# Start your app +if __name__ == "__main__": + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() + +``` + + + + +```python +import os +from slack_bolt import App + +# Initializes your app with your bot token and signing secret +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# Listens to incoming messages that contain "hello" +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +# Start your app +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + + + + +The value inside of `say()` is now an object that contains an array of `blocks`. Blocks are the building components of a Slack message and can range from text to images to datepickers. In this case, your app will respond with a section block that includes a button as an accessory. Since we're using `blocks`, the `text` is a fallback for notifications and accessibility. + +You'll notice in the button `accessory` object, there is an `action_id`. This will act as a unique identifier for the button so your app can specify which action it wants to respond to. + +:::tip[Using Block Kit Builder] + +The [Block Kit Builder](https://app.slack.com/block-kit-builder) is an simple way to prototype your interactive messages. The builder lets you (or anyone on your team) mock up messages and generates the corresponding JSON that you can paste directly in your app. + +::: + +Now, if you restart your app and say "hello" in a channel your app is in, you'll see a message with a button. But if you click the button, nothing happens (_yet!_). + +Let's add a handler to send a follow-up message when someone clicks the button: + + + + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# Initializes your app with your bot token and socket mode handler +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) + +# Listens to incoming messages that contain "hello" +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +@app.action("button_click") +def action_button_click(body, ack, say): + # Acknowledge the action + ack() + say(f"<@{body['user']['id']}> clicked the button") + +# Start your app +if __name__ == "__main__": + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() +``` + + + + +```python +import os +from slack_bolt import App + +# Initializes your app with your bot token and signing secret +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# Listens to incoming messages that contain "hello" +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +@app.action("button_click") +def action_button_click(body, ack, say): + # Acknowledge the action + ack() + say(f"<@{body['user']['id']}> clicked the button") + +# Start your app +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + + + + +You can see that we used `app.action()` to listen for the `action_id` that we named `button_click`. If you restart your app and click the button, you'll see a new message from your app that says you clicked the button. + +--- + +### Next steps {#next-steps} +You just built your first [Bolt for Python app](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started)! 🎉 + +Now that you have a basic app up and running, you can start exploring how to make your Bolt app stand out. Here are some ideas about what to explore next: + +* Read through the concepts pages to learn about the different methods and features your Bolt app has access to. + +* Explore the different events your bot can listen to with the [`app.event()`](/concepts/event-listening) method. All of the events are listed [on the API docs site](https://docs.slack.dev/reference/events). + +* Bolt allows you to [call Web API methods](/concepts/web-api) with the client attached to your app. There are [over 200 methods](https://docs.slack.dev/reference/methods) on our API site. + +* Learn more about the different token types [on the API docs site](https://docs.slack.dev/authentication/tokens). Your app may need different tokens depending on the actions you want it to perform. diff --git a/docs/content/getting-started.md b/docs/content/getting-started.md index d1b7d5e2c..a794f3176 100644 --- a/docs/content/getting-started.md +++ b/docs/content/getting-started.md @@ -1,481 +1,300 @@ --- -title: Getting Started -slug: getting-started -lang: en +title: Quickstart guide with Bolt for Python +sidebar_label: Quickstart --- # Getting started with Bolt for Python -This guide is meant to walk you through getting up and running with a Slack app using Bolt for Python. Along the way, we’ll create a new Slack app, set up your local environment, and develop an app that listens and responds to messages from a Slack workspace. +This quickstart guide aims to help you get a Slack app using Bolt for Python up and running as soon as possible! -When you're finished, you'll have this ⚡️[Getting Started with Slack app](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started) to run, modify, and make your own. - ---- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; -### Create an app {#create-an-app} -First thing's first: before you start developing with Bolt, you'll want to [create a Slack app](https://api.slack.com/apps/new). +When complete, you'll have a local environment configured with a customized [app](https://github.com/slack-samples/bolt-python-getting-started-app) running to modify and make your own. -:::tip +:::tip[Reference for readers] -We recommend using a workspace where you won't disrupt real work getting done — [you can create a new one for free](https://slack.com/get-started#create). +In search of the complete guide to building an app from scratch? Check out the [building an app](/building-an-app) guide. ::: -After you fill out an app name (_you can change it later_) and pick a workspace to install it to, hit the `Create App` button and you'll land on your app's **Basic Information** page. +#### Prerequisites -This page contains an overview of your app in addition to important credentials you'll need later. +A few tools are needed for the following steps. We recommend using the [**Slack CLI**](https://tools.slack.dev/slack-cli/) for the smoothest experience, but other options remain available. -![Basic Information page](/img/boltpy/basic-information-page.png "Basic Information page") +You can also begin by installing git and downloading [Python 3.6 or later](https://www.python.org/downloads/), or the latest stable version of Python. Refer to [Python's setup and building guide](https://devguide.python.org/getting-started/setup-building/) for more details. -Look around, add an app icon and description, and then let's start configuring your app 🔩 +Install the latest version of the Slack CLI to get started: ---- +- [Slack CLI for macOS & Linux](https://tools.slack.dev/slack-cli/guides/installing-the-slack-cli-for-mac-and-linux) +- [Slack CLI for Windows](https://tools.slack.dev/slack-cli/guides/installing-the-slack-cli-for-windows) -### Tokens and installing apps {#tokens-and-installing-apps} -Slack apps use [OAuth to manage access to Slack's APIs](https://docs.slack.dev/authentication/installing-with-oauth). When an app is installed, you'll receive a token that the app can use to call API methods. +Then confirm a successful installation with the following command: -There are three main token types available to a Slack app: user (`xoxp`), bot (`xoxb`), and app-level (`xapp`) tokens. -- [User tokens](https://docs.slack.dev/authentication/tokens#user) allow you to call API methods on behalf of users after they install or authenticate the app. There may be several user tokens for a single workspace. -- [Bot tokens](https://docs.slack.dev/authentication/tokens#bot) are associated with bot users, and are only granted once in a workspace where someone installs the app. The bot token your app uses will be the same no matter which user performed the installation. Bot tokens are the token type that _most_ apps use. -- [App-level tokens](https://docs.slack.dev/authentication/tokens#app-level) represent your app across organizations, including installations by all individual users on all workspaces in a given organization and are commonly used for creating WebSocket connections to your app. +```sh +$ slack version +``` -We're going to use bot and app-level tokens for this guide. +An authenticated login is also required if this hasn't been done before: -1. Navigate to the **OAuth & Permissions** on the left sidebar and scroll down to the **Bot Token Scopes** section. Click **Add an OAuth Scope**. +```sh +$ slack login +``` -2. For now, we'll just add one scope: [`chat:write`](https://docs.slack.dev/reference/scopes/chat.write). This grants your app the permission to post messages in channels it's a member of. +:::info[A place to belong] -3. Scroll up to the top of the **OAuth & Permissions** page and click **Install App to Workspace**. You'll be led through Slack's OAuth UI, where you should allow your app to be installed to your development workspace. +A workspace where development can happen is also needed. -4. Once you authorize the installation, you'll land on the **OAuth & Permissions** page and see a **Bot User OAuth Access Token**. +We recommend using [developer sandboxes](https://docs.slack.dev/tools/developer-sandboxes) to avoid disruptions where real work gets done. -![OAuth Tokens](/img/boltpy/bot-token.png "Bot OAuth Token") +::: -5. Then head over to **Basic Information** and scroll down under the App Token section and click **Generate Token and Scopes** to generate an app-level token. Add the `connections:write` scope to this token and save the generated `xapp` token, we'll use both these tokens in just a moment. +## Creating a project {#creating-a-project} -6. Navigate to **Socket Mode** on the left side menu and toggle to enable. +With the toolchain configured, it's time to set up a new Bolt project. This contains the code that handles logic for your app. -:::tip +If you don’t already have a project, let’s create a new one! -Treat your tokens like passwords and [keep them safe](https://docs.slack.dev/authentication/best-practices-for-security). Your app uses tokens to post and retrieve information from Slack workspaces. + + -::: +A starter template can be used to start with project scaffolding: ---- - -### Setting up your project {#setting-up-your-project} +```sh +$ slack create first-bolt-app --template slack-samples/bolt-python-getting-started-app +$ cd first-bolt-app +``` -With the initial configuration handled, it's time to set up a new Bolt project. This is where you'll write the code that handles the logic for your app. +After a project is created you'll have a `requirements.txt` file for app dependencies and a `.slack` directory for Slack CLI configuration. -If you don’t already have a project, let’s create a new one. Create an empty directory: +A few other files exist too, but we'll visit these later. -```shell -mkdir first-bolt-app -cd first-bolt-app -``` + + -Next, we recommend using a [Python virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment) to manage your project's dependencies. This is a great way to prevent conflicts with your system's Python packages. Let's create and activate a new virtual environment with [Python 3.6 or later](https://www.python.org/downloads/): +A starter template can be cloned to start with project scaffolding: -```shell -python3 -m venv .venv -source .venv/bin/activate +```sh +$ git clone https://github.com/slack-samples/bolt-python-getting-started-app first-bolt-app +$ cd first-bolt-app ``` -We can confirm that the virtual environment is active by checking that the path to `python3` is _inside_ your project ([a similar command is available on Windows](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#activating-a-virtual-environment)): +Outlines of a project are taking shape, so we can move on to running the app! -```shell -which python3 -# Output: /path/to/first-bolt-app/.venv/bin/python3 -``` + + -Before we install the Bolt for Python package to your new project, let's save the **bot token** and **app-level token** that were generated when you configured your app. +We recommend using a [Python virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment) to manage your project's dependencies. This is a great way to prevent conflicts with your system's Python packages. Let's create and activate a new virtual environment with [Python 3.6 or later](https://www.python.org/downloads/): -1. **Copy your bot (xoxb) token from the OAuth & Permissions page** and store it in a new environment variable. The following example works on Linux and macOS; but [similar commands are available on Windows](https://superuser.com/questions/212150/how-to-set-env-variable-in-windows-cmd-line/212153#212153). -```shell -export SLACK_BOT_TOKEN=xoxb- +```sh +$ python3 -m venv .venv +$ source .venv/bin/activate +$ pip install -r requirements.txt ``` -2. **Copy your app-level (xapp) token from the Basic Information page** and then store it in a new environment variable. -```shell -export SLACK_APP_TOKEN= +Confirm the virtual environment is active by checking that the path to `python3` is _inside_ your project ([a similar command is available on Windows](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#activating-a-virtual-environment)): + +```sh +$ which python3 +# Output: /path/to/first-bolt-app/.venv/bin/python3 ``` -:::warning +## Running the app {#running-the-app} -Remember to keep your tokens secure. At a minimum, you should avoid checking them into public version control, and access them via environment variables as we've done above. Check out the API documentation for more on [best practices for app security](https://docs.slack.dev/authentication/best-practices-for-security). +Before you can start developing with Bolt, you will want a running Slack app. -::: + + -Now, let's create your app. Install the `slack_bolt` Python package to your virtual environment using the following command: +The getting started app template contains a `manifest.json` file with details about an app that we will use to get started. Use the following command and select "Create a new app" to install the app to the team of choice: -```shell -pip install slack_bolt +```sh +$ slack run +... +⚡️ Bolt app is running! ``` -Create a new file called `app.py` in this directory and add the following code: +With the app running, you can test it out with the following steps in Slack: -```python -import os -from slack_bolt import App -from slack_bolt.adapter.socket_mode import SocketModeHandler +1. Open a direct message with your app or invite the bot `@first-bolt-app (local)` to a public channel. +2. Send "hello" to the current conversation and wait for a response. +3. Click the attached button labelled "Click Me" to post another reply. -# Initializes your app with your bot token and socket mode handler -app = App(token=os.environ.get("SLACK_BOT_TOKEN")) +After confirming the app responds, celebrate, then interrupt the process by pressing `CTRL+C` in the terminal to stop your app from running. -# Start your app -if __name__ == "__main__": - SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() -``` + + -Your tokens are enough to create your first Bolt app. Save your `app.py` file then on the command line run the following: +Navigate to your list of apps and [create a new Slack app](https://api.slack.com/apps/new) using the "from a manifest" option: -```script -python3 app.py -``` +1. Select the workspace to develop your app in. +2. Copy and paste the `manifest.json` file contents to create your app. +3. Confirm the app features and click "Create". -Your app should let you know that it's up and running. 🎉 +You'll then land on your app's **Basic Information** page, which is an overview of your app and which contains important credentials: ---- +![Basic Information page](/img/boltpy/basic-information-page.png "Basic Information page") -### Setting up events {#setting-up-events} -Your app behaves similarly to people on your team — it can post messages, add emoji reactions, and listen and respond to events. +To listen for events happening in Slack (such as a new posted message) without opening a port or exposing an endpoint, we will use [Socket Mode](/concepts/socket-mode). This connection requires a specific app token: -To listen for events happening in a Slack workspace (like when a message is posted or when a reaction is posted to a message) you'll use the [Events API to subscribe to event types](https://docs.slack.dev/apis/events-api/). +1. On the **Basic Information** page, scroll to the **App-Level Tokens** section and click **Generate Token and Scopes**. +2. Name the token "Development" or something similar and add the `connections:write` scope, then click **Generate**. +3. Save the generated `xapp` token as an environment variable within your project: -For those just starting, we recommend using [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode). Socket Mode allows your app to use the Events API and interactive features without exposing a public HTTP Request URL. This can be helpful during development, or if you're receiving requests from behind a firewall. +```sh +$ export SLACK_APP_TOKEN= +``` -That being said, you're welcome to set up an app with a public HTTP Request URL. HTTP is more useful for apps being deployed to hosting environments to respond within a large corporate Slack workspaces/organization, or apps intended for distribution via the Slack Marketplace. +The above command works on Linux and macOS but [similar commands are available on Windows](https://superuser.com/questions/212150/how-to-set-env-variable-in-windows-cmd-line/212153#212153). -We've provided instructions for both ways in this guide. +:::warning[Keep it secret. Keep it safe.] -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; +Treat your tokens like a password and [keep it safe](https://docs.slack.dev/authentication/best-practices-for-security). Your app uses these to retrieve and send information to Slack. - - +::: -1. Head to your app's configuration page (click on the app [from your app settings page](https://api.slack.com/apps)). Navigate to **Socket Mode** on the left side menu and toggle to enable. +A bot token is also needed to interact with the Web API methods as your app's bot user. We can gather this as follows: -2. Go to **Basic Information** and scroll down under the App-Level Tokens section and click **Generate Token and Scopes** to generate an app token. Add the `connections:write` scope to this token and save the generated `xapp` token, we'll use that in just a moment. +1. Navigate to the **OAuth & Permissions** on the left sidebar and install your app to your workspace to generate a token. +2. After authorizing the installation, you'll return to the **OAuth & Permissions** page and find a **Bot User OAuth Token**: -3. Finally, it's time to tell Slack what events we'd like to listen for. Under **Event Subscriptions**, toggle the switch labeled **Enable Events**. +![OAuth Tokens](/img/boltpy/bot-token.png "Bot OAuth Token") -When an event occurs, Slack will send your app some information about the event, like the user that triggered it and the channel it occurred in. Your app will process the details and can respond accordingly. +3. Copy the bot token beginning with `xoxb` from the **OAuth & Permissions page** and then store it in a new environment variable: - - +```sh +$ export SLACK_BOT_TOKEN=xoxb- +``` -1. Go back to your app configuration page (click on the app [from your app management page](https://api.slack.com/apps)). Click **Event Subscriptions** on the left sidebar. Toggle the switch labeled **Enable Events**. +After saving tokens for the app you created, it is time to run it: -2. Add your Request URL. Slack will send HTTP POST requests corresponding to events to this [Request URL](https://docs.slack.dev/apis/events-api/#subscribing) endpoint. Bolt uses the `/slack/events` path to listen to all incoming requests (whether shortcuts, events, or interactivity payloads). When configuring your Request URL within your app configuration, you'll append `/slack/events`, e.g. `https:///slack/events`. 💡 As long as your Bolt app is still running, your URL should become verified. +```sh +$ python3 app.py +... +⚡️ Bolt app is running! +``` -:::tip +With the app running, you can test it out with the following steps in Slack: -For local development, you can use a proxy service like ngrok to create a public URL and tunnel requests to your development environment. Refer to [ngrok's getting started guide](https://ngrok.com/docs#getting-started-expose) on how to create this tunnel. And when you get to hosting your app, we've collected some of the most common hosting providers Slack developers use to host their apps [on our API site](https://docs.slack.dev/distribution/hosting-slack-apps/). +1. Open a direct message with your app or invite the bot `@BoltApp` to a public channel. +2. Send "hello" to the current conversation and wait for a response. +3. Click the attached button labelled "Click Me" to post another reply. -::: +After confirming the app responds, celebrate, then interrupt the process by pressing `CTRL+C` in the terminal to stop your app from running. -Navigate to **Event Subscriptions** on the left sidebar and toggle to enable. Under **Subscribe to Bot Events**, you can add events for your bot to respond to. There are four events related to messages: -- [`message.channels`](https://docs.slack.dev/reference/events/message.channels) listens for messages in public channels that your app is added to -- [`message.groups`](https://docs.slack.dev/reference/events/message.groups) listens for messages in 🔒 private channels that your app is added to -- [`message.im`](https://docs.slack.dev/reference/events/message.im) listens for messages in your app's DMs with users -- [`message.mpim`](https://docs.slack.dev/reference/events/message.mpim) listens for messages in multi-person DMs that your app is added to +## Updating the app -If you want your bot to listen to messages from everywhere it is added to, choose all four message events. After you’ve selected the events you want your bot to listen to, click the green **Save Changes** button. +At this point, you've successfully run the getting started Bolt for Python [app](https://github.com/slack-samples/bolt-python-getting-started-app)! ---- +The defaults included leave opportunities abound, so to personalize this app let's now edit the code to respond with a kind farewell. -### Listening and responding to a message {#listening-and-responding-to-a-message} -Your app is now ready for some logic. Let's start by using the `message()` method to attach a listener for messages. +#### Responding to a farewell -The following example listens and responds to all messages in channels/DMs where your app has been added that contain the word "hello": +Chat is a common thing apps do and responding to various types of messages can make conversations more interesting. - - +Using an editor of choice, open the `app.py` file and add the following import to the top of the file, and message listener after the "hello" handler: ```python -import os -from slack_bolt import App -from slack_bolt.adapter.socket_mode import SocketModeHandler - -# Initializes your app with your bot token and socket mode handler -app = App(token=os.environ.get("SLACK_BOT_TOKEN")) - -# Listens to incoming messages that contain "hello" -# To learn available listener arguments, -# visit https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html -@app.message("hello") -def message_hello(message, say): - # say() sends a message to the channel where the event was triggered - say(f"Hey there <@{message['user']}>!") - -# Start your app -if __name__ == "__main__": - SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() -``` - - - +import random -```python -import os -from slack_bolt import App - -# Initializes your app with your bot token and signing secret -app = App( - token=os.environ.get("SLACK_BOT_TOKEN"), - signing_secret=os.environ.get("SLACK_SIGNING_SECRET") -) - -# Listens to incoming messages that contain "hello" -# To learn available listener arguments, -# visit https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html -@app.message("hello") -def message_hello(message, say): - # say() sends a message to the channel where the event was triggered - say(f"Hey there <@{message['user']}>!") - -# Start your app -if __name__ == "__main__": - app.start(port=int(os.environ.get("PORT", 3000))) +@app.message("goodbye") +def message_goodbye(say): + responses = ["Adios", "Au revoir", "Farewell"] + parting = random.choice(responses) + say(f"{parting}!") ``` - - - -If you restart your app, so long as your bot user has been added to the channel/DM, when you send any message that contains "hello", it will respond. +Once the file is updated, save the changes and then we'll make sure those changes are being used. -This is a basic example, but it gives you a place to start customizing your app based on your own goals. Let's try something a little more interactive by sending a button rather than plain text. + + ---- +Run the following command and select the app created earlier to start, or restart, your app with the latest changes: -### Sending and responding to actions {#sending-and-responding-to-actions} +```sh +$ slack run +... +⚡️ Bolt app is running! +``` -To use features like buttons, select menus, datepickers, modals, and shortcuts, you’ll need to enable interactivity. Head over to **Interactivity & Shortcuts** in your app configuration. +After finding the above output appears, open Slack to perform these steps: - - +1. Return to the direct message or public channel with your bot. +2. Send "goodbye" to the conversation. +3. Receive a parting response from before and repeat "goodbye" to find another one. -With Socket Mode on, basic interactivity is enabled by default, so no further action is needed. +Your app can be stopped again by pressing `CTRL+C` in the terminal to end these chats. - + -Similar to events, you'll need to specify a URL for Slack to send the action (such as *user clicked a button*). Back on your app configuration page, click on **Interactivity & Shortcuts** on the left side. You'll see that there's another **Request URL** box. +Run the following command to start, or restart, your app with the latest changes: -:::tip +```sh +$ python3 app.py +... +⚡️ Bolt app is running! +``` -By default, Bolt is configured to use the same endpoint for interactive components that it uses for events, so use the same request URL as above (for example, `https://8e8ec2d7.ngrok.io/slack/events`). Press the **Save Changes** button in the lower right hand corner, and that's it. Your app is set up to handle interactivity! +After finding the above output appears, open Slack to perform these steps: -::: +1. Return to the direct message or public channel with your bot. +2. Send "goodbye" to the conversation. +3. Receive a parting response from before and repeat "goodbye" to find another one. + +Your app can be stopped again by pressing `CTRL+C` in the terminal to end these chats. -When interactivity is enabled, interactions with shortcuts, modals, or interactive components (such as buttons, select menus, and datepickers) will be sent to your app as events. +#### Customizing app settings -Now, let's go back to your app's code and add logic to handle those events: -- First, we'll send a message that contains an interactive component (in this case a button) -- Next, we'll listen for the action of a user clicking the button before responding +The created app will have some placeholder values and a small set of [scopes](https://docs.slack.dev/reference/scopes) to start, but we recommend exploring the customizations possible on app settings. -Below, the code from the last section is modified to send a message containing a button rather than just a string: + + - - - -```python -import os -from slack_bolt import App -from slack_bolt.adapter.socket_mode import SocketModeHandler - -# Initializes your app with your bot token and socket mode handler -app = App( - token=os.environ.get("SLACK_BOT_TOKEN"), - # signing_secret=os.environ.get("SLACK_SIGNING_SECRET") # not required for socket mode -) - -# Listens to incoming messages that contain "hello" -@app.message("hello") -def message_hello(message, say): - # say() sends a message to the channel where the event was triggered - say( - blocks=[ - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Click Me"}, - "action_id": "button_click" - } - } - ], - text=f"Hey there <@{message['user']}>!" - ) - -# Start your app -if __name__ == "__main__": - SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() +Open app settings for your app with the following command: +```sh +$ slack app settings ``` - - +This will open the following page in a web browser: -```python -import os -from slack_bolt import App - -# Initializes your app with your bot token and signing secret -app = App( - token=os.environ.get("SLACK_BOT_TOKEN"), - signing_secret=os.environ.get("SLACK_SIGNING_SECRET") -) - -# Listens to incoming messages that contain "hello" -@app.message("hello") -def message_hello(message, say): - # say() sends a message to the channel where the event was triggered - say( - blocks=[ - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Click Me"}, - "action_id": "button_click" - } - } - ], - text=f"Hey there <@{message['user']}>!" - ) - -# Start your app -if __name__ == "__main__": - app.start(port=int(os.environ.get("PORT", 3000))) -``` +![Basic Information page](/img/boltpy/basic-information-page.png "Basic Information page") - - -The value inside of `say()` is now an object that contains an array of `blocks`. Blocks are the building components of a Slack message and can range from text to images to datepickers. In this case, your app will respond with a section block that includes a button as an accessory. Since we're using `blocks`, the `text` is a fallback for notifications and accessibility. - -You'll notice in the button `accessory` object, there is an `action_id`. This will act as a unique identifier for the button so your app can specify what action it wants to respond to. - -:::tip - -The [Block Kit Builder](https://app.slack.com/block-kit-builder) is an simple way to prototype your interactive messages. The builder lets you (or anyone on your team) mockup messages and generates the corresponding JSON that you can paste directly in your app. - -::: - -Now, if you restart your app and say "hello" in a channel your app is in, you'll see a message with a button. But if you click the button, nothing happens (*yet!*). - -Let's add a handler to send a followup message when someone clicks the button: + - - +Browse to https://api.slack.com/apps and select your app "Getting Started Bolt App" from the list. -```python -import os -from slack_bolt import App -from slack_bolt.adapter.socket_mode import SocketModeHandler - -# Initializes your app with your bot token and socket mode handler -app = App(token=os.environ.get("SLACK_BOT_TOKEN")) - -# Listens to incoming messages that contain "hello" -@app.message("hello") -def message_hello(message, say): - # say() sends a message to the channel where the event was triggered - say( - blocks=[ - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Click Me"}, - "action_id": "button_click" - } - } - ], - text=f"Hey there <@{message['user']}>!" - ) - -@app.action("button_click") -def action_button_click(body, ack, say): - # Acknowledge the action - ack() - say(f"<@{body['user']['id']}> clicked the button") - -# Start your app -if __name__ == "__main__": - SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() -``` +This will open the following page: - - - -```python -import os -from slack_bolt import App - -# Initializes your app with your bot token and signing secret -app = App( - token=os.environ.get("SLACK_BOT_TOKEN"), - signing_secret=os.environ.get("SLACK_SIGNING_SECRET") -) - -# Listens to incoming messages that contain "hello" -@app.message("hello") -def message_hello(message, say): - # say() sends a message to the channel where the event was triggered - say( - blocks=[ - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Click Me"}, - "action_id": "button_click" - } - } - ], - text=f"Hey there <@{message['user']}>!" - ) - -@app.action("button_click") -def action_button_click(body, ack, say): - # Acknowledge the action - ack() - say(f"<@{body['user']['id']}> clicked the button") - -# Start your app -if __name__ == "__main__": - app.start(port=int(os.environ.get("PORT", 3000))) -``` +![Basic Information page](/img/boltpy/basic-information-page.png "Basic Information page") -You can see that we used `app.action()` to listen for the `action_id` that we named `button_click`. If you restart your app and click the button, you'll see a new message from your app that says you clicked the button. +On these pages you're free to make changes such as updating your app icon, configuring app features, and perhaps even distributing your app! ---- +## Next steps {#next-steps} -### Next steps {#next-steps} -You just built your first [Bolt for Python app](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started)! 🎉 +Congrats once more on getting up and running with this quick start. -Now that you have a basic app up and running, you can start exploring how to make your Bolt app stand out. Here are some ideas about what to explore next: +:::info[Dive deeper] -* Read through the _Basic concepts_ to learn about the different methods and features your Bolt app has access to. +Follow along with the steps that went into making this app on the [building an app](/building-an-app) guide for an educational overview. -* Explore the different events your bot can listen to with the [`app.event()`](/concepts/event-listening) method. All of the events are listed [on the API site](https://docs.slack.dev/reference/events). +::: -* Bolt allows you to [call Web API methods](/concepts/web-api) with the client attached to your app. There are [over 220 methods](https://docs.slack.dev/reference/methods) on our API site. +You can now continue customizing your app with various features to make it right for whatever job's at hand. Here are some ideas about what to explore next: -* Learn more about the different token types [on our API site](https://docs.slack.dev/authentication/tokens). Your app may need different tokens depending on the actions you want it to perform. +- Explore the different events your bot can listen to with the [`app.event()`](/concepts/event-listening) method. All of the [events](https://docs.slack.dev/reference/events) are listed on the API docs site. +- Bolt allows you to call [Web API](/concepts/web-api) methods with the client attached to your app. There are [over 200 methods](https://docs.slack.dev/reference/methods) on the API docs site. +- Learn more about the different [token types](https://docs.slack.dev/authentication/tokens) and [authentication setups](/concepts/authenticating-oauth). Your app might need different tokens depending on the actions you want to perform or for installations to multiple workspaces. +- Receive events using HTTP for various deployment methods, such as deploying to [Heroku](/deployments/heroku) or [AWS Lambda](/deployments/aws-lambda). +- Read on [app design](https://docs.slack.dev/surfaces/app-design) and compose fancy messages with blocks using [Block Kit Builder](https://app.slack.com/block-kit-builder) to prototype messages. diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 60f3eae81..0d80161e6 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -98,6 +98,7 @@ const config = { // switch to alucard when available in prism? theme: prismThemes.github, darkTheme: prismThemes.dracula, + additionalLanguages: ['bash'], }, codeblock: { showGithubLink: true, diff --git a/docs/sidebars.js b/docs/sidebars.js index 65c5e85f4..decb8cccb 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -7,13 +7,17 @@ const sidebars = { label: 'Bolt for Python', className: 'sidebar-title', }, + { + type: 'doc', + id: 'getting-started', + }, { type: 'html', value: '
    ' }, { type: 'category', label: 'Guides', collapsed: false, items: [ - "getting-started", + "building-an-app", { type: "category", label: "Slack API calls", From 227ca8e8936dffa93938591dabb14ddc79dc8a18 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 00:21:59 +0000 Subject: [PATCH 769/865] chore(deps): bump the docusaurus group in /docs with 5 updates (#1325) --- docs/package-lock.json | 1072 +++++++++++++++------------------------- docs/package.json | 10 +- 2 files changed, 392 insertions(+), 690 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 4e7d7b445..6790558aa 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -8,9 +8,9 @@ "name": "website", "version": "2024.08.01", "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/plugin-client-redirects": "^3.8.0", - "@docusaurus/preset-classic": "3.8.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/plugin-client-redirects": "^3.8.1", + "@docusaurus/preset-classic": "3.8.1", "@mdx-js/react": "^3.1.0", "clsx": "^2.0.0", "docusaurus-theme-github-codeblock": "^2.0.2", @@ -19,8 +19,8 @@ "react-dom": "^19.1.0" }, "devDependencies": { - "@docusaurus/module-type-aliases": "3.8.0", - "@docusaurus/types": "3.8.0" + "@docusaurus/module-type-aliases": "3.8.1", + "@docusaurus/types": "3.8.1" }, "engines": { "node": ">=20.0" @@ -30,7 +30,6 @@ "version": "1.17.9", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.9.tgz", "integrity": "sha512-O7BxrpLDPJWWHv/DLA9DRFWs+iY1uOJZkqUwjS5HSZAGcl0hIVCQ97LTLewiZmZ402JYUrun+8NqFP+hCknlbQ==", - "license": "MIT", "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.9", "@algolia/autocomplete-shared": "1.17.9" @@ -40,7 +39,6 @@ "version": "1.17.9", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.9.tgz", "integrity": "sha512-u1fEHkCbWF92DBeB/KHeMacsjsoI0wFhjZtlCq2ddZbAehshbZST6Hs0Avkc0s+4UyBGbMDnSuXHLuvRWK5iDQ==", - "license": "MIT", "dependencies": { "@algolia/autocomplete-shared": "1.17.9" }, @@ -52,7 +50,6 @@ "version": "1.17.9", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.9.tgz", "integrity": "sha512-Na1OuceSJeg8j7ZWn5ssMu/Ax3amtOwk76u4h5J4eK2Nx2KB5qt0Z4cOapCsxot9VcEN11ADV5aUSlQF4RhGjQ==", - "license": "MIT", "dependencies": { "@algolia/autocomplete-shared": "1.17.9" }, @@ -65,106 +62,98 @@ "version": "1.17.9", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.9.tgz", "integrity": "sha512-iDf05JDQ7I0b7JEA/9IektxN/80a2MZ1ToohfmNS3rfeuQnIKI3IJlIafD0xu4StbtQTghx9T3Maa97ytkXenQ==", - "license": "MIT", "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "node_modules/@algolia/client-abtesting": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.25.0.tgz", - "integrity": "sha512-1pfQulNUYNf1Tk/svbfjfkLBS36zsuph6m+B6gDkPEivFmso/XnRgwDvjAx80WNtiHnmeNjIXdF7Gos8+OLHqQ==", - "license": "MIT", + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.30.0.tgz", + "integrity": "sha512-Q3OQXYlTNqVUN/V1qXX8VIzQbLjP3yrRBO9m6NRe1CBALmoGHh9JrYosEGvfior28+DjqqU3Q+nzCSuf/bX0Gw==", "dependencies": { - "@algolia/client-common": "5.25.0", - "@algolia/requester-browser-xhr": "5.25.0", - "@algolia/requester-fetch": "5.25.0", - "@algolia/requester-node-http": "5.25.0" + "@algolia/client-common": "5.30.0", + "@algolia/requester-browser-xhr": "5.30.0", + "@algolia/requester-fetch": "5.30.0", + "@algolia/requester-node-http": "5.30.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.25.0.tgz", - "integrity": "sha512-AFbG6VDJX/o2vDd9hqncj1B6B4Tulk61mY0pzTtzKClyTDlNP0xaUiEKhl6E7KO9I/x0FJF5tDCm0Hn6v5x18A==", - "license": "MIT", + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.30.0.tgz", + "integrity": "sha512-/b+SAfHjYjx/ZVeVReCKTTnFAiZWOyvYLrkYpeNMraMT6akYRR8eC1AvFcvR60GLG/jytxcJAp42G8nN5SdcLg==", "dependencies": { - "@algolia/client-common": "5.25.0", - "@algolia/requester-browser-xhr": "5.25.0", - "@algolia/requester-fetch": "5.25.0", - "@algolia/requester-node-http": "5.25.0" + "@algolia/client-common": "5.30.0", + "@algolia/requester-browser-xhr": "5.30.0", + "@algolia/requester-fetch": "5.30.0", + "@algolia/requester-node-http": "5.30.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.25.0.tgz", - "integrity": "sha512-il1zS/+Rc6la6RaCdSZ2YbJnkQC6W1wiBO8+SH+DE6CPMWBU6iDVzH0sCKSAtMWl9WBxoN6MhNjGBnCv9Yy2bA==", - "license": "MIT", + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.30.0.tgz", + "integrity": "sha512-tbUgvkp2d20mHPbM0+NPbLg6SzkUh0lADUUjzNCF+HiPkjFRaIW3NGMlESKw5ia4Oz6ZvFzyREquUX6rdkdJcQ==", "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-insights": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.25.0.tgz", - "integrity": "sha512-blbjrUH1siZNfyCGeq0iLQu00w3a4fBXm0WRIM0V8alcAPo7rWjLbMJMrfBtzL9X5ic6wgxVpDADXduGtdrnkw==", - "license": "MIT", + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.30.0.tgz", + "integrity": "sha512-caXuZqJK761m32KoEAEkjkE2WF/zYg1McuGesWXiLSgfxwZZIAf+DljpiSToBUXhoPesvjcLtINyYUzbkwE0iw==", "dependencies": { - "@algolia/client-common": "5.25.0", - "@algolia/requester-browser-xhr": "5.25.0", - "@algolia/requester-fetch": "5.25.0", - "@algolia/requester-node-http": "5.25.0" + "@algolia/client-common": "5.30.0", + "@algolia/requester-browser-xhr": "5.30.0", + "@algolia/requester-fetch": "5.30.0", + "@algolia/requester-node-http": "5.30.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.25.0.tgz", - "integrity": "sha512-aywoEuu1NxChBcHZ1pWaat0Plw7A8jDMwjgRJ00Mcl7wGlwuPt5dJ/LTNcg3McsEUbs2MBNmw0ignXBw9Tbgow==", - "license": "MIT", + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.30.0.tgz", + "integrity": "sha512-7K6P7TRBHLX1zTmwKDrIeBSgUidmbj6u3UW/AfroLRDGf9oZFytPKU49wg28lz/yulPuHY0nZqiwbyAxq9V17w==", "dependencies": { - "@algolia/client-common": "5.25.0", - "@algolia/requester-browser-xhr": "5.25.0", - "@algolia/requester-fetch": "5.25.0", - "@algolia/requester-node-http": "5.25.0" + "@algolia/client-common": "5.30.0", + "@algolia/requester-browser-xhr": "5.30.0", + "@algolia/requester-fetch": "5.30.0", + "@algolia/requester-node-http": "5.30.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.25.0.tgz", - "integrity": "sha512-a/W2z6XWKjKjIW1QQQV8PTTj1TXtaKx79uR3NGBdBdGvVdt24KzGAaN7sCr5oP8DW4D3cJt44wp2OY/fZcPAVA==", - "license": "MIT", + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.30.0.tgz", + "integrity": "sha512-WMjWuBjYxJheRt7Ec5BFr33k3cV0mq2WzmH9aBf5W4TT8kUp34x91VRsYVaWOBRlxIXI8o/WbhleqSngiuqjLA==", "dependencies": { - "@algolia/client-common": "5.25.0", - "@algolia/requester-browser-xhr": "5.25.0", - "@algolia/requester-fetch": "5.25.0", - "@algolia/requester-node-http": "5.25.0" + "@algolia/client-common": "5.30.0", + "@algolia/requester-browser-xhr": "5.30.0", + "@algolia/requester-fetch": "5.30.0", + "@algolia/requester-node-http": "5.30.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.25.0.tgz", - "integrity": "sha512-9rUYcMIBOrCtYiLX49djyzxqdK9Dya/6Z/8sebPn94BekT+KLOpaZCuc6s0Fpfq7nx5J6YY5LIVFQrtioK9u0g==", - "license": "MIT", + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.30.0.tgz", + "integrity": "sha512-puc1/LREfSqzgmrOFMY5L/aWmhYOlJ0TTpa245C0ZNMKEkdOkcimFbXTXQ8lZhzh+rlyFgR7cQGNtXJ5H0XgZg==", "dependencies": { - "@algolia/client-common": "5.25.0", - "@algolia/requester-browser-xhr": "5.25.0", - "@algolia/requester-fetch": "5.25.0", - "@algolia/requester-node-http": "5.25.0" + "@algolia/client-common": "5.30.0", + "@algolia/requester-browser-xhr": "5.30.0", + "@algolia/requester-fetch": "5.30.0", + "@algolia/requester-node-http": "5.30.0" }, "engines": { "node": ">= 14.0.0" @@ -173,85 +162,78 @@ "node_modules/@algolia/events": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz", - "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==", - "license": "MIT" + "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==" }, "node_modules/@algolia/ingestion": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.25.0.tgz", - "integrity": "sha512-jJeH/Hk+k17Vkokf02lkfYE4A+EJX+UgnMhTLR/Mb+d1ya5WhE+po8p5a/Nxb6lo9OLCRl6w3Hmk1TX1e9gVbQ==", - "license": "MIT", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.30.0.tgz", + "integrity": "sha512-NfqiIKVgGKTLr6T9F81oqB39pPiEtILTy0z8ujxPKg2rCvI/qQeDqDWFBmQPElCfUTU6kk67QAgMkQ7T6fE+gg==", "dependencies": { - "@algolia/client-common": "5.25.0", - "@algolia/requester-browser-xhr": "5.25.0", - "@algolia/requester-fetch": "5.25.0", - "@algolia/requester-node-http": "5.25.0" + "@algolia/client-common": "5.30.0", + "@algolia/requester-browser-xhr": "5.30.0", + "@algolia/requester-fetch": "5.30.0", + "@algolia/requester-node-http": "5.30.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.25.0.tgz", - "integrity": "sha512-Ls3i1AehJ0C6xaHe7kK9vPmzImOn5zBg7Kzj8tRYIcmCWVyuuFwCIsbuIIz/qzUf1FPSWmw0TZrGeTumk2fqXg==", - "license": "MIT", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.30.0.tgz", + "integrity": "sha512-/eeM3aqLKro5KBZw0W30iIA6afkGa+bcpvEM0NDa92m5t3vil4LOmJI9FkgzfmSkF4368z/SZMOTPShYcaVXjA==", "dependencies": { - "@algolia/client-common": "5.25.0", - "@algolia/requester-browser-xhr": "5.25.0", - "@algolia/requester-fetch": "5.25.0", - "@algolia/requester-node-http": "5.25.0" + "@algolia/client-common": "5.30.0", + "@algolia/requester-browser-xhr": "5.30.0", + "@algolia/requester-fetch": "5.30.0", + "@algolia/requester-node-http": "5.30.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.25.0.tgz", - "integrity": "sha512-79sMdHpiRLXVxSjgw7Pt4R1aNUHxFLHiaTDnN2MQjHwJ1+o3wSseb55T9VXU4kqy3m7TUme3pyRhLk5ip/S4Mw==", - "license": "MIT", + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.30.0.tgz", + "integrity": "sha512-iWeAUWqw+xT+2IyUyTqnHCK+cyCKYV5+B6PXKdagc9GJJn6IaPs8vovwoC0Za5vKCje/aXQ24a2Z1pKpc/tdHg==", "dependencies": { - "@algolia/client-common": "5.25.0", - "@algolia/requester-browser-xhr": "5.25.0", - "@algolia/requester-fetch": "5.25.0", - "@algolia/requester-node-http": "5.25.0" + "@algolia/client-common": "5.30.0", + "@algolia/requester-browser-xhr": "5.30.0", + "@algolia/requester-fetch": "5.30.0", + "@algolia/requester-node-http": "5.30.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.25.0.tgz", - "integrity": "sha512-JLaF23p1SOPBmfEqozUAgKHQrGl3z/Z5RHbggBu6s07QqXXcazEsub5VLonCxGVqTv6a61AAPr8J1G5HgGGjEw==", - "license": "MIT", + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.30.0.tgz", + "integrity": "sha512-alo3ly0tdNLjfMSPz9dmNwYUFHx7guaz5dTGlIzVGnOiwLgIoM6NgA+MJLMcH6e1S7OpmE2AxOy78svlhst2tQ==", "dependencies": { - "@algolia/client-common": "5.25.0" + "@algolia/client-common": "5.30.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.25.0.tgz", - "integrity": "sha512-rtzXwqzFi1edkOF6sXxq+HhmRKDy7tz84u0o5t1fXwz0cwx+cjpmxu/6OQKTdOJFS92JUYHsG51Iunie7xbqfQ==", - "license": "MIT", + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.30.0.tgz", + "integrity": "sha512-WOnTYUIY2InllHBy6HHMpGIOo7Or4xhYUx/jkoSK/kPIa1BRoFEHqa8v4pbKHtoG7oLvM2UAsylSnjVpIhGZXg==", "dependencies": { - "@algolia/client-common": "5.25.0" + "@algolia/client-common": "5.30.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.25.0.tgz", - "integrity": "sha512-ZO0UKvDyEFvyeJQX0gmZDQEvhLZ2X10K+ps6hViMo1HgE2V8em00SwNsQ+7E/52a+YiBkVWX61pJJJE44juDMQ==", - "license": "MIT", + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.30.0.tgz", + "integrity": "sha512-uSTUh9fxeHde1c7KhvZKUrivk90sdiDftC+rSKNFKKEU9TiIKAGA7B2oKC+AoMCqMymot1vW9SGbeESQPTZd0w==", "dependencies": { - "@algolia/client-common": "5.25.0" + "@algolia/client-common": "5.30.0" }, "engines": { "node": ">= 14.0.0" @@ -731,7 +713,6 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", - "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -1379,7 +1360,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz", "integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==", - "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1499,7 +1479,6 @@ "version": "7.27.4", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.27.4.tgz", "integrity": "sha512-D68nR5zxU64EUzV8i7T3R5XP0Xhrou/amNnddsRQssx6GrTLdZl1rLxyjtVZBd+v/NVX4AbTPOB5aU8thAZV1A==", - "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", @@ -1519,7 +1498,6 @@ "version": "0.11.1", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", - "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.3", "core-js-compat": "^3.40.0" @@ -1532,7 +1510,6 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", "bin": { "semver": "bin/semver.js" } @@ -1838,10 +1815,9 @@ } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.27.4.tgz", - "integrity": "sha512-H7QhL0ucCGOObsUETNbB2PuzF4gAvN8p32P6r91bX7M/hk4bx+3yz2hTwHL9d/Efzwu1upeb4/cd7oSxCzup3w==", - "license": "MIT", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.27.6.tgz", + "integrity": "sha512-vDVrlmRAY8z9Ul/HxT+8ceAru95LQgkSKiXkSYZvqtbkPSfhZJgpRp45Cldbh1GJ1kxzQkI70AqyrTI58KpaWQ==", "dependencies": { "core-js-pure": "^3.30.2" }, @@ -1918,7 +1894,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "engines": { "node": ">=18" }, @@ -1941,7 +1916,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" } @@ -1960,7 +1934,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "engines": { "node": ">=18" }, @@ -1983,7 +1956,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "dependencies": { "@csstools/color-helpers": "^5.0.2", "@csstools/css-calc": "^2.1.4" @@ -2010,7 +1982,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "engines": { "node": ">=18" }, @@ -2032,7 +2003,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "engines": { "node": ">=18" } @@ -2051,7 +2021,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "engines": { "node": ">=18" }, @@ -2061,9 +2030,9 @@ } }, "node_modules/@csstools/postcss-cascade-layers": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-5.0.1.tgz", - "integrity": "sha512-XOfhI7GShVcKiKwmPAnWSqd2tBR0uxt+runAxttbSp/LY2U16yAVPmAf7e9q4JJ0d+xMNmpwNDLBXnmRCl3HMQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-5.0.2.tgz", + "integrity": "sha512-nWBE08nhO8uWl6kSAeCx4im7QfVko3zLrtgWZY4/bP87zrSPpSyN/3W3TDqz1jJuH+kbKOHXg5rJnK+ZVYcFFg==", "funding": [ { "type": "github", @@ -2074,7 +2043,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/selector-specificity": "^5.0.0", "postcss-selector-parser": "^7.0.0" @@ -2100,7 +2068,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" }, @@ -2112,7 +2079,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -2135,7 +2101,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-color-parser": "^3.0.10", "@csstools/css-parser-algorithms": "^3.0.5", @@ -2164,7 +2129,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-color-parser": "^3.0.10", "@csstools/css-parser-algorithms": "^3.0.5", @@ -2193,7 +2157,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-color-parser": "^3.0.10", "@csstools/css-parser-algorithms": "^3.0.5", @@ -2222,7 +2185,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -2250,7 +2212,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-parser-algorithms": "^3.0.5", @@ -2277,7 +2238,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" @@ -2303,7 +2263,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-color-parser": "^3.0.10", "@csstools/css-parser-algorithms": "^3.0.5", @@ -2330,7 +2289,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-color-parser": "^3.0.10", "@csstools/css-parser-algorithms": "^3.0.5", @@ -2359,7 +2317,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-color-parser": "^3.0.10", "@csstools/css-parser-algorithms": "^3.0.5", @@ -2388,7 +2345,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0", @@ -2415,7 +2371,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" }, @@ -2424,9 +2379,9 @@ } }, "node_modules/@csstools/postcss-is-pseudo-class": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-5.0.1.tgz", - "integrity": "sha512-JLp3POui4S1auhDR0n8wHd/zTOWmMsmK3nQd3hhL6FhWPaox5W7j1se6zXOG/aP07wV2ww0lxbKYGwbBszOtfQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-5.0.3.tgz", + "integrity": "sha512-jS/TY4SpG4gszAtIg7Qnf3AS2pjcUM5SzxpApOrlndMeGhIbaTzWBzzP/IApXoNWEW7OhcjkRT48jnAUIFXhAQ==", "funding": [ { "type": "github", @@ -2437,7 +2392,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/selector-specificity": "^5.0.0", "postcss-selector-parser": "^7.0.0" @@ -2463,7 +2417,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" }, @@ -2475,7 +2428,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -2498,7 +2450,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -2526,7 +2477,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" }, @@ -2548,7 +2498,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" }, @@ -2570,7 +2519,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" }, @@ -2592,7 +2540,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -2617,7 +2564,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-tokenizer": "^3.0.4", "@csstools/utilities": "^2.0.0" @@ -2643,7 +2589,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-parser-algorithms": "^3.0.5", @@ -2671,7 +2616,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -2698,7 +2642,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" @@ -2724,7 +2667,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -2749,7 +2691,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-color-parser": "^3.0.10", "@csstools/css-parser-algorithms": "^3.0.5", @@ -2778,7 +2719,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -2803,7 +2743,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-parser-algorithms": "^3.0.5", @@ -2830,7 +2769,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-color-parser": "^3.0.10", "@csstools/css-parser-algorithms": "^3.0.5", @@ -2859,7 +2797,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "postcss-selector-parser": "^7.0.0" }, @@ -2874,7 +2811,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -2897,7 +2833,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-parser-algorithms": "^3.0.5", @@ -2924,7 +2859,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-parser-algorithms": "^3.0.5", @@ -2951,7 +2885,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/color-helpers": "^5.0.2", "postcss-value-parser": "^4.2.0" @@ -2977,7 +2910,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-parser-algorithms": "^3.0.5", @@ -3004,7 +2936,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" }, @@ -3026,7 +2957,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" }, @@ -3046,14 +2976,12 @@ "node_modules/@docsearch/css": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.9.0.tgz", - "integrity": "sha512-cQbnVbq0rrBwNAKegIac/t6a8nWoUAn8frnkLFW6YARaRmAQr5/Eoe6Ln2fqkUCZ40KpdrKbpSAmgrkviOxuWA==", - "license": "MIT" + "integrity": "sha512-cQbnVbq0rrBwNAKegIac/t6a8nWoUAn8frnkLFW6YARaRmAQr5/Eoe6Ln2fqkUCZ40KpdrKbpSAmgrkviOxuWA==" }, "node_modules/@docsearch/react": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.9.0.tgz", "integrity": "sha512-mb5FOZYZIkRQ6s/NWnM98k879vu5pscWqTLubLFBO87igYYT4VzVazh4h5o/zCvTIZgEt3PvsCOMOswOUo9yHQ==", - "license": "MIT", "dependencies": { "@algolia/autocomplete-core": "1.17.9", "@algolia/autocomplete-preset-algolia": "1.17.9", @@ -3082,10 +3010,9 @@ } }, "node_modules/@docusaurus/babel": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.8.0.tgz", - "integrity": "sha512-9EJwSgS6TgB8IzGk1L8XddJLhZod8fXT4ULYMx6SKqyCBqCFpVCEjR/hNXXhnmtVM2irDuzYoVLGWv7srG/VOA==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.8.1.tgz", + "integrity": "sha512-3brkJrml8vUbn9aeoZUlJfsI/GqyFcDgQJwQkmBtclJgWDEQBKKeagZfOgx0WfUQhagL1sQLNW0iBdxnI863Uw==", "dependencies": { "@babel/core": "^7.25.9", "@babel/generator": "^7.25.9", @@ -3097,8 +3024,8 @@ "@babel/runtime": "^7.25.9", "@babel/runtime-corejs3": "^7.25.9", "@babel/traverse": "^7.25.9", - "@docusaurus/logger": "3.8.0", - "@docusaurus/utils": "3.8.0", + "@docusaurus/logger": "3.8.1", + "@docusaurus/utils": "3.8.1", "babel-plugin-dynamic-import-node": "^2.3.3", "fs-extra": "^11.1.1", "tslib": "^2.6.0" @@ -3108,30 +3035,29 @@ } }, "node_modules/@docusaurus/bundler": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.8.0.tgz", - "integrity": "sha512-Rq4Z/MSeAHjVzBLirLeMcjLIAQy92pF1OI+2rmt18fSlMARfTGLWRE8Vb+ljQPTOSfJxwDYSzsK6i7XloD2rNA==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.8.1.tgz", + "integrity": "sha512-/z4V0FRoQ0GuSLToNjOSGsk6m2lQUG4FRn8goOVoZSRsTrU8YR2aJacX5K3RG18EaX9b+52pN4m1sL3MQZVsQA==", "dependencies": { "@babel/core": "^7.25.9", - "@docusaurus/babel": "3.8.0", - "@docusaurus/cssnano-preset": "3.8.0", - "@docusaurus/logger": "3.8.0", - "@docusaurus/types": "3.8.0", - "@docusaurus/utils": "3.8.0", + "@docusaurus/babel": "3.8.1", + "@docusaurus/cssnano-preset": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", "babel-loader": "^9.2.1", - "clean-css": "^5.3.2", + "clean-css": "^5.3.3", "copy-webpack-plugin": "^11.0.0", - "css-loader": "^6.8.1", + "css-loader": "^6.11.0", "css-minimizer-webpack-plugin": "^5.0.1", "cssnano": "^6.1.2", "file-loader": "^6.2.0", "html-minifier-terser": "^7.2.0", - "mini-css-extract-plugin": "^2.9.1", + "mini-css-extract-plugin": "^2.9.2", "null-loader": "^4.0.1", - "postcss": "^8.4.26", - "postcss-loader": "^7.3.3", - "postcss-preset-env": "^10.1.0", + "postcss": "^8.5.4", + "postcss-loader": "^7.3.4", + "postcss-preset-env": "^10.2.1", "terser-webpack-plugin": "^5.3.9", "tslib": "^2.6.0", "url-loader": "^4.1.1", @@ -3151,18 +3077,17 @@ } }, "node_modules/@docusaurus/core": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.8.0.tgz", - "integrity": "sha512-c7u6zFELmSGPEP9WSubhVDjgnpiHgDqMh1qVdCB7rTflh4Jx0msTYmMiO91Ez0KtHj4sIsDsASnjwfJ2IZp3Vw==", - "license": "MIT", - "dependencies": { - "@docusaurus/babel": "3.8.0", - "@docusaurus/bundler": "3.8.0", - "@docusaurus/logger": "3.8.0", - "@docusaurus/mdx-loader": "3.8.0", - "@docusaurus/utils": "3.8.0", - "@docusaurus/utils-common": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.8.1.tgz", + "integrity": "sha512-ENB01IyQSqI2FLtOzqSI3qxG2B/jP4gQPahl2C3XReiLebcVh5B5cB9KYFvdoOqOWPyr5gXK4sjgTKv7peXCrA==", + "dependencies": { + "@docusaurus/babel": "3.8.1", + "@docusaurus/bundler": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/mdx-loader": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "boxen": "^6.2.1", "chalk": "^4.1.2", "chokidar": "^3.5.3", @@ -3212,13 +3137,12 @@ } }, "node_modules/@docusaurus/cssnano-preset": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.8.0.tgz", - "integrity": "sha512-UJ4hAS2T0R4WNy+phwVff2Q0L5+RXW9cwlH6AEphHR5qw3m/yacfWcSK7ort2pMMbDn8uGrD38BTm4oLkuuNoQ==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.8.1.tgz", + "integrity": "sha512-G7WyR2N6SpyUotqhGznERBK+x84uyhfMQM2MmDLs88bw4Flom6TY46HzkRkSEzaP9j80MbTN8naiL1fR17WQug==", "dependencies": { "cssnano-preset-advanced": "^6.1.2", - "postcss": "^8.4.38", + "postcss": "^8.5.4", "postcss-sort-media-queries": "^5.2.0", "tslib": "^2.6.0" }, @@ -3227,10 +3151,9 @@ } }, "node_modules/@docusaurus/logger": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.8.0.tgz", - "integrity": "sha512-7eEMaFIam5Q+v8XwGqF/n0ZoCld4hV4eCCgQkfcN9Mq5inoZa6PHHW9Wu6lmgzoK5Kx3keEeABcO2SxwraoPDQ==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.8.1.tgz", + "integrity": "sha512-2wjeGDhKcExEmjX8k1N/MRDiPKXGF2Pg+df/bDDPnnJWHXnVEZxXj80d6jcxp1Gpnksl0hF8t/ZQw9elqj2+ww==", "dependencies": { "chalk": "^4.1.2", "tslib": "^2.6.0" @@ -3240,14 +3163,13 @@ } }, "node_modules/@docusaurus/mdx-loader": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.8.0.tgz", - "integrity": "sha512-mDPSzssRnpjSdCGuv7z2EIAnPS1MHuZGTaRLwPn4oQwszu4afjWZ/60sfKjTnjBjI8Vl4OgJl2vMmfmiNDX4Ng==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.8.1.tgz", + "integrity": "sha512-DZRhagSFRcEq1cUtBMo4TKxSNo/W6/s44yhr8X+eoXqCLycFQUylebOMPseHi5tc4fkGJqwqpWJLz6JStU9L4w==", "dependencies": { - "@docusaurus/logger": "3.8.0", - "@docusaurus/utils": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "@docusaurus/logger": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "@mdx-js/mdx": "^3.0.0", "@slorber/remark-comment": "^1.0.0", "escape-html": "^1.0.3", @@ -3279,12 +3201,11 @@ } }, "node_modules/@docusaurus/module-type-aliases": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.8.0.tgz", - "integrity": "sha512-/uMb4Ipt5J/QnD13MpnoC/A4EYAe6DKNWqTWLlGrqsPJwJv73vSwkA25xnYunwfqWk0FlUQfGv/Swdh5eCCg7g==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.8.1.tgz", + "integrity": "sha512-6xhvAJiXzsaq3JdosS7wbRt/PwEPWHr9eM4YNYqVlbgG1hSK3uQDXTVvQktasp3VO6BmfYWPozueLWuj4gB+vg==", "dependencies": { - "@docusaurus/types": "3.8.0", + "@docusaurus/types": "3.8.1", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -3298,16 +3219,15 @@ } }, "node_modules/@docusaurus/plugin-client-redirects": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.8.0.tgz", - "integrity": "sha512-J8f5qzAlO61BnG1I91+N5WH1b/lPWqn6ifTxf/Bluz9JVe1bhFNSl0yW03p+Ff3AFOINDy2ofX70al9nOnOLyw==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/logger": "3.8.0", - "@docusaurus/utils": "3.8.0", - "@docusaurus/utils-common": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.8.1.tgz", + "integrity": "sha512-F+86R7PBn6VNgy/Ux8w3ZRypJGJEzksbejQKlbTC8u6uhBUhfdXWkDp6qdOisIoW0buY5nLqucvZt1zNJzhJhA==", + "dependencies": { + "@docusaurus/core": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "eta": "^2.2.0", "fs-extra": "^11.1.1", "lodash": "^4.17.21", @@ -3322,19 +3242,18 @@ } }, "node_modules/@docusaurus/plugin-content-blog": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.8.0.tgz", - "integrity": "sha512-0SlOTd9R55WEr1GgIXu+hhTT0hzARYx3zIScA5IzpdekZQesI/hKEa5LPHBd415fLkWMjdD59TaW/3qQKpJ0Lg==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/logger": "3.8.0", - "@docusaurus/mdx-loader": "3.8.0", - "@docusaurus/theme-common": "3.8.0", - "@docusaurus/types": "3.8.0", - "@docusaurus/utils": "3.8.0", - "@docusaurus/utils-common": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.8.1.tgz", + "integrity": "sha512-vNTpMmlvNP9n3hGEcgPaXyvTljanAKIUkuG9URQ1DeuDup0OR7Ltvoc8yrmH+iMZJbcQGhUJF+WjHLwuk8HSdw==", + "dependencies": { + "@docusaurus/core": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/mdx-loader": "3.8.1", + "@docusaurus/theme-common": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "cheerio": "1.0.0-rc.12", "feed": "^4.2.2", "fs-extra": "^11.1.1", @@ -3356,20 +3275,19 @@ } }, "node_modules/@docusaurus/plugin-content-docs": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.0.tgz", - "integrity": "sha512-fRDMFLbUN6eVRXcjP8s3Y7HpAt9pzPYh1F/7KKXOCxvJhjjCtbon4VJW0WndEPInVz4t8QUXn5QZkU2tGVCE2g==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/logger": "3.8.0", - "@docusaurus/mdx-loader": "3.8.0", - "@docusaurus/module-type-aliases": "3.8.0", - "@docusaurus/theme-common": "3.8.0", - "@docusaurus/types": "3.8.0", - "@docusaurus/utils": "3.8.0", - "@docusaurus/utils-common": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.1.tgz", + "integrity": "sha512-oByRkSZzeGNQByCMaX+kif5Nl2vmtj2IHQI2fWjCfCootsdKZDPFLonhIp5s3IGJO7PLUfe0POyw0Xh/RrGXJA==", + "dependencies": { + "@docusaurus/core": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/mdx-loader": "3.8.1", + "@docusaurus/module-type-aliases": "3.8.1", + "@docusaurus/theme-common": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "@types/react-router-config": "^5.0.7", "combine-promises": "^1.1.0", "fs-extra": "^11.1.1", @@ -3389,16 +3307,15 @@ } }, "node_modules/@docusaurus/plugin-content-pages": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.8.0.tgz", - "integrity": "sha512-39EDx2y1GA0Pxfion5tQZLNJxL4gq6susd1xzetVBjVIQtwpCdyloOfQBAgX0FylqQxfJrYqL0DIUuq7rd7uBw==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/mdx-loader": "3.8.0", - "@docusaurus/types": "3.8.0", - "@docusaurus/utils": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.8.1.tgz", + "integrity": "sha512-a+V6MS2cIu37E/m7nDJn3dcxpvXb6TvgdNI22vJX8iUTp8eoMoPa0VArEbWvCxMY/xdC26WzNv4wZ6y0iIni/w==", + "dependencies": { + "@docusaurus/core": "3.8.1", + "@docusaurus/mdx-loader": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "fs-extra": "^11.1.1", "tslib": "^2.6.0", "webpack": "^5.88.1" @@ -3412,14 +3329,14 @@ } }, "node_modules/@docusaurus/plugin-css-cascade-layers": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.8.0.tgz", - "integrity": "sha512-/VBTNymPIxQB8oA3ZQ4GFFRYdH4ZxDRRBECxyjRyv486mfUPXfcdk+im4S5mKWa6EK2JzBz95IH/Wu0qQgJ5yQ==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/types": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.8.1.tgz", + "integrity": "sha512-VQ47xRxfNKjHS5ItzaVXpxeTm7/wJLFMOPo1BkmoMG4Cuz4nuI+Hs62+RMk1OqVog68Swz66xVPK8g9XTrBKRw==", + "dependencies": { + "@docusaurus/core": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "tslib": "^2.6.0" }, "engines": { @@ -3427,14 +3344,13 @@ } }, "node_modules/@docusaurus/plugin-debug": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.8.0.tgz", - "integrity": "sha512-teonJvJsDB9o2OnG6ifbhblg/PXzZvpUKHFgD8dOL1UJ58u0lk8o0ZOkvaYEBa9nDgqzoWrRk9w+e3qaG2mOhQ==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.8.1.tgz", + "integrity": "sha512-nT3lN7TV5bi5hKMB7FK8gCffFTBSsBsAfV84/v293qAmnHOyg1nr9okEw8AiwcO3bl9vije5nsUvP0aRl2lpaw==", "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/types": "3.8.0", - "@docusaurus/utils": "3.8.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", "fs-extra": "^11.1.1", "react-json-view-lite": "^2.3.0", "tslib": "^2.6.0" @@ -3448,14 +3364,13 @@ } }, "node_modules/@docusaurus/plugin-google-analytics": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.8.0.tgz", - "integrity": "sha512-aKKa7Q8+3xRSRESipNvlFgNp3FNPELKhuo48Cg/svQbGNwidSHbZT03JqbW4cBaQnyyVchO1ttk+kJ5VC9Gx0w==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.8.1.tgz", + "integrity": "sha512-Hrb/PurOJsmwHAsfMDH6oVpahkEGsx7F8CWMjyP/dw1qjqmdS9rcV1nYCGlM8nOtD3Wk/eaThzUB5TSZsGz+7Q==", "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/types": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "tslib": "^2.6.0" }, "engines": { @@ -3467,14 +3382,13 @@ } }, "node_modules/@docusaurus/plugin-google-gtag": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.8.0.tgz", - "integrity": "sha512-ugQYMGF4BjbAW/JIBtVcp+9eZEgT9HRdvdcDudl5rywNPBA0lct+lXMG3r17s02rrhInMpjMahN3Yc9Cb3H5/g==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.8.1.tgz", + "integrity": "sha512-tKE8j1cEZCh8KZa4aa80zpSTxsC2/ZYqjx6AAfd8uA8VHZVw79+7OTEP2PoWi0uL5/1Is0LF5Vwxd+1fz5HlKg==", "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/types": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "@types/gtag.js": "^0.0.12", "tslib": "^2.6.0" }, @@ -3487,14 +3401,13 @@ } }, "node_modules/@docusaurus/plugin-google-tag-manager": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.8.0.tgz", - "integrity": "sha512-9juRWxbwZD3SV02Jd9QB6yeN7eu+7T4zB0bvJLcVQwi+am51wAxn2CwbdL0YCCX+9OfiXbADE8D8Q65Hbopu/w==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.8.1.tgz", + "integrity": "sha512-iqe3XKITBquZq+6UAXdb1vI0fPY5iIOitVjPQ581R1ZKpHr0qe+V6gVOrrcOHixPDD/BUKdYwkxFjpNiEN+vBw==", "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/types": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "tslib": "^2.6.0" }, "engines": { @@ -3506,17 +3419,16 @@ } }, "node_modules/@docusaurus/plugin-sitemap": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.8.0.tgz", - "integrity": "sha512-fGpOIyJvNiuAb90nSJ2Gfy/hUOaDu6826e5w5UxPmbpCIc7KlBHNAZ5g4L4ZuHhc4hdfq4mzVBsQSnne+8Ze1g==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/logger": "3.8.0", - "@docusaurus/types": "3.8.0", - "@docusaurus/utils": "3.8.0", - "@docusaurus/utils-common": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.8.1.tgz", + "integrity": "sha512-+9YV/7VLbGTq8qNkjiugIelmfUEVkTyLe6X8bWq7K5qPvGXAjno27QAfFq63mYfFFbJc7z+pudL63acprbqGzw==", + "dependencies": { + "@docusaurus/core": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "fs-extra": "^11.1.1", "sitemap": "^7.1.1", "tslib": "^2.6.0" @@ -3530,15 +3442,14 @@ } }, "node_modules/@docusaurus/plugin-svgr": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.8.0.tgz", - "integrity": "sha512-kEDyry+4OMz6BWLG/lEqrNsL/w818bywK70N1gytViw4m9iAmoxCUT7Ri9Dgs7xUdzCHJ3OujolEmD88Wy44OA==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/types": "3.8.0", - "@docusaurus/utils": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.8.1.tgz", + "integrity": "sha512-rW0LWMDsdlsgowVwqiMb/7tANDodpy1wWPwCcamvhY7OECReN3feoFwLjd/U4tKjNY3encj0AJSTxJA+Fpe+Gw==", + "dependencies": { + "@docusaurus/core": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "@svgr/core": "8.1.0", "@svgr/webpack": "^8.1.0", "tslib": "^2.6.0", @@ -3553,26 +3464,25 @@ } }, "node_modules/@docusaurus/preset-classic": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.8.0.tgz", - "integrity": "sha512-qOu6tQDOWv+rpTlKu+eJATCJVGnABpRCPuqf7LbEaQ1mNY//N/P8cHQwkpAU+aweQfarcZ0XfwCqRHJfjeSV/g==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/plugin-content-blog": "3.8.0", - "@docusaurus/plugin-content-docs": "3.8.0", - "@docusaurus/plugin-content-pages": "3.8.0", - "@docusaurus/plugin-css-cascade-layers": "3.8.0", - "@docusaurus/plugin-debug": "3.8.0", - "@docusaurus/plugin-google-analytics": "3.8.0", - "@docusaurus/plugin-google-gtag": "3.8.0", - "@docusaurus/plugin-google-tag-manager": "3.8.0", - "@docusaurus/plugin-sitemap": "3.8.0", - "@docusaurus/plugin-svgr": "3.8.0", - "@docusaurus/theme-classic": "3.8.0", - "@docusaurus/theme-common": "3.8.0", - "@docusaurus/theme-search-algolia": "3.8.0", - "@docusaurus/types": "3.8.0" + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.8.1.tgz", + "integrity": "sha512-yJSjYNHXD8POMGc2mKQuj3ApPrN+eG0rO1UPgSx7jySpYU+n4WjBikbrA2ue5ad9A7aouEtMWUoiSRXTH/g7KQ==", + "dependencies": { + "@docusaurus/core": "3.8.1", + "@docusaurus/plugin-content-blog": "3.8.1", + "@docusaurus/plugin-content-docs": "3.8.1", + "@docusaurus/plugin-content-pages": "3.8.1", + "@docusaurus/plugin-css-cascade-layers": "3.8.1", + "@docusaurus/plugin-debug": "3.8.1", + "@docusaurus/plugin-google-analytics": "3.8.1", + "@docusaurus/plugin-google-gtag": "3.8.1", + "@docusaurus/plugin-google-tag-manager": "3.8.1", + "@docusaurus/plugin-sitemap": "3.8.1", + "@docusaurus/plugin-svgr": "3.8.1", + "@docusaurus/theme-classic": "3.8.1", + "@docusaurus/theme-common": "3.8.1", + "@docusaurus/theme-search-algolia": "3.8.1", + "@docusaurus/types": "3.8.1" }, "engines": { "node": ">=18.0" @@ -3583,31 +3493,30 @@ } }, "node_modules/@docusaurus/theme-classic": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.8.0.tgz", - "integrity": "sha512-nQWFiD5ZjoT76OaELt2n33P3WVuuCz8Dt5KFRP2fCBo2r9JCLsp2GJjZpnaG24LZ5/arRjv4VqWKgpK0/YLt7g==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/logger": "3.8.0", - "@docusaurus/mdx-loader": "3.8.0", - "@docusaurus/module-type-aliases": "3.8.0", - "@docusaurus/plugin-content-blog": "3.8.0", - "@docusaurus/plugin-content-docs": "3.8.0", - "@docusaurus/plugin-content-pages": "3.8.0", - "@docusaurus/theme-common": "3.8.0", - "@docusaurus/theme-translations": "3.8.0", - "@docusaurus/types": "3.8.0", - "@docusaurus/utils": "3.8.0", - "@docusaurus/utils-common": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.8.1.tgz", + "integrity": "sha512-bqDUCNqXeYypMCsE1VcTXSI1QuO4KXfx8Cvl6rYfY0bhhqN6d2WZlRkyLg/p6pm+DzvanqHOyYlqdPyP0iz+iw==", + "dependencies": { + "@docusaurus/core": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/mdx-loader": "3.8.1", + "@docusaurus/module-type-aliases": "3.8.1", + "@docusaurus/plugin-content-blog": "3.8.1", + "@docusaurus/plugin-content-docs": "3.8.1", + "@docusaurus/plugin-content-pages": "3.8.1", + "@docusaurus/theme-common": "3.8.1", + "@docusaurus/theme-translations": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "copy-text-to-clipboard": "^3.2.0", "infima": "0.2.0-alpha.45", "lodash": "^4.17.21", "nprogress": "^0.2.0", - "postcss": "^8.4.26", + "postcss": "^8.5.4", "prism-react-renderer": "^2.3.0", "prismjs": "^1.29.0", "react-router-dom": "^5.3.4", @@ -3624,15 +3533,14 @@ } }, "node_modules/@docusaurus/theme-common": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.8.0.tgz", - "integrity": "sha512-YqV2vAWpXGLA+A3PMLrOMtqgTHJLDcT+1Caa6RF7N4/IWgrevy5diY8oIHFkXR/eybjcrFFjUPrHif8gSGs3Tw==", - "license": "MIT", - "dependencies": { - "@docusaurus/mdx-loader": "3.8.0", - "@docusaurus/module-type-aliases": "3.8.0", - "@docusaurus/utils": "3.8.0", - "@docusaurus/utils-common": "3.8.0", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.8.1.tgz", + "integrity": "sha512-UswMOyTnPEVRvN5Qzbo+l8k4xrd5fTFu2VPPfD6FcW/6qUtVLmJTQCktbAL3KJ0BVXGm5aJXz/ZrzqFuZERGPw==", + "dependencies": { + "@docusaurus/mdx-loader": "3.8.1", + "@docusaurus/module-type-aliases": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -3652,19 +3560,18 @@ } }, "node_modules/@docusaurus/theme-search-algolia": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.8.0.tgz", - "integrity": "sha512-GBZ5UOcPgiu6nUw153+0+PNWvFKweSnvKIL6Rp04H9olKb475jfKjAwCCtju5D2xs5qXHvCMvzWOg5o9f6DtuQ==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.8.1.tgz", + "integrity": "sha512-NBFH5rZVQRAQM087aYSRKQ9yGEK9eHd+xOxQjqNpxMiV85OhJDD4ZGz6YJIod26Fbooy54UWVdzNU0TFeUUUzQ==", "dependencies": { "@docsearch/react": "^3.9.0", - "@docusaurus/core": "3.8.0", - "@docusaurus/logger": "3.8.0", - "@docusaurus/plugin-content-docs": "3.8.0", - "@docusaurus/theme-common": "3.8.0", - "@docusaurus/theme-translations": "3.8.0", - "@docusaurus/utils": "3.8.0", - "@docusaurus/utils-validation": "3.8.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/plugin-content-docs": "3.8.1", + "@docusaurus/theme-common": "3.8.1", + "@docusaurus/theme-translations": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "algoliasearch": "^5.17.1", "algoliasearch-helper": "^3.22.6", "clsx": "^2.0.0", @@ -3683,10 +3590,9 @@ } }, "node_modules/@docusaurus/theme-translations": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.8.0.tgz", - "integrity": "sha512-1DTy/snHicgkCkryWq54fZvsAglTdjTx4qjOXgqnXJ+DIty1B+aPQrAVUu8LiM+6BiILfmNxYsxhKTj+BS3PZg==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.8.1.tgz", + "integrity": "sha512-OTp6eebuMcf2rJt4bqnvuwmm3NVXfzfYejL+u/Y1qwKhZPrjPoKWfk1CbOP5xH5ZOPkiAsx4dHdQBRJszK3z2g==", "dependencies": { "fs-extra": "^11.1.1", "tslib": "^2.6.0" @@ -3696,10 +3602,9 @@ } }, "node_modules/@docusaurus/types": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.8.0.tgz", - "integrity": "sha512-RDEClpwNxZq02c+JlaKLWoS13qwWhjcNsi2wG1UpzmEnuti/z1Wx4SGpqbUqRPNSd8QWWePR8Cb7DvG0VN/TtA==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.8.1.tgz", + "integrity": "sha512-ZPdW5AB+pBjiVrcLuw3dOS6BFlrG0XkS2lDGsj8TizcnREQg3J8cjsgfDviszOk4CweNfwo1AEELJkYaMUuOPg==", "dependencies": { "@mdx-js/mdx": "^3.0.0", "@types/history": "^4.7.11", @@ -3731,14 +3636,13 @@ } }, "node_modules/@docusaurus/utils": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.8.0.tgz", - "integrity": "sha512-2wvtG28ALCN/A1WCSLxPASFBFzXCnP0YKCAFIPcvEb6imNu1wg7ni/Svcp71b3Z2FaOFFIv4Hq+j4gD7gA0yfQ==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.8.1.tgz", + "integrity": "sha512-P1ml0nvOmEFdmu0smSXOqTS1sxU5tqvnc0dA4MTKV39kye+bhQnjkIKEE18fNOvxjyB86k8esoCIFM3x4RykOQ==", "dependencies": { - "@docusaurus/logger": "3.8.0", - "@docusaurus/types": "3.8.0", - "@docusaurus/utils-common": "3.8.0", + "@docusaurus/logger": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils-common": "3.8.1", "escape-string-regexp": "^4.0.0", "execa": "5.1.1", "file-loader": "^6.2.0", @@ -3763,12 +3667,11 @@ } }, "node_modules/@docusaurus/utils-common": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.8.0.tgz", - "integrity": "sha512-3TGF+wVTGgQ3pAc9+5jVchES4uXUAhAt9pwv7uws4mVOxL4alvU3ue/EZ+R4XuGk94pDy7CNXjRXpPjlfZXQfw==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.8.1.tgz", + "integrity": "sha512-zTZiDlvpvoJIrQEEd71c154DkcriBecm4z94OzEE9kz7ikS3J+iSlABhFXM45mZ0eN5pVqqr7cs60+ZlYLewtg==", "dependencies": { - "@docusaurus/types": "3.8.0", + "@docusaurus/types": "3.8.1", "tslib": "^2.6.0" }, "engines": { @@ -3776,14 +3679,13 @@ } }, "node_modules/@docusaurus/utils-validation": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.8.0.tgz", - "integrity": "sha512-MrnEbkigr54HkdFeg8e4FKc4EF+E9dlVwsY3XQZsNkbv3MKZnbHQ5LsNJDIKDROFe8PBf5C4qCAg5TPBpsjrjg==", - "license": "MIT", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.8.1.tgz", + "integrity": "sha512-gs5bXIccxzEbyVecvxg6upTwaUbfa0KMmTj7HhHzc016AGyxH2o73k1/aOD0IFrdCsfJNt37MqNI47s2MgRZMA==", "dependencies": { - "@docusaurus/logger": "3.8.0", - "@docusaurus/utils": "3.8.0", - "@docusaurus/utils-common": "3.8.0", + "@docusaurus/logger": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", "fs-extra": "^11.2.0", "joi": "^17.9.2", "js-yaml": "^4.1.0", @@ -3811,7 +3713,6 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -3823,7 +3724,6 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -4044,8 +3944,7 @@ "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "license": "MIT" + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" }, "node_modules/@sindresorhus/is": { "version": "4.6.0", @@ -4074,7 +3973,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", - "license": "MIT", "engines": { "node": ">=14" }, @@ -4090,7 +3988,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", - "license": "MIT", "engines": { "node": ">=14" }, @@ -4106,7 +4003,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", - "license": "MIT", "engines": { "node": ">=14" }, @@ -4122,7 +4018,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", - "license": "MIT", "engines": { "node": ">=14" }, @@ -4138,7 +4033,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", - "license": "MIT", "engines": { "node": ">=14" }, @@ -4154,7 +4048,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", - "license": "MIT", "engines": { "node": ">=14" }, @@ -4170,7 +4063,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", - "license": "MIT", "engines": { "node": ">=14" }, @@ -4186,7 +4078,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", - "license": "MIT", "engines": { "node": ">=12" }, @@ -4202,7 +4093,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", - "license": "MIT", "dependencies": { "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", @@ -4228,7 +4118,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", - "license": "MIT", "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -4248,7 +4137,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", - "license": "MIT", "dependencies": { "@babel/types": "^7.21.3", "entities": "^4.4.0" @@ -4265,7 +4153,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", - "license": "MIT", "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -4287,7 +4174,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-8.1.0.tgz", "integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==", - "license": "MIT", "dependencies": { "cosmiconfig": "^8.1.3", "deepmerge": "^4.3.1", @@ -4308,7 +4194,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz", "integrity": "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==", - "license": "MIT", "dependencies": { "@babel/core": "^7.21.3", "@babel/plugin-transform-react-constant-elements": "^7.21.3", @@ -4471,8 +4356,7 @@ "node_modules/@types/gtag.js": { "version": "0.0.12", "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.12.tgz", - "integrity": "sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==", - "license": "MIT" + "integrity": "sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==" }, "node_modules/@types/hast": { "version": "3.0.4", @@ -4517,14 +4401,12 @@ "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "license": "MIT" + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "license": "MIT", "dependencies": { "@types/istanbul-lib-coverage": "*" } @@ -4533,7 +4415,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "license": "MIT", "dependencies": { "@types/istanbul-lib-report": "*" } @@ -4655,7 +4536,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", - "license": "MIT", "dependencies": { "@types/node": "*" } @@ -4717,7 +4597,6 @@ "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "license": "MIT", "dependencies": { "@types/yargs-parser": "*" } @@ -4725,8 +4604,7 @@ "node_modules/@types/yargs-parser": { "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "license": "MIT" + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", @@ -5016,34 +4894,32 @@ } }, "node_modules/algoliasearch": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.25.0.tgz", - "integrity": "sha512-n73BVorL4HIwKlfJKb4SEzAYkR3Buwfwbh+MYxg2mloFph2fFGV58E90QTzdbfzWrLn4HE5Czx/WTjI8fcHaMg==", - "license": "MIT", - "dependencies": { - "@algolia/client-abtesting": "5.25.0", - "@algolia/client-analytics": "5.25.0", - "@algolia/client-common": "5.25.0", - "@algolia/client-insights": "5.25.0", - "@algolia/client-personalization": "5.25.0", - "@algolia/client-query-suggestions": "5.25.0", - "@algolia/client-search": "5.25.0", - "@algolia/ingestion": "1.25.0", - "@algolia/monitoring": "1.25.0", - "@algolia/recommend": "5.25.0", - "@algolia/requester-browser-xhr": "5.25.0", - "@algolia/requester-fetch": "5.25.0", - "@algolia/requester-node-http": "5.25.0" + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.30.0.tgz", + "integrity": "sha512-ILSdPX4je0n5WUKD34TMe57/eqiXUzCIjAsdtLQYhomqOjTtFUg1s6dE7kUegc4Mc43Xr7IXYlMutU9HPiYfdw==", + "dependencies": { + "@algolia/client-abtesting": "5.30.0", + "@algolia/client-analytics": "5.30.0", + "@algolia/client-common": "5.30.0", + "@algolia/client-insights": "5.30.0", + "@algolia/client-personalization": "5.30.0", + "@algolia/client-query-suggestions": "5.30.0", + "@algolia/client-search": "5.30.0", + "@algolia/ingestion": "1.30.0", + "@algolia/monitoring": "1.30.0", + "@algolia/recommend": "5.30.0", + "@algolia/requester-browser-xhr": "5.30.0", + "@algolia/requester-fetch": "5.30.0", + "@algolia/requester-node-http": "5.30.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/algoliasearch-helper": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.25.0.tgz", - "integrity": "sha512-vQoK43U6HXA9/euCqLjvyNdM4G2Fiu/VFp4ae0Gau9sZeIKBPvUPnXfLYAe65Bg7PFuw03coeu5K6lTPSXRObw==", - "license": "MIT", + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.26.0.tgz", + "integrity": "sha512-Rv2x3GXleQ3ygwhkhJubhhYGsICmShLAiqtUuJTUkr9uOCOXyF2E71LVT4XDnVffbknv8XgScP4U0Oxtgm+hIw==", "dependencies": { "@algolia/events": "^4.0.1" }, @@ -5084,7 +4960,6 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "license": "MIT", "dependencies": { "type-fest": "^0.21.3" }, @@ -5099,7 +4974,6 @@ "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -5158,8 +5032,7 @@ "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "license": "MIT" + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" }, "node_modules/argparse": { "version": "2.0.1", @@ -5206,7 +5079,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", @@ -5229,7 +5101,6 @@ "version": "9.2.1", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", - "license": "MIT", "dependencies": { "find-cache-dir": "^4.0.0", "schema-utils": "^4.0.0" @@ -5246,7 +5117,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", - "license": "MIT", "dependencies": { "object.assign": "^4.1.0" } @@ -5518,7 +5388,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", @@ -5594,7 +5463,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", - "license": "MIT", "dependencies": { "browserslist": "^4.0.0", "caniuse-lite": "^1.0.0", @@ -5695,7 +5563,6 @@ "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", - "license": "MIT", "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", @@ -5716,7 +5583,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", @@ -5902,8 +5768,7 @@ "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", - "license": "MIT" + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" }, "node_modules/colorette": { "version": "2.0.20", @@ -5939,8 +5804,7 @@ "node_modules/common-path-prefix": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", - "license": "ISC" + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==" }, "node_modules/compressible": { "version": "2.0.18", @@ -6052,7 +5916,6 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" } @@ -6099,7 +5962,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.0.tgz", "integrity": "sha512-RnJFp1XR/LOBDckxTib5Qjr/PMfkatD0MUCQgdpqS8MdKiNUzBjAQBEN6oUy+jW7LI93BBG3DtMB2KOOKpGs2Q==", - "license": "MIT", "engines": { "node": ">=12" }, @@ -6111,7 +5973,6 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", - "license": "MIT", "dependencies": { "fast-glob": "^3.2.11", "glob-parent": "^6.0.1", @@ -6135,7 +5996,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -6147,7 +6007,6 @@ "version": "13.2.2", "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", - "license": "MIT", "dependencies": { "dir-glob": "^3.0.1", "fast-glob": "^3.3.0", @@ -6166,7 +6025,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", - "license": "MIT", "engines": { "node": ">=12" }, @@ -6199,11 +6057,10 @@ } }, "node_modules/core-js-pure": { - "version": "3.42.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.42.0.tgz", - "integrity": "sha512-007bM04u91fF4kMgwom2I5cQxAFIy8jVulgr9eozILl/SZE53QOqnW/+vviC+wQWLv+AunBG+8Q0TLoeSsSxRQ==", + "version": "3.43.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.43.0.tgz", + "integrity": "sha512-i/AgxU2+A+BbJdMxh3v7/vxi2SbFqxiFmg6VsDwYB4jkucrd1BZNA9a9gphC0fYMG5IBSgQcbQnk865VCLe7xA==", "hasInstallScript": true, - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -6295,7 +6152,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "postcss-selector-parser": "^7.0.0" }, @@ -6310,7 +6166,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -6323,7 +6178,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz", "integrity": "sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==", - "license": "ISC", "engines": { "node": "^14 || ^16 || >=18" }, @@ -6345,7 +6199,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/selector-specificity": "^5.0.0", "postcss-selector-parser": "^7.0.0", @@ -6372,7 +6225,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" }, @@ -6384,7 +6236,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -6397,7 +6248,6 @@ "version": "6.11.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", "integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==", - "license": "MIT", "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.33", @@ -6432,7 +6282,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-5.0.1.tgz", "integrity": "sha512-3caImjKFQkS+ws1TGcFn0V1HyDJFq1Euy589JlD6/3rV2kj+w7r5G9WDMgSHvpvXHNZ2calVypZWuEDQd9wfLg==", - "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.18", "cssnano": "^6.0.1", @@ -6486,7 +6335,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" }, @@ -6533,9 +6381,9 @@ } }, "node_modules/cssdb": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.3.0.tgz", - "integrity": "sha512-c7bmItIg38DgGjSwDPZOYF/2o0QU/sSgkWOMyl8votOfgFuyiFKWPesmCGEsrGLxEA9uL540cp8LdaGEjUGsZQ==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.3.1.tgz", + "integrity": "sha512-XnDRQMXucLueX92yDe0LPKupXetWoFOgawr4O4X41l5TltgK2NVbJJVDnnOywDYfW1sTJ28AcXGKOqdRKwCcmQ==", "funding": [ { "type": "opencollective", @@ -6545,14 +6393,12 @@ "type": "github", "url": "https://github.com/sponsors/csstools" } - ], - "license": "MIT-0" + ] }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", "bin": { "cssesc": "bin/cssesc" }, @@ -6564,7 +6410,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.1.2.tgz", "integrity": "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==", - "license": "MIT", "dependencies": { "cssnano-preset-default": "^6.1.2", "lilconfig": "^3.1.1" @@ -6584,7 +6429,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-6.1.2.tgz", "integrity": "sha512-Nhao7eD8ph2DoHolEzQs5CfRpiEP0xa1HBdnFZ82kvqdmbwVBUr2r1QuQ4t1pi+D1ZpqpcO4T+wy/7RxzJ/WPQ==", - "license": "MIT", "dependencies": { "autoprefixer": "^10.4.19", "browserslist": "^4.23.0", @@ -6605,7 +6449,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.2.tgz", "integrity": "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==", - "license": "MIT", "dependencies": { "browserslist": "^4.23.0", "css-declaration-sorter": "^7.2.0", @@ -6649,7 +6492,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz", "integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==", - "license": "MIT", "engines": { "node": "^14 || ^16 || >=18.0" }, @@ -6766,7 +6608,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -6796,7 +6637,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -6822,7 +6662,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -7593,7 +7432,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz", "integrity": "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==", - "license": "MIT", "dependencies": { "xml-js": "^1.6.11" }, @@ -7605,7 +7443,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "license": "MIT", "dependencies": { "escape-string-regexp": "^1.0.5" }, @@ -7620,7 +7457,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", "engines": { "node": ">=0.8.0" } @@ -7737,7 +7573,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", - "license": "MIT", "dependencies": { "common-path-prefix": "^3.0.0", "pkg-dir": "^7.0.0" @@ -7753,7 +7588,6 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", - "license": "MIT", "dependencies": { "locate-path": "^7.1.0", "path-exists": "^5.0.0" @@ -7823,7 +7657,6 @@ "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "license": "MIT", "engines": { "node": "*" }, @@ -8163,7 +7996,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" }, @@ -8502,7 +8334,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", - "license": "MIT", "dependencies": { "camel-case": "^4.1.2", "clean-css": "~5.3.2", @@ -8523,7 +8354,6 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "license": "MIT", "engines": { "node": ">=14" } @@ -8623,7 +8453,6 @@ "url": "https://github.com/sponsors/fb55" } ], - "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", @@ -8753,7 +8582,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "license": "ISC", "engines": { "node": "^10 || ^12 || >= 14" }, @@ -8827,7 +8655,6 @@ "version": "0.2.0-alpha.45", "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.45.tgz", "integrity": "sha512-uyH0zfr1erU1OohLk0fT4Rrb94AOhguWNOcD9uGrSpRvNB+6gZXUoJX5J0NtvzBO10YZ9PgvA4NFgt+fYg8ojw==", - "license": "MIT", "engines": { "node": ">=12" } @@ -9165,7 +8992,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -9182,7 +9008,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "license": "MIT", "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", @@ -9197,7 +9022,6 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -9357,7 +9181,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "license": "MIT", "engines": { "node": ">=14" }, @@ -9395,7 +9218,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "license": "MIT", "dependencies": { "p-locate": "^6.0.0" }, @@ -9419,14 +9241,12 @@ "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "license": "MIT" + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", - "license": "MIT" + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" }, "node_modules/longest-streak": { "version": "3.1.0", @@ -11727,7 +11547,6 @@ "version": "2.9.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz", "integrity": "sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==", - "license": "MIT", "dependencies": { "schema-utils": "^4.0.0", "tapable": "^2.2.1" @@ -11797,9 +11616,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -11879,7 +11698,6 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -11911,8 +11729,7 @@ "node_modules/nprogress": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", - "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==", - "license": "MIT" + "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==" }, "node_modules/nth-check": { "version": "2.1.1", @@ -11929,7 +11746,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/null-loader/-/null-loader-4.0.1.tgz", "integrity": "sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg==", - "license": "MIT", "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" @@ -11949,7 +11765,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -11965,7 +11780,6 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "license": "MIT", "peerDependencies": { "ajv": "^6.9.1" } @@ -11973,14 +11787,12 @@ "node_modules/null-loader/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "node_modules/null-loader/node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", @@ -12018,7 +11830,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "license": "MIT", "engines": { "node": ">= 0.4" } @@ -12027,7 +11838,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -12141,7 +11951,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "license": "MIT", "dependencies": { "yocto-queue": "^1.0.0" }, @@ -12156,7 +11965,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "license": "MIT", "dependencies": { "p-limit": "^4.0.0" }, @@ -12306,8 +12114,7 @@ "node_modules/parse-numeric-range": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz", - "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==", - "license": "ISC" + "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==" }, "node_modules/parse5": { "version": "7.1.2", @@ -12324,7 +12131,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", - "license": "MIT", "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" @@ -12356,7 +12162,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } @@ -12435,7 +12240,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", - "license": "MIT", "dependencies": { "find-up": "^6.3.0" }, @@ -12447,9 +12251,9 @@ } }, "node_modules/postcss": { - "version": "8.4.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", - "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -12465,9 +12269,9 @@ } ], "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -12487,7 +12291,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "dependencies": { "postcss-selector-parser": "^7.0.0" }, @@ -12502,7 +12305,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12515,7 +12317,6 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-9.0.1.tgz", "integrity": "sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==", - "license": "MIT", "dependencies": { "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0" @@ -12531,7 +12332,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -12556,7 +12356,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-color-parser": "^3.0.10", "@csstools/css-parser-algorithms": "^3.0.5", @@ -12585,7 +12384,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "dependencies": { "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" @@ -12611,7 +12409,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" @@ -12627,7 +12424,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz", "integrity": "sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw==", - "license": "MIT", "dependencies": { "browserslist": "^4.23.0", "caniuse-api": "^3.0.0", @@ -12645,7 +12441,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.1.0.tgz", "integrity": "sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w==", - "license": "MIT", "dependencies": { "browserslist": "^4.23.0", "postcss-value-parser": "^4.2.0" @@ -12671,7 +12466,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "dependencies": { "@csstools/cascade-layer-name-parser": "^2.0.5", "@csstools/css-parser-algorithms": "^3.0.5", @@ -12686,9 +12480,9 @@ } }, "node_modules/postcss-custom-properties": { - "version": "14.0.5", - "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-14.0.5.tgz", - "integrity": "sha512-UWf/vhMapZatv+zOuqlfLmYXeOhhHLh8U8HAKGI2VJ00xLRYoAJh4xv8iX6FB6+TLXeDnm0DBLMi00E0hodbQw==", + "version": "14.0.6", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-14.0.6.tgz", + "integrity": "sha512-fTYSp3xuk4BUeVhxCSJdIPhDLpJfNakZKoiTDx7yRGCdlZrSJR7mWKVOBS4sBF+5poPQFMj2YdXx1VHItBGihQ==", "funding": [ { "type": "github", @@ -12699,7 +12493,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "dependencies": { "@csstools/cascade-layer-name-parser": "^2.0.5", "@csstools/css-parser-algorithms": "^3.0.5", @@ -12728,7 +12521,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "dependencies": { "@csstools/cascade-layer-name-parser": "^2.0.5", "@csstools/css-parser-algorithms": "^3.0.5", @@ -12746,7 +12538,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12769,7 +12560,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "postcss-selector-parser": "^7.0.0" }, @@ -12784,7 +12574,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12797,7 +12586,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.2.tgz", "integrity": "sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw==", - "license": "MIT", "engines": { "node": "^14 || ^16 || >=18.0" }, @@ -12809,7 +12597,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.3.tgz", "integrity": "sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw==", - "license": "MIT", "engines": { "node": "^14 || ^16 || >=18.0" }, @@ -12821,7 +12608,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-6.0.3.tgz", "integrity": "sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ==", - "license": "MIT", "engines": { "node": "^14 || ^16 || >=18.0" }, @@ -12833,7 +12619,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-6.0.2.tgz", "integrity": "sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ==", - "license": "MIT", "engines": { "node": "^14 || ^16 || >=18.0" }, @@ -12845,7 +12630,6 @@ "version": "6.0.5", "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-6.0.5.tgz", "integrity": "sha512-wHalBlRHkaNnNwfC8z+ppX57VhvS+HWgjW508esjdaEYr3Mx7Gnn2xA4R/CKf5+Z9S5qsqC+Uzh4ueENWwCVUA==", - "license": "MIT", "dependencies": { "postcss-selector-parser": "^6.0.16" }, @@ -12870,7 +12654,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/postcss-progressive-custom-properties": "^4.1.0", "@csstools/utilities": "^2.0.0", @@ -12897,7 +12680,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "postcss-selector-parser": "^7.0.0" }, @@ -12912,7 +12694,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12935,7 +12716,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "postcss-selector-parser": "^7.0.0" }, @@ -12950,7 +12730,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12963,7 +12742,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", - "license": "MIT", "peerDependencies": { "postcss": "^8.1.0" } @@ -12982,7 +12760,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" }, @@ -13004,7 +12781,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" @@ -13030,7 +12806,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "@csstools/css-color-parser": "^3.0.10", "@csstools/css-parser-algorithms": "^3.0.5", @@ -13049,7 +12824,6 @@ "version": "7.3.4", "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.4.tgz", "integrity": "sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A==", - "license": "MIT", "dependencies": { "cosmiconfig": "^8.3.5", "jiti": "^1.20.0", @@ -13081,7 +12855,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13096,7 +12869,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-6.0.3.tgz", "integrity": "sha512-1oIoAsODUs6IHQZkLQGO15uGEbK3EAl5wi9SS8hs45VgsxQfMnxvt+L+zIr7ifZFIH14cfAeVe2uCTa+SPRa3g==", - "license": "MIT", "dependencies": { "cssnano-utils": "^4.0.2", "postcss-value-parser": "^4.2.0" @@ -13112,7 +12884,6 @@ "version": "6.0.5", "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.5.tgz", "integrity": "sha512-5LOiordeTfi64QhICp07nzzuTDjNSO8g5Ksdibt44d+uvIIAE1oZdRn8y/W5ZtYgRH/lnLDlvi9F8btZcVzu3w==", - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0", "stylehacks": "^6.1.1" @@ -13128,7 +12899,6 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-6.1.1.tgz", "integrity": "sha512-KOdWF0gju31AQPZiD+2Ar9Qjowz1LTChSjFFbS+e2sFgc4uHOp3ZvVX4sNeTlk0w2O31ecFGgrFzhO0RSWbWwQ==", - "license": "MIT", "dependencies": { "browserslist": "^4.23.0", "caniuse-api": "^3.0.0", @@ -13146,7 +12916,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.1.0.tgz", "integrity": "sha512-gklfI/n+9rTh8nYaSJXlCo3nOKqMNkxuGpTn/Qm0gstL3ywTr9/WRKznE+oy6fvfolH6dF+QM4nCo8yPLdvGJg==", - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13161,7 +12930,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-6.0.3.tgz", "integrity": "sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q==", - "license": "MIT", "dependencies": { "colord": "^2.9.3", "cssnano-utils": "^4.0.2", @@ -13178,7 +12946,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-6.1.0.tgz", "integrity": "sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA==", - "license": "MIT", "dependencies": { "browserslist": "^4.23.0", "cssnano-utils": "^4.0.2", @@ -13195,7 +12962,6 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-6.0.4.tgz", "integrity": "sha512-L8dZSwNLgK7pjTto9PzWRoMbnLq5vsZSTu8+j1P/2GB8qdtGQfn+K1uSvFgYvgh83cbyxT5m43ZZhUMTJDSClQ==", - "license": "MIT", "dependencies": { "postcss-selector-parser": "^6.0.16" }, @@ -13210,7 +12976,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", - "license": "ISC", "engines": { "node": "^10 || ^12 || >= 14" }, @@ -13222,7 +12987,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", - "license": "MIT", "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^7.0.0", @@ -13239,7 +13003,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13252,7 +13015,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", - "license": "ISC", "dependencies": { "postcss-selector-parser": "^7.0.0" }, @@ -13267,7 +13029,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13280,7 +13041,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", - "license": "ISC", "dependencies": { "icss-utils": "^5.0.0" }, @@ -13292,9 +13052,9 @@ } }, "node_modules/postcss-nesting": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.1.tgz", - "integrity": "sha512-VbqqHkOBOt4Uu3G8Dm8n6lU5+9cJFxiuty9+4rcoyRPO9zZS1JIs6td49VIoix3qYqELHlJIn46Oih9SAKo+yQ==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.2.tgz", + "integrity": "sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ==", "funding": [ { "type": "github", @@ -13305,9 +13065,8 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { - "@csstools/selector-resolve-nested": "^3.0.0", + "@csstools/selector-resolve-nested": "^3.1.0", "@csstools/selector-specificity": "^5.0.0", "postcss-selector-parser": "^7.0.0" }, @@ -13319,9 +13078,9 @@ } }, "node_modules/postcss-nesting/node_modules/@csstools/selector-resolve-nested": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.0.0.tgz", - "integrity": "sha512-ZoK24Yku6VJU1gS79a5PFmC8yn3wIapiKmPgun0hZgEI5AOqgH2kiPRsPz1qkGv4HL+wuDLH83yQyk6inMYrJQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.1.0.tgz", + "integrity": "sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g==", "funding": [ { "type": "github", @@ -13332,7 +13091,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" }, @@ -13354,7 +13112,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" }, @@ -13366,7 +13123,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13379,7 +13135,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.2.tgz", "integrity": "sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ==", - "license": "MIT", "engines": { "node": "^14 || ^16 || >=18.0" }, @@ -13391,7 +13146,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.2.tgz", "integrity": "sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg==", - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13406,7 +13160,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-6.0.2.tgz", "integrity": "sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q==", - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13421,7 +13174,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.2.tgz", "integrity": "sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ==", - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13436,7 +13188,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-6.0.2.tgz", "integrity": "sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ==", - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13451,7 +13202,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.2.tgz", "integrity": "sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA==", - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13466,7 +13216,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-6.1.0.tgz", "integrity": "sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg==", - "license": "MIT", "dependencies": { "browserslist": "^4.23.0", "postcss-value-parser": "^4.2.0" @@ -13482,7 +13231,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-6.0.2.tgz", "integrity": "sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ==", - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13497,7 +13245,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.2.tgz", "integrity": "sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q==", - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13522,7 +13269,6 @@ "url": "https://liberapay.com/mrcgrtz" } ], - "license": "MIT", "engines": { "node": ">=18" }, @@ -13534,7 +13280,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-6.0.2.tgz", "integrity": "sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q==", - "license": "MIT", "dependencies": { "cssnano-utils": "^4.0.2", "postcss-value-parser": "^4.2.0" @@ -13560,7 +13305,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13575,7 +13319,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", - "license": "MIT", "peerDependencies": { "postcss": "^8" } @@ -13594,7 +13337,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13606,9 +13348,9 @@ } }, "node_modules/postcss-preset-env": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-10.2.0.tgz", - "integrity": "sha512-cl13sPBbSqo1Q7Ryb19oT5NZO5IHFolRbIMdgDq4f9w1MHYiL6uZS7uSsjXJ1KzRIcX5BMjEeyxmAevVXENa3Q==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-10.2.4.tgz", + "integrity": "sha512-q+lXgqmTMdB0Ty+EQ31SuodhdfZetUlwCA/F0zRcd/XdxjzI+Rl2JhZNz5US2n/7t9ePsvuhCnEN4Bmu86zXlA==", "funding": [ { "type": "github", @@ -13619,9 +13361,8 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { - "@csstools/postcss-cascade-layers": "^5.0.1", + "@csstools/postcss-cascade-layers": "^5.0.2", "@csstools/postcss-color-function": "^4.0.10", "@csstools/postcss-color-mix-function": "^3.0.10", "@csstools/postcss-color-mix-variadic-function-arguments": "^1.0.0", @@ -13633,7 +13374,7 @@ "@csstools/postcss-hwb-function": "^4.0.10", "@csstools/postcss-ic-unit": "^4.0.2", "@csstools/postcss-initial": "^2.0.1", - "@csstools/postcss-is-pseudo-class": "^5.0.1", + "@csstools/postcss-is-pseudo-class": "^5.0.3", "@csstools/postcss-light-dark-function": "^2.0.9", "@csstools/postcss-logical-float-and-clear": "^3.0.0", "@csstools/postcss-logical-overflow": "^2.0.0", @@ -13655,7 +13396,7 @@ "@csstools/postcss-trigonometric-functions": "^4.0.9", "@csstools/postcss-unset-value": "^4.0.0", "autoprefixer": "^10.4.21", - "browserslist": "^4.24.5", + "browserslist": "^4.25.0", "css-blank-pseudo": "^7.0.1", "css-has-pseudo": "^7.0.2", "css-prefers-color-scheme": "^10.0.0", @@ -13666,7 +13407,7 @@ "postcss-color-hex-alpha": "^10.0.0", "postcss-color-rebeccapurple": "^10.0.0", "postcss-custom-media": "^11.0.6", - "postcss-custom-properties": "^14.0.5", + "postcss-custom-properties": "^14.0.6", "postcss-custom-selectors": "^8.0.5", "postcss-dir-pseudo-class": "^9.0.1", "postcss-double-position-gradients": "^6.0.2", @@ -13677,7 +13418,7 @@ "postcss-image-set-function": "^7.0.0", "postcss-lab-function": "^7.0.10", "postcss-logical": "^8.1.0", - "postcss-nesting": "^13.0.1", + "postcss-nesting": "^13.0.2", "postcss-opacity-percentage": "^3.0.0", "postcss-overflow-shorthand": "^6.0.0", "postcss-page-break": "^3.0.4", @@ -13707,7 +13448,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "dependencies": { "postcss-selector-parser": "^7.0.0" }, @@ -13722,7 +13462,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13735,7 +13474,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-6.0.3.tgz", "integrity": "sha512-G3yCqZDpsNPoQgbDUy3T0E6hqOQ5xigUtBQyrmq3tn2GxlyiL0yyl7H+T8ulQR6kOcHJ9t7/9H4/R2tv8tJbMA==", - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13750,7 +13488,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.1.0.tgz", "integrity": "sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw==", - "license": "MIT", "dependencies": { "browserslist": "^4.23.0", "caniuse-api": "^3.0.0" @@ -13766,7 +13503,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.2.tgz", "integrity": "sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA==", - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13781,7 +13517,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", - "license": "MIT", "peerDependencies": { "postcss": "^8.0.3" } @@ -13800,7 +13535,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "dependencies": { "postcss-selector-parser": "^7.0.0" }, @@ -13815,7 +13549,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13828,7 +13561,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13841,7 +13573,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/postcss-sort-media-queries/-/postcss-sort-media-queries-5.2.0.tgz", "integrity": "sha512-AZ5fDMLD8SldlAYlvi8NIqo0+Z8xnXU2ia0jxmuhxAU+Lqt9K+AlmLNJ/zWEnE9x+Zx3qL3+1K20ATgNOr3fAA==", - "license": "MIT", "dependencies": { "sort-css-media-queries": "2.2.0" }, @@ -13856,7 +13587,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.3.tgz", "integrity": "sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==", - "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0", "svgo": "^3.2.0" @@ -13872,7 +13602,6 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-6.0.4.tgz", "integrity": "sha512-K38OCaIrO8+PzpArzkLKB42dSARtC2tmG6PvD4b1o1Q2E9Os8jzfWFfSy/rixsHwohtsDdFtAWGjFVFUdwYaMg==", - "license": "MIT", "dependencies": { "postcss-selector-parser": "^6.0.16" }, @@ -13886,14 +13615,12 @@ "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT" + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, "node_modules/postcss-zindex": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-6.0.2.tgz", "integrity": "sha512-5BxW9l1evPB/4ZIc+2GobEBoKC+h8gPGCMi+jxsYvd2x0mjq7wazk6DrP71pStqxE9Foxh5TVnonbWpFZzXaYg==", - "license": "MIT", "engines": { "node": "^14 || ^16 || >=18.0" }, @@ -13915,7 +13642,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/pretty-time/-/pretty-time-1.1.0.tgz", "integrity": "sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==", - "license": "MIT", "engines": { "node": ">=4" } @@ -13936,7 +13662,6 @@ "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "license": "MIT", "engines": { "node": ">=6" } @@ -14186,7 +13911,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-2.4.1.tgz", "integrity": "sha512-fwFYknRIBxjbFm0kBDrzgBy1xa5tDg2LyXXBepC5f1b+MY3BUClMCsvanMPn089JbV1Eg3nZcrp0VCuH43aXnA==", - "license": "MIT", "engines": { "node": ">=18" }, @@ -14638,7 +14362,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "license": "MIT", "engines": { "node": ">=0.10" } @@ -14754,7 +14477,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz", "integrity": "sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==", - "license": "MIT", "dependencies": { "escalade": "^3.1.1", "picocolors": "^1.0.0", @@ -14818,8 +14540,7 @@ "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "license": "ISC" + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" }, "node_modules/scheduler": { "version": "0.26.0", @@ -14830,8 +14551,7 @@ "node_modules/schema-dts": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.5.tgz", - "integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==", - "license": "Apache-2.0" + "integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==" }, "node_modules/schema-utils": { "version": "4.3.0", @@ -14856,7 +14576,6 @@ "version": "2.17.3", "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "license": "MIT", "peer": true }, "node_modules/section-matter": { @@ -15106,7 +14825,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -15276,7 +14994,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-7.1.2.tgz", "integrity": "sha512-ARCqzHJ0p4gWt+j7NlU5eDlIO9+Rkr/JhPFZKKQ1l5GCus7rJH4UdrlVAh0xC/gDS/Qir2UMxqYNHtsKr2rpCw==", - "license": "MIT", "dependencies": { "@types/node": "^17.0.5", "@types/sax": "^1.2.1", @@ -15294,8 +15011,7 @@ "node_modules/sitemap/node_modules/@types/node": { "version": "17.0.45", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", - "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", - "license": "MIT" + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==" }, "node_modules/skin-tone": { "version": "2.0.0", @@ -15321,7 +15037,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "license": "MIT", "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" @@ -15342,7 +15057,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/sort-css-media-queries/-/sort-css-media-queries-2.2.0.tgz", "integrity": "sha512-0xtkGhWCC9MGt/EzgnvbbbKhqWjl1+/rncmhTh5qCpbYguXh6S/qwePfv/JQ8jePXXmqingylxoC49pCkSPIbA==", - "license": "MIT", "engines": { "node": ">= 6.3.0" } @@ -15356,9 +15070,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } @@ -15428,7 +15142,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/srcset/-/srcset-4.0.0.tgz", "integrity": "sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==", - "license": "MIT", "engines": { "node": ">=12" }, @@ -15448,8 +15161,7 @@ "node_modules/std-env": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", - "license": "MIT" + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==" }, "node_modules/string_decoder": { "version": "1.3.0", @@ -15564,7 +15276,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "license": "MIT", "engines": { "node": ">=8" }, @@ -15584,7 +15295,6 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz", "integrity": "sha512-gSTTEQ670cJNoaeIp9KX6lZmm8LJ3jPB5yJmX8Zq/wQxOsAFXV3qjWzHas3YYk1qesuVIyYWWUpZ0vSE/dTSGg==", - "license": "MIT", "dependencies": { "browserslist": "^4.23.0", "postcss-selector-parser": "^6.0.16" @@ -15621,8 +15331,7 @@ "node_modules/svg-parser": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", - "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", - "license": "MIT" + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==" }, "node_modules/svgo": { "version": "3.3.2", @@ -16762,7 +16471,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-6.0.1.tgz", "integrity": "sha512-TnErZpmuKdwWBdMoexjio3KKX6ZtoKHRVvLIU0A47R0VVBDtx3ZyOJDktgYixhoJokZTYTt1Z37OkO9pnGJa9Q==", - "license": "MIT", "dependencies": { "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", @@ -16783,14 +16491,12 @@ "node_modules/webpackbar/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/webpackbar/node_modules/markdown-table": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", - "license": "MIT", "dependencies": { "repeat-string": "^1.0.0" }, @@ -16803,7 +16509,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -16817,7 +16522,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -16998,7 +16702,6 @@ "version": "1.6.11", "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", - "license": "MIT", "dependencies": { "sax": "^1.2.4" }, @@ -17015,7 +16718,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", - "license": "MIT", "engines": { "node": ">=12.20" }, diff --git a/docs/package.json b/docs/package.json index 2bb440711..def8214d8 100644 --- a/docs/package.json +++ b/docs/package.json @@ -14,9 +14,9 @@ "write-heading-ids": "docusaurus write-heading-ids" }, "dependencies": { - "@docusaurus/core": "3.8.0", - "@docusaurus/plugin-client-redirects": "^3.8.0", - "@docusaurus/preset-classic": "3.8.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/plugin-client-redirects": "^3.8.1", + "@docusaurus/preset-classic": "3.8.1", "@mdx-js/react": "^3.1.0", "clsx": "^2.0.0", "docusaurus-theme-github-codeblock": "^2.0.2", @@ -25,8 +25,8 @@ "react-dom": "^19.1.0" }, "devDependencies": { - "@docusaurus/module-type-aliases": "3.8.0", - "@docusaurus/types": "3.8.0" + "@docusaurus/module-type-aliases": "3.8.1", + "@docusaurus/types": "3.8.1" }, "browserslist": { "production": [ From 50b424e8e644606646e1800e28d68301b7c1b703 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 00:26:36 +0000 Subject: [PATCH 770/865] chore(deps): bump flake8 from 7.2.0 to 7.3.0 (#1326) --- requirements/tools.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/tools.txt b/requirements/tools.txt index 0e6090497..41c427cf7 100644 --- a/requirements/tools.txt +++ b/requirements/tools.txt @@ -1,3 +1,3 @@ mypy==1.16.0 -flake8==7.2.0 +flake8==7.3.0 black==24.8.0 # Until we drop Python 3.6 support, we have to stay with this version From c75897e7b18c44e53455258836f0408fcfe9c1ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 00:30:46 +0000 Subject: [PATCH 771/865] chore(deps): bump mypy from 1.16.0 to 1.16.1 (#1327) --- requirements/tools.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/tools.txt b/requirements/tools.txt index 41c427cf7..0603319d7 100644 --- a/requirements/tools.txt +++ b/requirements/tools.txt @@ -1,3 +1,3 @@ -mypy==1.16.0 +mypy==1.16.1 flake8==7.3.0 black==24.8.0 # Until we drop Python 3.6 support, we have to stay with this version From 347cb5e0e4f32a26fa8d19268557bf6cd2a42052 Mon Sep 17 00:00:00 2001 From: ewanek1 Date: Wed, 2 Jul 2025 09:53:15 -0700 Subject: [PATCH 772/865] Remove py36 references (#1330) --- .github/maintainers_guide.md | 1 - README.md | 2 +- pyproject.toml | 3 +-- scripts/install_all_and_run_tests.sh | 7 +------ slack_bolt/async_app.py | 2 +- 5 files changed, 4 insertions(+), 11 deletions(-) diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index 85b4e13be..4ab491789 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -25,7 +25,6 @@ $ pyenv local 3.8.5 $ pyenv versions system - 3.6.10 3.7.7 * 3.8.5 (set by /path-to-bolt-python/.python-version) diff --git a/README.md b/README.md index 7576597d5..862c63e96 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A Python framework to build Slack apps in a flash with the latest platform featu ## Setup ```bash -# Python 3.6+ required +# Python 3.7+ required python -m venv .venv source .venv/bin/activate diff --git a/pyproject.toml b/pyproject.toml index 5ce2c62bc..5337b5c55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ dynamic = ["version", "readme", "dependencies", "authors"] description = "The Bolt Framework for Python" license = { text = "MIT" } classifiers = [ - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -20,7 +19,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -requires-python = ">=3.6" +requires-python = ">=3.7" [project.urls] diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index 21660dca5..e71c8511b 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -16,12 +16,7 @@ pip uninstall python-lambda test_target="$1" python_version=`python --version | awk '{print $2}'` -if [ ${python_version:0:3} == "3.6" ] -then - pip install -U -r requirements.txt -else - pip install -e . -fi +pip install -e . if [[ $test_target != "" ]] then diff --git a/slack_bolt/async_app.py b/slack_bolt/async_app.py index 10878c51b..fdf724d4c 100644 --- a/slack_bolt/async_app.py +++ b/slack_bolt/async_app.py @@ -5,7 +5,7 @@ If you'd prefer to build your app with [asyncio](https://docs.python.org/3/library/asyncio.html), you can import the [AIOHTTP](https://docs.aiohttp.org/en/stable/) library and call the `AsyncApp` constructor. Within async apps, you can use the async/await pattern. ```bash -# Python 3.6+ required +# Python 3.7+ required python -m venv .venv source .venv/bin/activate From 46e25d3e1f02a9a50bc1b214f3772957473d70a5 Mon Sep 17 00:00:00 2001 From: ewanek1 Date: Wed, 2 Jul 2025 12:16:29 -0700 Subject: [PATCH 773/865] Remove py36 references (#1331) --- .github/maintainers_guide.md | 1 + README.md | 2 +- pyproject.toml | 3 ++- scripts/install_all_and_run_tests.sh | 7 ++++++- slack_bolt/async_app.py | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index 4ab491789..85b4e13be 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -25,6 +25,7 @@ $ pyenv local 3.8.5 $ pyenv versions system + 3.6.10 3.7.7 * 3.8.5 (set by /path-to-bolt-python/.python-version) diff --git a/README.md b/README.md index 862c63e96..7576597d5 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A Python framework to build Slack apps in a flash with the latest platform featu ## Setup ```bash -# Python 3.7+ required +# Python 3.6+ required python -m venv .venv source .venv/bin/activate diff --git a/pyproject.toml b/pyproject.toml index 5337b5c55..5ce2c62bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dynamic = ["version", "readme", "dependencies", "authors"] description = "The Bolt Framework for Python" license = { text = "MIT" } classifiers = [ + "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -19,7 +20,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -requires-python = ">=3.7" +requires-python = ">=3.6" [project.urls] diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index e71c8511b..21660dca5 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -16,7 +16,12 @@ pip uninstall python-lambda test_target="$1" python_version=`python --version | awk '{print $2}'` -pip install -e . +if [ ${python_version:0:3} == "3.6" ] +then + pip install -U -r requirements.txt +else + pip install -e . +fi if [[ $test_target != "" ]] then diff --git a/slack_bolt/async_app.py b/slack_bolt/async_app.py index fdf724d4c..10878c51b 100644 --- a/slack_bolt/async_app.py +++ b/slack_bolt/async_app.py @@ -5,7 +5,7 @@ If you'd prefer to build your app with [asyncio](https://docs.python.org/3/library/asyncio.html), you can import the [AIOHTTP](https://docs.aiohttp.org/en/stable/) library and call the `AsyncApp` constructor. Within async apps, you can use the async/await pattern. ```bash -# Python 3.7+ required +# Python 3.6+ required python -m venv .venv source .venv/bin/activate From 1e69f1d0c9a68c9967864f52e50544c19b2b1d3b Mon Sep 17 00:00:00 2001 From: Luke Russell <31357343+lukegalbraithrussell@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:05:33 -0700 Subject: [PATCH 774/865] Docs: Updates Modals tutorial to not use Glitch (#1334) --- docs/content/tutorial/modals.md | 166 +++++++++++++++++++------------- 1 file changed, 100 insertions(+), 66 deletions(-) diff --git a/docs/content/tutorial/modals.md b/docs/content/tutorial/modals.md index 678d3783b..b6d672d08 100644 --- a/docs/content/tutorial/modals.md +++ b/docs/content/tutorial/modals.md @@ -1,101 +1,135 @@ + # Modals -If you're learning about Slack apps, modals, or slash commands for the first time, you've come to the right place! In this tutorial, we'll take a look at setting up your very own server using Glitch, and using that server to run your Slack app. +If you're learning about Slack apps, modals, or slash commands for the first time, you've come to the right place! In this tutorial, we'll take a look at setting up your very own server using GitHub Codespaces, then using that server to run your Slack app built with the [**Bolt for Python framework**](https://github.com/SlackAPI/bolt-python). -Let's take a look at the technologies we'll use in this tutorial: +:::info[GitHub Codespaces] +GitHub Codespaces is an online IDE that allows you to work on code and host your own server at the same time. While Codespaces is good for testing and development purposes, it should not be used in production. -* Glitch is a online IDE that allows you to collaboratively work on code and host your own server. Glitch should only be used for development purposes and should not be used in production. -* We'll use Python in conjunction with our [Bolt for Python](https://github.com/SlackAPI/bolt-python) SDK. -* [Block Kit](https://docs.slack.dev/block-kit/) is a UI framework for Slack apps that allows you to create beautiful, interactive messages within Slack. If you've ever seen a message in Slack with buttons or a select menu, that's Block Kit. -* Modals are similar to a pop-up window that displays right in Slack. They grab the attention of the user, and are normally used to prompt users to provide some kind of information or input. +::: ---- +At the end of this tutorial, your final app will look like this: -## Final product overview {#final_product} -If you follow through with the extra credit tasks, your final app will look like this: +![announce](https://github.com/user-attachments/assets/0bf1c2f0-4b22-4c9c-98b3-b21e9bcc14a8) -![Final product](/img/tutorials/modals/final_product.gif) +And will make use of these Slack concepts: +* [**Block Kit**](https://docs.slack.dev/block-kit/) is a UI framework for Slack apps that allows you to create beautiful, interactive messages within Slack. If you've ever seen a message in Slack with buttons or a select menu, that's Block Kit. +* [**Modals**](https://docs.slack.dev/surfaces/modals) are a pop-up window that displays right in Slack. They grab the attention of the user, and are normally used to prompt users to provide some kind of information or input in a form. +* [**Slash Commands**](https://docs.slack.dev/interactivity/implementing-slash-commands) allow you to invoke your app within Slack by just typing into the message composer box. e.g. `/remind`, `/topic`. ---- +If you're familiar with using Heroku you can also deploy directly to Heroku with the following button. -## The process {#steps} +[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://www.heroku.com/deploy?template=https://github.com/wongjas/modal-example) -1. [Create a new app](https://api.slack.com/apps/new) and name it whatever you like. +--- + +## Setting up your app within App Settings {#setting-up-app-settings} -2. [Remix (or clone)](https://glitch.com/edit/#!/remix/intro-to-modals-bolt) the Glitch template. +You'll need to create an app and configure it properly within App Settings before using it. -Here's a copy of what the modal payload looks like — this is what powers the modal. +1. [Create a new app](https://api.slack.com/apps/new), click `From a Manifest`, and choose the workspace that you want to develop on. Then copy the following JSON object; it describes the metadata about your app, like its name, its bot display name and permissions it will request. ```json { - "type": "modal", - "callback_id": "gratitude-modal", - "title": { - "type": "plain_text", - "text": "Gratitude Box", - "emoji": true - }, - "submit": { - "type": "plain_text", - "text": "Submit", - "emoji": true - }, - "close": { - "type": "plain_text", - "text": "Cancel", - "emoji": true - }, - "blocks": [ - { - "type": "input", - "block_id": "my_block", - "element": { - "type": "plain_text_input", - "action_id": "my_action" - }, - "label": { - "type": "plain_text", - "text": "Say something nice!", - "emoji": true - } + "display_information": { + "name": "Intro to Modals" + }, + "features": { + "bot_user": { + "display_name": "Intro to Modals", + "always_online": false + }, + "slash_commands": [ + { + "command": "/announce", + "description": "Makes an announcement", + "should_escape": false + } + ] + }, + "oauth_config": { + "scopes": { + "bot": [ + "chat:write", + "commands" + ] + } + }, + "settings": { + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false } - ] } ``` -3. Find the base path to your server by clicking **Share**, then copy the Live site link. +2. Once your app has been created, scroll down to `App-Level Tokens` and create a token that requests for the [`connections:write`](https://docs.slack.dev/reference/scopes/connections.write) scope, which allows you to use [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode), a secure way to develop on Slack through the use of WebSockets. Copy the value of your app token and keep it for safe-keeping. + +3. Install your app by heading to `Install App` in the left sidebar. Hit `Allow`, which means you're agreeing to install your app with the permissions that it is requesting. Be sure to copy the token that you receive, and keep it somewhere secret and safe. - ![Get the base link](/img/tutorials/modals/base_link.gif) +## Starting your Codespaces server {#starting-server} -4. On your app page, navigate to **Interactivity & Shortcuts**. Append "/slack/events" to your base path URL and enter it into the **Request URL** e.g., `https://festive-harmonious-march.glitch.me/slack/events`. This allows your server to retrieve information from the modal. You can see the code for this within the Glitch project. +1. Log into GitHub and head to this [repository](https://github.com/wongjas/modal-example). - ![Interactivity URL](/img/tutorials/modals/interactivity_url.png) +2. Click the green `Code` button and hit the `Codespaces` tab and then `Create codespace on main`. This will bring up a code editor within your browser so you can start coding. -5. Create the slash command so you can access it within Slack. Navigate to the **Slash Commands** section and create a new command. Note the **Request URL** is the same link as above, e.g. `https://festive-harmonious-march.glitch.me/slack/events` . The code that powers the slash command and opens a modal can be found within the Glitch project. +## Understanding the project files {#understanding-files} - ![Slash command details](/img/tutorials/modals/slash_command.png) +Within the project you'll find a `manifest.json` file. This is a a configuration file used by Slack apps. With a manifest, you can create an app with a pre-defined configuration, or adjust the configuration of an existing app. -6. Select **Install App**. After you've done this, you'll see a **Bot User OAuth Access Token**, copy this. +The `simple_modal_example.py` Python script contains the code that powers your app. If you're going to tinker with the app itself, take a look at the comments found within the `simple_modal_example.py` file! -7. Navigate to your Glitch project and click the `.env` file where the credentials are stored, and paste your bot token where the `SLACK_BOT_TOKEN` variable is shown. This allows your server to send authenticated requests to the Slack API. You'll also need to head to your app's settings page under **Basic Information** and copy the _Signing secret_ to place into the `SLACK_SIGNING_SECRET` variable. +The `requirements.txt` file contains the Python package dependencies needed to run this app. - ![Environment variables](/img/tutorials/modals/heart_icon.gif) +:::info[This repo contains optional Heroku-specific configurations] -8. Test by heading to Slack and typing `/thankyou`. +The `app.json` file defines your Heroku app configuration including environment variables and deployment settings, to allow your app to deploy with one click. `Procfile` is a Heroku-specific file that tells Heroku what command to run when starting your app — in this case a Python script would run as a `worker` process. If you aren't deploying to Heroku, you can ignore both these files. -All done! 🎉 You've created your first slash command using Block Kit and modals! The world is your oyster; you can create more complex modals by playing around with [Block Kit Builder](https://app.slack.com/block-kit-builder). +::: + +## Adding tokens {#adding-tokens} + +1. Open a terminal up within the browser's editor. + +2. Grab the app and bot tokens that you kept safe. We're going to set them as environment variables. + +```bash +export SLACK_APP_TOKEN= +export SLACK_BOT_TOKEN= +``` -### Extra credit {#extra_credit} +## Running the app {#running-app} + +1. Activate a virtual environment for your Python packages to be installed. + +```bash +# Setup your python virtual environment +python3 -m venv .venv +source .venv/bin/activate +``` + +2. Install the dependencies from the `requirements.txt` file. + + +```bash +# Install the dependencies +pip install -r requirements.txt +``` + +3. Start your app using the `python3 simple_modal_example.py` command. + +```bash +# Start your local server +python3 simple_modal_example.py +``` -For a little extra credit, let's post the feedback we received in a channel. +4. Now that your app is running, you should be able to see it within Slack. Test this by heading to Slack and typing `/announce`. -1. Add the `chat:write` bot scope, which allows your bot to post messages within Slack. You can do this in the **OAuth & Permissions** section for your Slack app. -2. Reinstall your app to apply the scope. -3. Create a channel and name it `#thanks`. Get its ID by right clicking the channel name, copying the link, and copying the last part starting with the letter `C`. For example, if your channel link looks like this: https://my.slack.com/archives/C123FCN2MLM, the ID is `C123FCN2MLM`. -4. Add your bot to the channel by typing the command `/invite @your_bots_name`. -5. Uncomment the `Extra Credit` code within your Glitch project and make sure to replace `your_channel_id` with the ID above. -6. Test it out by typing `/thankyou`, and watching all the feedback come into your channel! +All done! 🎉 You've created your first slash command using Block Kit and modals! The world is your oyster; play around with [Block Kit Builder](https://app.slack.com/block-kit-builder) and create more complex modals and place them in your code to see what happens! ## Next steps {#next-steps} -If you want to learn more about Bolt for Python, refer to the [Getting Started guide](/bolt-python/getting-started). +If you want to learn more about Bolt for Python, refer to the [Getting Started guide](https://tools.slack.dev/bolt-python/getting-started). \ No newline at end of file From e6b34ebba6c5f43d72e006190e9072a7af5b3ada Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:05:45 -0400 Subject: [PATCH 775/865] chore(deps): bump on-headers and compression in /docs (#1336) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 6790558aa..773665646 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -5828,16 +5828,16 @@ } }, "node_modules/compression": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", - "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", - "on-headers": "~1.0.2", + "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" }, @@ -11872,9 +11872,9 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" From 88ddc7cbe1cf23b45dd4349a357f60d9bfcaa1a6 Mon Sep 17 00:00:00 2001 From: Haley Elmendorf <31392893+haleychaas@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:43:07 -0500 Subject: [PATCH 776/865] Docs: Update language around AI Apps (#1335) --- docs/content/concepts/ai-apps.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/content/concepts/ai-apps.md b/docs/content/concepts/ai-apps.md index d30513bdd..a8da2bf1f 100644 --- a/docs/content/concepts/ai-apps.md +++ b/docs/content/concepts/ai-apps.md @@ -1,5 +1,5 @@ --- -title: AI Apps +title: Using AI in Apps lang: en slug: /concepts/ai-apps --- @@ -8,7 +8,7 @@ slug: /concepts/ai-apps If you don't have a paid workspace for development, you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. ::: -AI apps comprise a new messaging experience for Slack. If you're unfamiliar with using AI apps within Slack, you'll want to read the [API documentation on the subject](https://docs.slack.dev/ai/). Then come back here to implement them with Bolt! +The Agents & AI Apps feature comprises a unique messaging experience for Slack. If you're unfamiliar with using the Agents & AI Apps feature within Slack, you'll want to read the [API documentation on the subject](https://docs.slack.dev/ai/). Then come back here to implement them with Bolt! ## Configuring your app to support AI features {#configuring-your-app} @@ -25,12 +25,12 @@ AI apps comprise a new messaging experience for Slack. If you're unfamiliar with * [`message.im`](https://docs.slack.dev/reference/events/message.im) :::info -You _could_ implement your own AI app by [listening](event-listening) for the `assistant_thread_started`, `assistant_thread_context_changed`, and `message.im` events (see implementation details below). That being said, using the `Assistant` class will streamline the process. And we already wrote this nice guide for you! +You _could_ go it alone and [listen](event-listening) for the `assistant_thread_started`, `assistant_thread_context_changed`, and `message.im` events (see implementation details below) in order to implement the AI features in your app. That being said, using the `Assistant` class will streamline the process. And we already wrote this nice guide for you! ::: ## The `Assistant` class instance {#assistant-class} -The `Assistant` class can be used to handle the incoming events expected from a user interacting with an AI app in Slack. A typical flow would look like: +The `Assistant` class can be used to handle the incoming events expected from a user interacting with an app in Slack that has the Agents & AI Apps feature enabled. A typical flow would look like: 1. [The user starts a thread](#handling-a-new-thread). The `Assistant` class handles the incoming [`assistant_thread_started`](https://docs.slack.dev/reference/events/assistant_thread_started) event. 2. [The thread context may change at any point](#handling-thread-context-changes). The `Assistant` class can handle any incoming [`assistant_thread_context_changed`](https://docs.slack.dev/reference/events/assistant_thread_context_changed) events. The class also provides a default context store to keep track of thread context changes as the user moves through Slack. @@ -107,13 +107,13 @@ Refer to the [module document](https://tools.slack.dev/bolt-python/api-docs/slac ## Handling a new thread {#handling-a-new-thread} -When the user opens a new thread with your AI app, the [`assistant_thread_started`](https://docs.slack.dev/reference/events/assistant_thread_started) event will be sent to your app. +When the user opens a new thread with your AI-enabled app, the [`assistant_thread_started`](https://docs.slack.dev/reference/events/assistant_thread_started) event will be sent to your app. :::tip -When a user opens an AI app thread while in a channel, the channel info is stored as the thread's `AssistantThreadContext` data. You can grab that info by using the `get_thread_context` utility, as subsequent user message event payloads won't include the channel info. +When a user opens an app thread while in a channel, the channel info is stored as the thread's `AssistantThreadContext` data. You can grab that info by using the `get_thread_context` utility, as subsequent user message event payloads won't include the channel info. ::: -### Block Kit interactions in the AI app thread {#block-kit-interactions} +### Block Kit interactions in the app thread {#block-kit-interactions} For advanced use cases, Block Kit buttons may be used instead of suggested prompts, as well as the sending of messages with structured [metadata](https://docs.slack.dev/messaging/message-metadata/) to trigger subsequent interactions with the user. From beba392234ca149a8f3b2cd65141619a09aeea3d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 24 Jul 2025 19:49:22 -0700 Subject: [PATCH 777/865] docs: filter against bot_id in listener middleware example (#1339) --- docs/content/concepts/listener-middleware.md | 11 +++++------ .../current/concepts/listener-middleware.md | 13 ++++++------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/docs/content/concepts/listener-middleware.md b/docs/content/concepts/listener-middleware.md index 338cb0d4f..3507d7d97 100644 --- a/docs/content/concepts/listener-middleware.md +++ b/docs/content/concepts/listener-middleware.md @@ -11,11 +11,10 @@ If your listener middleware is a quite simple one, you can use a listener matche Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. ```python -# Listener middleware which filters out messages with "bot_message" subtype +# Listener middleware which filters out messages from a bot def no_bot_messages(message, next): - subtype = message.get("subtype") - if subtype != "bot_message": - next() + if "bot_id" not in message: + next() # This listener only receives messages from humans @app.event(event="message", middleware=[no_bot_messages]) @@ -24,10 +23,10 @@ def log_message(logger, event): # Listener matchers: simplified version of listener middleware def no_bot_messages(message) -> bool: - return message.get("subtype") != "bot_message" + return "bot_id" not in message @app.event( - event="message", + event="message", matchers=[no_bot_messages] # or matchers=[lambda message: message.get("subtype") != "bot_message"] ) diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/listener-middleware.md b/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/listener-middleware.md index 822b5ac63..a013dde42 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/listener-middleware.md +++ b/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/listener-middleware.md @@ -11,11 +11,10 @@ slug: /concepts/listener-middleware 指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python -# "bot_message" サブタイプのメッセージを抽出するリスナーミドルウェア +# ボットからのメッセージをフィルタリングするリスナーミドルウェア def no_bot_messages(message, next): - subtype = message.get("subtype") - if subtype != "bot_message": - next() + if "bot_id" not in message: + next() # このリスナーは人間によって送信されたメッセージのみを受け取ります @app.event(event="message", middleware=[no_bot_messages]) @@ -24,13 +23,13 @@ def log_message(logger, event): # リスナーマッチャー: 簡略化されたバージョンのリスナーミドルウェア def no_bot_messages(message) -> bool: - return message.get("subtype") != "bot_message" + return "bot_id" not in message @app.event( - event="message", + event="message", matchers=[no_bot_messages] # or matchers=[lambda message: message.get("subtype") != "bot_message"] ) def log_message(logger, event): logger.info(f"(MSG) User: {event['user']}\nMessage: {event['text']}") -``` \ No newline at end of file +``` From df3c426093dd3349ba05dae49820e3709117b25b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:53:42 -0400 Subject: [PATCH 778/865] chore(deps): update pytest requirement from <8.4,>=6.2.5 to >=6.2.5,<8.5 (#1328) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: William Bergamin --- requirements/testing_without_asyncio.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/testing_without_asyncio.txt b/requirements/testing_without_asyncio.txt index 754889faf..d10c4345e 100644 --- a/requirements/testing_without_asyncio.txt +++ b/requirements/testing_without_asyncio.txt @@ -1,3 +1,3 @@ # pip install -r requirements/testing_without_asyncio.txt -pytest>=6.2.5,<8.4 # https://github.com/tornadoweb/tornado/issues/3375 +pytest>=6.2.5,<8.5 # https://github.com/tornadoweb/tornado/issues/3375 pytest-cov>=3,<7 From 1b876abb16b1cd9f0a6bf8ab8decaaa7a179e85c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 15:42:14 -0700 Subject: [PATCH 779/865] chore(deps): bump mypy from 1.16.1 to 1.17.1 (#1343) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/tools.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/tools.txt b/requirements/tools.txt index 0603319d7..c3a383a13 100644 --- a/requirements/tools.txt +++ b/requirements/tools.txt @@ -1,3 +1,3 @@ -mypy==1.16.1 +mypy==1.17.1 flake8==7.3.0 black==24.8.0 # Until we drop Python 3.6 support, we have to stay with this version From dd4d622e8a407567dfbd22b0b36a741b51c574fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 22:47:10 +0000 Subject: [PATCH 780/865] chore(deps): bump the react group in /docs with 2 updates (#1344) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 18 +++++++++--------- docs/package.json | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 773665646..ec7f3558a 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -15,8 +15,8 @@ "clsx": "^2.0.0", "docusaurus-theme-github-codeblock": "^2.0.2", "prism-react-renderer": "^2.4.1", - "react": "^19.1.0", - "react-dom": "^19.1.0" + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { "@docusaurus/module-type-aliases": "3.8.1", @@ -13858,24 +13858,24 @@ } }, "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { - "react": "^19.1.0" + "react": "^19.1.1" } }, "node_modules/react-fast-compare": { diff --git a/docs/package.json b/docs/package.json index def8214d8..b67d8a7a4 100644 --- a/docs/package.json +++ b/docs/package.json @@ -21,8 +21,8 @@ "clsx": "^2.0.0", "docusaurus-theme-github-codeblock": "^2.0.2", "prism-react-renderer": "^2.4.1", - "react": "^19.1.0", - "react-dom": "^19.1.0" + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { "@docusaurus/module-type-aliases": "3.8.1", From 9596797e55f56c92a07868595f002eb2751f0739 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 23:45:47 +0000 Subject: [PATCH 781/865] chore(deps): bump slackapi/slack-github-action from 2.1.0 to 2.1.1 (#1345) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4fe757b1a..f53a603ff 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -89,7 +89,7 @@ jobs: if: failure() && github.ref == 'refs/heads/main' && github.event_name != 'workflow_dispatch' steps: - name: Send notifications of failing tests - uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 with: errors: true webhook: ${{ secrets.SLACK_REGRESSION_FAILURES_WEBHOOK_URL }} From eb3a51923a38fda54f89515dd9e3853014ffaf64 Mon Sep 17 00:00:00 2001 From: Luke Russell <31357343+lukegalbraithrussell@users.noreply.github.com> Date: Thu, 7 Aug 2025 12:04:46 -0700 Subject: [PATCH 782/865] Build: remove docusaurus configuration files (#1342) --- .github/workflows/docs-deploy.yml | 65 - README.md | 6 +- docs/.gitignore | 3 - docs/README.md | 129 - docs/babel.config.js | 3 - docs/content/concepts/custom-steps.md | 154 - docs/content/concepts/web-api.md | 28 - docs/docusaurus.config.js | 114 - docs/english/_sidebar.json | 209 + docs/{content => english}/building-an-app.md | 49 +- .../concepts/acknowledge.md | 14 +- docs/{content => english}/concepts/actions.md | 14 +- .../{content => english}/concepts/adapters.md | 6 +- docs/{content => english}/concepts/ai-apps.md | 62 +- .../{content => english}/concepts/app-home.md | 14 +- docs/{content => english}/concepts/async.md | 6 +- .../concepts/authenticating-oauth.md | 12 +- .../concepts/authorization.md | 8 +- .../{content => english}/concepts/commands.md | 10 +- docs/{content => english}/concepts/context.md | 6 +- .../concepts/custom-adapters.md | 8 +- .../concepts/custom-steps-dynamic-options.md | 32 +- .../concepts/custom-steps.md | 16 +- docs/{content => english}/concepts/errors.md | 6 +- .../concepts/event-listening.md | 12 +- .../concepts/global-middleware.md | 8 +- .../concepts/lazy-listeners.md | 6 +- .../concepts/listener-middleware.md | 8 +- docs/{content => english}/concepts/logging.md | 6 +- .../concepts/message-listening.md | 13 +- .../concepts/message-sending.md | 12 +- .../concepts/opening-modals.md | 12 +- .../concepts/select-menu-options.md | 14 +- .../concepts/shortcuts.md | 18 +- .../concepts/socket-mode.md | 10 +- .../concepts/token-rotation.md | 10 +- .../concepts/updating-pushing-views.md | 14 +- .../concepts/view-submissions.md | 16 +- docs/english/concepts/web-api.md | 22 + docs/{content => english}/getting-started.md | 39 +- docs/{content => english}/index.md | 2 +- .../legacy}/steps-from-apps.md | 38 +- .../tutorial}/ai-chatbot/1.png | Bin .../tutorial}/ai-chatbot/2.png | Bin .../tutorial}/ai-chatbot/3.png | Bin .../tutorial}/ai-chatbot/4.png | Bin .../tutorial}/ai-chatbot/5.png | Bin .../tutorial}/ai-chatbot/6.png | Bin .../tutorial}/ai-chatbot/7.png | Bin .../tutorial}/ai-chatbot/8.png | Bin .../tutorial/ai-chatbot}/ai-chatbot.md | 26 +- .../tutorial/custom-steps-for-jira}/1.png | Bin .../tutorial/custom-steps-for-jira}/2.png | Bin .../tutorial/custom-steps-for-jira}/3.png | Bin .../tutorial/custom-steps-for-jira}/4.png | Bin .../tutorial/custom-steps-for-jira}/5.png | Bin .../tutorial/custom-steps-for-jira}/6.png | Bin .../tutorial/custom-steps-for-jira}/7.png | Bin .../custom-steps-for-jira.md | 24 +- .../add-step.png | Bin .../app-message.png | Bin .../custom-steps-workflow-builder-existing.md | 26 +- .../define-step.png | Bin .../find-step.png | Bin .../inputs.png | Bin .../org-ready.png | Bin .../outputs.png | Bin .../step-inputs.png | Bin .../app-token.png | Bin .../bot-token.png | Bin .../custom-steps-workflow-builder-new.md | 49 +- .../install.png | Bin .../manifest.png | Bin .../wfb-1.png | Bin .../wfb-10.png | Bin .../wfb-11.png | Bin .../wfb-12.png | Bin .../wfb-2.png | Bin .../wfb-3.png | Bin .../wfb-4.png | Bin .../wfb-5.png | Bin .../wfb-6.png | Bin .../wfb-7.png | Bin .../wfb-8.png | Bin .../wfb-9.png | Bin .../workflow-step.png | Bin .../tutorial/custom-steps.md | 12 +- .../tutorial}/modals/base_link.gif | Bin .../tutorial}/modals/final_product.gif | Bin .../tutorial}/modals/heart_icon.gif | Bin .../tutorial}/modals/interactivity_url.png | Bin .../tutorial/modals}/modals.md | 11 +- .../tutorial}/modals/slash_command.png | Bin docs/footerConfig.js | 21 - docs/i18n/ja-jp/README.md | 121 - docs/i18n/ja-jp/code.json | 321 - .../current.json | 78 - .../current/concepts/app-home.md | 43 - .../current/concepts/web-api.md | 23 - .../docusaurus-theme-classic/footer.json | 6 - .../docusaurus-theme-classic/navbar.json | 62 - .../boltpy => img}/basic-information-page.png | Bin docs/{static/img/boltpy => img}/bot-token.png | Bin .../concepts/acknowledge.md | 12 +- .../current => japanese}/concepts/actions.md | 14 +- .../current => japanese}/concepts/adapters.md | 6 +- docs/japanese/concepts/app-home.md | 40 + .../concepts/assistant.md | 16 +- .../current => japanese}/concepts/async.md | 6 +- .../concepts/authenticating-oauth.md | 12 +- .../concepts/authorization.md | 8 +- .../current => japanese}/concepts/commands.md | 10 +- .../current => japanese}/concepts/context.md | 6 +- .../concepts/custom-adapters.md | 8 +- .../current => japanese}/concepts/errors.md | 6 +- .../concepts/event-listening.md | 12 +- .../concepts/global-middleware.md | 9 +- .../concepts/lazy-listeners.md | 6 +- .../concepts/listener-middleware.md | 8 +- .../current => japanese}/concepts/logging.md | 6 +- .../concepts/message-listening.md | 10 +- .../concepts/message-sending.md | 12 +- .../concepts/opening-modals.md | 12 +- .../concepts/select-menu-options.md | 14 +- .../concepts/shortcuts.md | 18 +- .../concepts/socket-mode.md | 10 +- .../concepts/token-rotation.md | 10 +- .../concepts/updating-pushing-views.md | 14 +- .../concepts/view-submissions.md | 16 +- docs/japanese/concepts/web-api.md | 19 + .../current => japanese}/getting-started.md | 62 +- .../legacy/steps-from-apps.md | 30 +- docs/navbarConfig.js | 97 - docs/package-lock.json | 16738 ---------------- docs/package.json | 46 - .../adapter/aiohttp/index.html | 0 .../adapter/asgi/aiohttp/index.html | 0 .../adapter/asgi/async_handler.html | 0 .../adapter/asgi/base_handler.html | 0 .../adapter/asgi/builtin/index.html | 0 .../adapter/asgi/http_request.html | 0 .../adapter/asgi/http_response.html | 0 .../adapter/asgi/index.html | 0 .../adapter/asgi/utils.html | 0 .../adapter/aws_lambda/chalice_handler.html | 0 .../chalice_lazy_listener_runner.html | 0 .../adapter/aws_lambda/handler.html | 0 .../adapter/aws_lambda/index.html | 0 .../adapter/aws_lambda/internals.html | 0 .../aws_lambda/lambda_s3_oauth_flow.html | 0 .../aws_lambda/lazy_listener_runner.html | 0 .../aws_lambda/local_lambda_client.html | 0 .../adapter/bottle/handler.html | 0 .../adapter/bottle/index.html | 0 .../adapter/cherrypy/handler.html | 0 .../adapter/cherrypy/index.html | 0 .../adapter/django/handler.html | 0 .../adapter/django/index.html | 0 .../adapter/falcon/async_resource.html | 0 .../adapter/falcon/index.html | 0 .../adapter/falcon/resource.html | 0 .../adapter/fastapi/async_handler.html | 0 .../adapter/fastapi/index.html | 0 .../adapter/flask/handler.html | 0 .../adapter/flask/index.html | 0 .../google_cloud_functions/handler.html | 0 .../adapter/google_cloud_functions/index.html | 0 .../adapter/index.html | 0 .../adapter/pyramid/handler.html | 0 .../adapter/pyramid/index.html | 0 .../adapter/sanic/async_handler.html | 0 .../adapter/sanic/index.html | 0 .../adapter/socket_mode/aiohttp/index.html | 0 .../socket_mode/async_base_handler.html | 0 .../adapter/socket_mode/async_handler.html | 0 .../adapter/socket_mode/async_internals.html | 0 .../adapter/socket_mode/base_handler.html | 0 .../adapter/socket_mode/builtin/index.html | 0 .../adapter/socket_mode/index.html | 0 .../adapter/socket_mode/internals.html | 0 .../socket_mode/websocket_client/index.html | 0 .../adapter/socket_mode/websockets/index.html | 0 .../adapter/starlette/async_handler.html | 0 .../adapter/starlette/handler.html | 0 .../adapter/starlette/index.html | 0 .../adapter/tornado/async_handler.html | 0 .../adapter/tornado/handler.html | 0 .../adapter/tornado/index.html | 0 .../adapter/wsgi/handler.html | 0 .../adapter/wsgi/http_request.html | 0 .../adapter/wsgi/http_response.html | 0 .../adapter/wsgi/index.html | 0 .../adapter/wsgi/internals.html | 0 .../slack_bolt => reference}/app/app.html | 0 .../app/async_app.html | 0 .../app/async_server.html | 0 .../slack_bolt => reference}/app/index.html | 0 .../slack_bolt => reference}/async_app.html | 0 .../authorization/async_authorize.html | 0 .../authorization/async_authorize_args.html | 0 .../authorization/authorize.html | 0 .../authorization/authorize_args.html | 0 .../authorization/authorize_result.html | 0 .../authorization/index.html | 0 .../context/ack/ack.html | 0 .../context/ack/async_ack.html | 0 .../context/ack/index.html | 0 .../context/ack/internals.html | 0 .../assistant/assistant_utilities.html | 0 .../assistant/async_assistant_utilities.html | 0 .../context/assistant/index.html | 0 .../context/assistant/internals.html | 0 .../assistant/thread_context/index.html | 0 .../thread_context_store/async_store.html | 0 .../default_async_store.html | 0 .../thread_context_store/default_store.html | 0 .../thread_context_store/file/index.html | 0 .../assistant/thread_context_store/index.html | 0 .../assistant/thread_context_store/store.html | 0 .../context/async_context.html | 0 .../context/base_context.html | 0 .../context/complete/async_complete.html | 0 .../context/complete/complete.html | 0 .../context/complete/index.html | 0 .../context/context.html | 0 .../context/fail/async_fail.html | 0 .../context/fail/fail.html | 0 .../context/fail/index.html | 0 .../async_get_thread_context.html | 0 .../get_thread_context.html | 0 .../context/get_thread_context/index.html | 0 .../context/index.html | 0 .../context/respond/async_respond.html | 0 .../context/respond/index.html | 0 .../context/respond/internals.html | 0 .../context/respond/respond.html | 0 .../async_save_thread_context.html | 0 .../context/save_thread_context/index.html | 0 .../save_thread_context.html | 0 .../context/say/async_say.html | 0 .../context/say/index.html | 0 .../context/say/internals.html | 0 .../context/say/say.html | 0 .../context/set_status/async_set_status.html | 0 .../context/set_status/index.html | 0 .../context/set_status/set_status.html | 0 .../async_set_suggested_prompts.html | 0 .../context/set_suggested_prompts/index.html | 0 .../set_suggested_prompts.html | 0 .../context/set_title/async_set_title.html | 0 .../context/set_title/index.html | 0 .../context/set_title/set_title.html | 0 .../slack_bolt => reference}/error/index.html | 0 .../slack_bolt => reference}/index.html | 0 .../kwargs_injection/args.html | 0 .../kwargs_injection/async_args.html | 0 .../kwargs_injection/async_utils.html | 0 .../kwargs_injection/index.html | 0 .../kwargs_injection/utils.html | 0 .../lazy_listener/async_internals.html | 0 .../lazy_listener/async_runner.html | 0 .../lazy_listener/asyncio_runner.html | 0 .../lazy_listener/index.html | 0 .../lazy_listener/internals.html | 0 .../lazy_listener/runner.html | 0 .../lazy_listener/thread_runner.html | 0 .../listener/async_builtins.html | 0 .../listener/async_listener.html | 0 .../async_listener_completion_handler.html | 0 .../async_listener_error_handler.html | 0 .../async_listener_start_handler.html | 0 .../listener/asyncio_runner.html | 0 .../listener/builtins.html | 0 .../listener/custom_listener.html | 0 .../listener/index.html | 0 .../listener/listener.html | 0 .../listener/listener_completion_handler.html | 0 .../listener/listener_error_handler.html | 0 .../listener/listener_start_handler.html | 0 .../listener/thread_runner.html | 0 .../listener_matcher/async_builtins.html | 0 .../async_listener_matcher.html | 0 .../listener_matcher/builtins.html | 0 .../custom_listener_matcher.html | 0 .../listener_matcher/index.html | 0 .../listener_matcher/listener_matcher.html | 0 .../logger/index.html | 0 .../logger/messages.html | 0 .../middleware/assistant/assistant.html | 0 .../middleware/assistant/async_assistant.html | 0 .../middleware/assistant/index.html | 0 .../middleware/async_builtins.html | 0 .../middleware/async_custom_middleware.html | 0 .../middleware/async_middleware.html | 0 .../async_middleware_error_handler.html | 0 .../async_attaching_function_token.html | 0 .../attaching_function_token.html | 0 .../attaching_function_token/index.html | 0 .../authorization/async_authorization.html | 0 .../authorization/async_internals.html | 0 .../async_multi_teams_authorization.html | 0 .../async_single_team_authorization.html | 0 .../authorization/authorization.html | 0 .../middleware/authorization/index.html | 0 .../middleware/authorization/internals.html | 0 .../multi_teams_authorization.html | 0 .../single_team_authorization.html | 0 .../middleware/custom_middleware.html | 0 .../async_ignoring_self_events.html | 0 .../ignoring_self_events.html | 0 .../ignoring_self_events/index.html | 0 .../middleware/index.html | 0 .../async_message_listener_matches.html | 0 .../message_listener_matches/index.html | 0 .../message_listener_matches.html | 0 .../middleware/middleware.html | 0 .../middleware/middleware_error_handler.html | 0 .../async_request_verification.html | 0 .../request_verification/index.html | 0 .../request_verification.html | 0 .../middleware/ssl_check/async_ssl_check.html | 0 .../middleware/ssl_check/index.html | 0 .../middleware/ssl_check/ssl_check.html | 0 .../async_url_verification.html | 0 .../middleware/url_verification/index.html | 0 .../url_verification/url_verification.html | 0 .../oauth/async_callback_options.html | 0 .../oauth/async_internals.html | 0 .../oauth/async_oauth_flow.html | 0 .../oauth/async_oauth_settings.html | 0 .../oauth/callback_options.html | 0 .../slack_bolt => reference}/oauth/index.html | 0 .../oauth/internals.html | 0 .../oauth/oauth_flow.html | 0 .../oauth/oauth_settings.html | 0 .../request/async_internals.html | 0 .../request/async_request.html | 0 .../request/index.html | 0 .../request/internals.html | 0 .../request/payload_utils.html | 0 .../request/request.html | 0 .../response/index.html | 0 .../response/response.html | 0 .../util/async_utils.html | 0 .../slack_bolt => reference}/util/index.html | 0 .../slack_bolt => reference}/util/utils.html | 0 .../slack_bolt => reference}/version.html | 0 .../workflows/index.html | 0 .../workflows/step/async_step.html | 0 .../workflows/step/async_step_middleware.html | 0 .../workflows/step/index.html | 0 .../workflows/step/internals.html | 0 .../workflows/step/step.html | 0 .../workflows/step/step_middleware.html | 0 .../step/utilities/async_complete.html | 0 .../step/utilities/async_configure.html | 0 .../workflows/step/utilities/async_fail.html | 0 .../step/utilities/async_update.html | 0 .../workflows/step/utilities/complete.html | 0 .../workflows/step/utilities/configure.html | 0 .../workflows/step/utilities/fail.html | 0 .../workflows/step/utilities/index.html | 0 .../workflows/step/utilities/update.html | 0 docs/sidebars.js | 127 - docs/src/css/custom.css | 583 - docs/src/theme/NotFound/Content/index.js | 36 - docs/src/theme/NotFound/index.js | 19 - docs/static/.nojekyll | 0 docs/static/img/bolt-logo.svg | 1 - docs/static/img/bolt-py-logo.svg | 1 - docs/static/img/boltpy/bolt-favicon.png | Bin 3376 -> 0 bytes docs/static/img/boltpy/ngrok.gif | Bin 49094 -> 0 bytes docs/static/img/boltpy/request-url-config.png | Bin 168494 -> 0 bytes docs/static/img/boltpy/signing-secret.png | Bin 289939 -> 0 bytes docs/static/img/favicon.ico | Bin 24499 -> 0 bytes docs/static/img/slack-logo-on-white.png | Bin 25811 -> 0 bytes docs/static/img/slack-logo.svg | 6 - examples/getting_started/app.py | 2 +- scripts/generate_api_docs.sh | 6 +- 379 files changed, 679 insertions(+), 19452 deletions(-) delete mode 100644 .github/workflows/docs-deploy.yml delete mode 100644 docs/.gitignore delete mode 100644 docs/README.md delete mode 100644 docs/babel.config.js delete mode 100644 docs/content/concepts/custom-steps.md delete mode 100644 docs/content/concepts/web-api.md delete mode 100644 docs/docusaurus.config.js create mode 100644 docs/english/_sidebar.json rename docs/{content => english}/building-an-app.md (81%) rename docs/{content => english}/concepts/acknowledge.md (60%) rename docs/{content => english}/concepts/actions.md (74%) rename docs/{content => english}/concepts/adapters.md (97%) rename docs/{content => english}/concepts/ai-apps.md (79%) rename docs/{content => english}/concepts/app-home.md (52%) rename docs/{content => english}/concepts/async.md (97%) rename docs/{content => english}/concepts/authenticating-oauth.md (89%) rename docs/{content => english}/concepts/authorization.md (94%) rename docs/{content => english}/concepts/commands.md (74%) rename docs/{content => english}/concepts/context.md (96%) rename docs/{content => english}/concepts/custom-adapters.md (92%) rename docs/{content => english}/concepts/custom-steps-dynamic-options.md (75%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => english}/concepts/custom-steps.md (86%) rename docs/{content => english}/concepts/errors.md (90%) rename docs/{content => english}/concepts/event-listening.md (60%) rename docs/{content => english}/concepts/global-middleware.md (82%) rename docs/{content => english}/concepts/lazy-listeners.md (98%) rename docs/{content => english}/concepts/listener-middleware.md (83%) rename docs/{content => english}/concepts/logging.md (94%) rename docs/{content => english}/concepts/message-listening.md (59%) rename docs/{content => english}/concepts/message-sending.md (77%) rename docs/{content => english}/concepts/opening-modals.md (68%) rename docs/{content => english}/concepts/select-menu-options.md (67%) rename docs/{content => english}/concepts/shortcuts.md (74%) rename docs/{content => english}/concepts/socket-mode.md (78%) rename docs/{content => english}/concepts/token-rotation.md (73%) rename docs/{content => english}/concepts/updating-pushing-views.md (64%) rename docs/{content => english}/concepts/view-submissions.md (74%) create mode 100644 docs/english/concepts/web-api.md rename docs/{content => english}/getting-started.md (80%) rename docs/{content => english}/index.md (93%) rename docs/{content/concepts => english/legacy}/steps-from-apps.md (67%) rename docs/{static/img/tutorials => english/tutorial}/ai-chatbot/1.png (100%) rename docs/{static/img/tutorials => english/tutorial}/ai-chatbot/2.png (100%) rename docs/{static/img/tutorials => english/tutorial}/ai-chatbot/3.png (100%) rename docs/{static/img/tutorials => english/tutorial}/ai-chatbot/4.png (100%) rename docs/{static/img/tutorials => english/tutorial}/ai-chatbot/5.png (100%) rename docs/{static/img/tutorials => english/tutorial}/ai-chatbot/6.png (100%) rename docs/{static/img/tutorials => english/tutorial}/ai-chatbot/7.png (100%) rename docs/{static/img/tutorials => english/tutorial}/ai-chatbot/8.png (100%) rename docs/{content/tutorial => english/tutorial/ai-chatbot}/ai-chatbot.md (88%) rename docs/{static/img/tutorials/custom-steps-jira => english/tutorial/custom-steps-for-jira}/1.png (100%) rename docs/{static/img/tutorials/custom-steps-jira => english/tutorial/custom-steps-for-jira}/2.png (100%) rename docs/{static/img/tutorials/custom-steps-jira => english/tutorial/custom-steps-for-jira}/3.png (100%) rename docs/{static/img/tutorials/custom-steps-jira => english/tutorial/custom-steps-for-jira}/4.png (100%) rename docs/{static/img/tutorials/custom-steps-jira => english/tutorial/custom-steps-for-jira}/5.png (100%) rename docs/{static/img/tutorials/custom-steps-jira => english/tutorial/custom-steps-for-jira}/6.png (100%) rename docs/{static/img/tutorials/custom-steps-jira => english/tutorial/custom-steps-for-jira}/7.png (100%) rename docs/{content/tutorial => english/tutorial/custom-steps-for-jira}/custom-steps-for-jira.md (86%) rename docs/{static/img/tutorials/custom-steps-wfb-existing => english/tutorial/custom-steps-workflow-builder-existing}/add-step.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-existing => english/tutorial/custom-steps-workflow-builder-existing}/app-message.png (100%) rename docs/{content/tutorial => english/tutorial/custom-steps-workflow-builder-existing}/custom-steps-workflow-builder-existing.md (91%) rename docs/{static/img/tutorials/custom-steps-wfb-existing => english/tutorial/custom-steps-workflow-builder-existing}/define-step.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-existing => english/tutorial/custom-steps-workflow-builder-existing}/find-step.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-existing => english/tutorial/custom-steps-workflow-builder-existing}/inputs.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-existing => english/tutorial/custom-steps-workflow-builder-existing}/org-ready.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-existing => english/tutorial/custom-steps-workflow-builder-existing}/outputs.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-existing => english/tutorial/custom-steps-workflow-builder-existing}/step-inputs.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/app-token.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/bot-token.png (100%) rename docs/{content/tutorial => english/tutorial/custom-steps-workflow-builder-new}/custom-steps-workflow-builder-new.md (90%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/install.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/manifest.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/wfb-1.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/wfb-10.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/wfb-11.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/wfb-12.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/wfb-2.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/wfb-3.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/wfb-4.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/wfb-5.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/wfb-6.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/wfb-7.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/wfb-8.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/wfb-9.png (100%) rename docs/{static/img/tutorials/custom-steps-wfb-new => english/tutorial/custom-steps-workflow-builder-new}/workflow-step.png (100%) rename docs/{content => english}/tutorial/custom-steps.md (94%) rename docs/{static/img/tutorials => english/tutorial}/modals/base_link.gif (100%) rename docs/{static/img/tutorials => english/tutorial}/modals/final_product.gif (100%) rename docs/{static/img/tutorials => english/tutorial}/modals/heart_icon.gif (100%) rename docs/{static/img/tutorials => english/tutorial}/modals/interactivity_url.png (100%) rename docs/{content/tutorial => english/tutorial/modals}/modals.md (83%) rename docs/{static/img/tutorials => english/tutorial}/modals/slash_command.png (100%) delete mode 100644 docs/footerConfig.js delete mode 100644 docs/i18n/ja-jp/README.md delete mode 100644 docs/i18n/ja-jp/code.json delete mode 100644 docs/i18n/ja-jp/docusaurus-plugin-content-docs/current.json delete mode 100644 docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/app-home.md delete mode 100644 docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/web-api.md delete mode 100644 docs/i18n/ja-jp/docusaurus-theme-classic/footer.json delete mode 100644 docs/i18n/ja-jp/docusaurus-theme-classic/navbar.json rename docs/{static/img/boltpy => img}/basic-information-page.png (100%) rename docs/{static/img/boltpy => img}/bot-token.png (100%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/acknowledge.md (69%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/actions.md (76%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/adapters.md (97%) create mode 100644 docs/japanese/concepts/app-home.md rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/assistant.md (91%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/async.md (97%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/authenticating-oauth.md (89%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/authorization.md (94%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/commands.md (72%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/context.md (96%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/custom-adapters.md (90%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/errors.md (92%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/event-listening.md (50%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/global-middleware.md (81%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/lazy-listeners.md (98%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/listener-middleware.md (83%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/logging.md (95%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/message-listening.md (55%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/message-sending.md (74%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/opening-modals.md (62%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/select-menu-options.md (68%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/shortcuts.md (77%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/socket-mode.md (86%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/token-rotation.md (67%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/updating-pushing-views.md (60%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/concepts/view-submissions.md (72%) create mode 100644 docs/japanese/concepts/web-api.md rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/getting-started.md (80%) rename docs/{i18n/ja-jp/docusaurus-plugin-content-docs/current => japanese}/legacy/steps-from-apps.md (71%) delete mode 100644 docs/navbarConfig.js delete mode 100644 docs/package-lock.json delete mode 100644 docs/package.json rename docs/{static/api-docs/slack_bolt => reference}/adapter/aiohttp/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/asgi/aiohttp/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/asgi/async_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/asgi/base_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/asgi/builtin/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/asgi/http_request.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/asgi/http_response.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/asgi/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/asgi/utils.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/aws_lambda/chalice_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/aws_lambda/chalice_lazy_listener_runner.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/aws_lambda/handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/aws_lambda/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/aws_lambda/internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/aws_lambda/lambda_s3_oauth_flow.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/aws_lambda/lazy_listener_runner.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/aws_lambda/local_lambda_client.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/bottle/handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/bottle/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/cherrypy/handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/cherrypy/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/django/handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/django/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/falcon/async_resource.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/falcon/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/falcon/resource.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/fastapi/async_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/fastapi/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/flask/handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/flask/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/google_cloud_functions/handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/google_cloud_functions/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/pyramid/handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/pyramid/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/sanic/async_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/sanic/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/socket_mode/aiohttp/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/socket_mode/async_base_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/socket_mode/async_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/socket_mode/async_internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/socket_mode/base_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/socket_mode/builtin/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/socket_mode/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/socket_mode/internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/socket_mode/websocket_client/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/socket_mode/websockets/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/starlette/async_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/starlette/handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/starlette/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/tornado/async_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/tornado/handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/tornado/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/wsgi/handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/wsgi/http_request.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/wsgi/http_response.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/wsgi/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/adapter/wsgi/internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/app/app.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/app/async_app.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/app/async_server.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/app/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/async_app.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/authorization/async_authorize.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/authorization/async_authorize_args.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/authorization/authorize.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/authorization/authorize_args.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/authorization/authorize_result.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/authorization/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/ack/ack.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/ack/async_ack.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/ack/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/ack/internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/assistant/assistant_utilities.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/assistant/async_assistant_utilities.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/assistant/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/assistant/internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/assistant/thread_context/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/assistant/thread_context_store/async_store.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/assistant/thread_context_store/default_async_store.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/assistant/thread_context_store/default_store.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/assistant/thread_context_store/file/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/assistant/thread_context_store/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/assistant/thread_context_store/store.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/async_context.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/base_context.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/complete/async_complete.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/complete/complete.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/complete/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/context.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/fail/async_fail.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/fail/fail.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/fail/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/get_thread_context/async_get_thread_context.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/get_thread_context/get_thread_context.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/get_thread_context/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/respond/async_respond.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/respond/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/respond/internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/respond/respond.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/save_thread_context/async_save_thread_context.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/save_thread_context/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/save_thread_context/save_thread_context.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/say/async_say.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/say/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/say/internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/say/say.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/set_status/async_set_status.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/set_status/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/set_status/set_status.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/set_suggested_prompts/async_set_suggested_prompts.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/set_suggested_prompts/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/set_suggested_prompts/set_suggested_prompts.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/set_title/async_set_title.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/set_title/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/context/set_title/set_title.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/error/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/kwargs_injection/args.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/kwargs_injection/async_args.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/kwargs_injection/async_utils.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/kwargs_injection/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/kwargs_injection/utils.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/lazy_listener/async_internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/lazy_listener/async_runner.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/lazy_listener/asyncio_runner.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/lazy_listener/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/lazy_listener/internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/lazy_listener/runner.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/lazy_listener/thread_runner.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/async_builtins.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/async_listener.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/async_listener_completion_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/async_listener_error_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/async_listener_start_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/asyncio_runner.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/builtins.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/custom_listener.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/listener.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/listener_completion_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/listener_error_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/listener_start_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener/thread_runner.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener_matcher/async_builtins.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener_matcher/async_listener_matcher.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener_matcher/builtins.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener_matcher/custom_listener_matcher.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener_matcher/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/listener_matcher/listener_matcher.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/logger/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/logger/messages.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/assistant/assistant.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/assistant/async_assistant.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/assistant/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/async_builtins.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/async_custom_middleware.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/async_middleware.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/async_middleware_error_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/attaching_function_token/async_attaching_function_token.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/attaching_function_token/attaching_function_token.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/attaching_function_token/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/authorization/async_authorization.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/authorization/async_internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/authorization/async_multi_teams_authorization.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/authorization/async_single_team_authorization.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/authorization/authorization.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/authorization/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/authorization/internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/authorization/multi_teams_authorization.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/authorization/single_team_authorization.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/custom_middleware.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/ignoring_self_events/async_ignoring_self_events.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/ignoring_self_events/ignoring_self_events.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/ignoring_self_events/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/message_listener_matches/async_message_listener_matches.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/message_listener_matches/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/message_listener_matches/message_listener_matches.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/middleware.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/middleware_error_handler.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/request_verification/async_request_verification.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/request_verification/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/request_verification/request_verification.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/ssl_check/async_ssl_check.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/ssl_check/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/ssl_check/ssl_check.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/url_verification/async_url_verification.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/url_verification/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/middleware/url_verification/url_verification.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/oauth/async_callback_options.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/oauth/async_internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/oauth/async_oauth_flow.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/oauth/async_oauth_settings.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/oauth/callback_options.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/oauth/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/oauth/internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/oauth/oauth_flow.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/oauth/oauth_settings.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/request/async_internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/request/async_request.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/request/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/request/internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/request/payload_utils.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/request/request.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/response/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/response/response.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/util/async_utils.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/util/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/util/utils.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/version.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/async_step.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/async_step_middleware.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/internals.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/step.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/step_middleware.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/utilities/async_complete.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/utilities/async_configure.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/utilities/async_fail.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/utilities/async_update.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/utilities/complete.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/utilities/configure.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/utilities/fail.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/utilities/index.html (100%) rename docs/{static/api-docs/slack_bolt => reference}/workflows/step/utilities/update.html (100%) delete mode 100644 docs/sidebars.js delete mode 100644 docs/src/css/custom.css delete mode 100644 docs/src/theme/NotFound/Content/index.js delete mode 100644 docs/src/theme/NotFound/index.js delete mode 100644 docs/static/.nojekyll delete mode 100644 docs/static/img/bolt-logo.svg delete mode 100644 docs/static/img/bolt-py-logo.svg delete mode 100644 docs/static/img/boltpy/bolt-favicon.png delete mode 100644 docs/static/img/boltpy/ngrok.gif delete mode 100644 docs/static/img/boltpy/request-url-config.png delete mode 100644 docs/static/img/boltpy/signing-secret.png delete mode 100644 docs/static/img/favicon.ico delete mode 100644 docs/static/img/slack-logo-on-white.png delete mode 100644 docs/static/img/slack-logo.svg diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml deleted file mode 100644 index ed18c4b1d..000000000 --- a/.github/workflows/docs-deploy.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Deploy to GitHub Pages - -on: - pull_request: - branches: - - main - paths: - - "docs/**" - push: - branches: - - main - paths: - - "docs/**" - workflow_dispatch: - -jobs: - build: - name: Build Docusaurus - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - persist-credentials: false - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 20 - cache: npm - cache-dependency-path: docs/package-lock.json - - - name: Install dependencies - run: npm ci - working-directory: ./docs - - - name: Build website - run: npm run build - working-directory: ./docs - - - name: Upload Build Artifact - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 - with: - path: ./docs/build - - deploy: - name: Deploy to GitHub Pages - if: github.event_name != 'pull_request' - needs: build - - # Grant GITHUB_TOKEN the permissions required to make a Pages deployment - permissions: - pages: write # to deploy to Pages - id-token: write # verifies deployment is from an appropriate source - - # Deploy to the github-pages environment - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - runs-on: ubuntu-latest - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/README.md b/README.md index 7576597d5..c6b6a536c 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@
    Python Versions - + Documentation

    -A Python framework to build Slack apps in a flash with the latest platform features. Read the [getting started guide](https://tools.slack.dev/bolt-python/getting-started) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. The Python module documents are available [here](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/). +A Python framework to build Slack apps in a flash with the latest platform features. Read the [getting started guide](https://docs.slack.dev/tools/bolt-python/getting-started) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. The Python module documents are available [here](https://docs.slack.dev/tools/bolt-python/reference/). ## Setup @@ -192,7 +192,7 @@ Apps can be run the same way as the syncronous example above. If you'd prefer an ## Getting Help -[The documentation](https://tools.slack.dev/bolt-python) has more information on basic and advanced concepts for Bolt for Python. Also, all the Python module documents of this library are available [here](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/). +[The documentation](https://tools.slack.dev/bolt-python) has more information on basic and advanced concepts for Bolt for Python. Also, all the Python module documents of this library are available [here](https://tools.slack.dev/bolt-python/reference/). If you otherwise get stuck, we're here to help. The following are the best ways to get assistance working through your issue: diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index 53a1610fd..000000000 --- a/docs/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/ -.docusaurus -build \ No newline at end of file diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 023d89975..000000000 --- a/docs/README.md +++ /dev/null @@ -1,129 +0,0 @@ -# tools.slack.dev/bolt-python - -This website is built using [Docusaurus](https://docusaurus.io/). 'Tis cool. - -Each Bolt/SDK has its own Docusaurus website, with matching CSS and nav/footer. There is also be a Docusaurus website of just the homepage and community tools. - -``` -website/ -├── docs/ (the good stuff. md and mdx files supported) -│ ├── getting-started.md -│ └── concepts -│ └── sending-message.md -├── i18n/ja/ (the japanese translations) -│ ├──docusaurus-theme-classic/ (footer/navbar translations) -│ └──docusaurus-plugin-content-docs/ -│ └── current/ ( file names need to exactly match **/docs/, but japanese content) -│ ├── getting-started.md -│ └── concepts -│ └── sending-message.md -├── static/ -│ ├── css/ -│ │ └── custom.css (the css for everything!) -│ ├── img/ (the pictures for the site) -│ │ ├── rory.png -│ │ └── oslo.svg -│ └── api-docs/slack_bolt (the generated reference docs with their own HTML/CSS) -│ ├── index.html -│ └── adaptor -│ └── index.html -├── src/ -│ ├── pages/ (stuff that isn't docs. This is empty for this repo!) -│ └── theme/ (only contains the 404 page) -├── docusaurus.config.js (main config file) -├── footerConfig.js (footer. go to main repo to change) -├── navbarConfig.js (navbar. go to main repo to change) -└── sidebar.js (manually set where the docs are in the sidebar.) -``` - -A cheat-sheet: -* _I want to edit a doc._ `docs/*/*.md` -* _I want to edit a Japanese doc._ `i18n/ja-jp/docusaurus-plugin-content-docs/current/*/*.md`. See the [Japanese docs README](./docs/README.md) -* _I want to change the docs sidebar._ `sidebar.js` -* _I want to change the css._ Don't use this repo, use the home repo and the changes will propagate here. -* _I want to change anything else._ `docusaurus.config.js` - ----- - -## Adding a doc - -1. Make a markdown file. Add a `# Title` or use [front matter](https://docusaurus.io/docs/next/create-doc) with `title:`. -2. Save it in `docs/folder/title.md` or `docs/title.md`, depending on if it's in a sidebar category. The nuance is just for internal organization. -3. There needs to be 1:1 docs for the sidebar. Copy the folder/file and put it in the Japanese docs: `i18n/ja/docusaurus-plugin-content-docs/current/*`. Just leave it in English if you don't speak Japanese. -4. Add the doc's path to the sidebar within `docusaurus.config.js`. Where ever makes most sense for you. -5. Test the changes ↓ - ---- - -## Running locally - -Docusaurus requires at least Node 18. You can update Node however you want. `nvm` is one way. - -Install `nvm` if you don't have it: - -``` -curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash -``` - -Then grab the latest version of Node. - -``` -nvm install node -``` - -If you are running this project locally for the first time, you'll need to install the packages with the following command: - -``` -npm install -``` - -The following command starts a local development server and opens up a browser window. - -``` -npm run start -``` - -Edits to pages are reflected live — no restarting the server or reloading the page. (I'd say... 95% of the time, and 100% time if you're just editing a markdown file). The generated reference docs only load in prod! - -Remember — you're only viewing the Bolt for Python docs right now. - -#### Running locally in Japanese - -For local runs, Docusaurus treats each language as a different instance of the website. You'll want to specify the language to run the japanese site locally: - -``` -npm run start -- --locale ja-jp -``` - -Don't worry - both languages will be built/served on deployment. - ---- - -## Deploying - -The following command generates static content into the `build` directory. - -``` -$ npm run build -``` - -Then you can test out with the following command: - -``` -npm run serve -``` - -If it looks good, make a PR request! - -### Deployment to GitHub pages - -There is a GitHub action workflow set up in each repo. - -* On PR, it tests a site build. -* On Merge, it builds the site and deploys it. Site should update in a minute or two. - ---- - -## Something's broken - -Luke goofed. Open an issue please! `:bufo-appreciates-the-insight:` \ No newline at end of file diff --git a/docs/babel.config.js b/docs/babel.config.js deleted file mode 100644 index e00595dae..000000000 --- a/docs/babel.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - presets: [require.resolve('@docusaurus/core/lib/babel/preset')], -}; diff --git a/docs/content/concepts/custom-steps.md b/docs/content/concepts/custom-steps.md deleted file mode 100644 index 52e3d76e2..000000000 --- a/docs/content/concepts/custom-steps.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -title: Listening and responding to custom steps -sidebar_label: Custom Steps -lang: en -slug: /concepts/custom-steps ---- - -Your app can use the `function()` method to listen to incoming [custom step requests](https://docs.slack.dev/workflows/workflow-steps). Custom steps are used in Workflow Builder to build workflows. The method requires a step `callback_id` of type `str`. This `callback_id` must also be defined in your [Function](https://docs.slack.dev/reference/app-manifest#functions) definition. Custom steps must be finalized using the `complete()` or `fail()` listener arguments to notify Slack that your app has processed the request. - -* `complete()` requires **one** argument: `outputs` of type `dict`. It ends your custom step **successfully** and provides a dictionary containing the outputs of your custom step as per its definition. -* `fail()` requires **one** argument: `error` of type `str`. It ends your custom step **unsuccessfully** and provides a message containing information regarding why your custom step failed. - -You can reference your custom step's inputs using the `inputs` listener argument of type `dict`. - -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn about the available listener arguments. - -```python -# This sample custom step formats an input and outputs it -@app.function("sample_custom_step") -def sample_step_callback(inputs: dict, fail: Fail, complete: Complete): - try: - message = inputs["message"] - complete( - outputs={ - "message": f":wave: You submitted the following message: \n\n>{message}" - } - ) - except Exception as e: - fail(f"Failed to handle a custom step request (error: {e})") - raise e -``` - -
    - -Example app manifest definition - - -```json -... -"functions": { - "sample_custom_step": { - "title": "Sample custom step", - "description": "Run a sample custom step", - "input_parameters": { - "message": { - "type": "string", - "title": "Message", - "description": "A message to be formatted by the custom step", - "is_required": true, - } - }, - "output_parameters": { - "message": { - "type": "string", - "title": "Messge", - "description": "A formatted message", - "is_required": true, - } - } - } -} -``` - -
    - ---- - -### Listening to custom step interactivity events - -Your app's custom steps may create interactivity points for users, for example: Post a message with a button. - -If such interaction points originate from a custom step execution, the events sent to your app representing the end-user interaction with these points are considered to be _function-scoped interactivity events_. These interactivity events can be handled by your app using the same concepts we covered earlier, such as [Listening to actions](/concepts/action-listening). - -_function-scoped interactivity events_ will contain data related to the custom step (`function_executed` event) they were spawned from, such as custom step `inputs` and access to `complete()` and `fail()` listener arguments. - -Your app can skip calling `complete()` or `fail()` in the `function()` handler method if the custom step creates an interaction point that requires user interaction before the step can end. However, in the relevant interactivity handler method, your app must invoke `complete()` or `fail()` to notify Slack that the custom step has been processed. - -You’ll notice in all interactivity handler examples, `ack()` is used. It is required to call the `ack()` function within an interactivity listener to acknowledge that the request was received from Slack. This is discussed in the [acknowledging requests section](/concepts/acknowledge). - -```python -# This sample custom step posts a message with a button -@app.function("custom_step_button") -def sample_step_callback(inputs, say, fail): - try: - say( - channel=inputs["user_id"], # sending a DM to this user - text="Click the button to signal the step completion", - blocks=[ - { - "type": "section", - "text": {"type": "mrkdwn", "text": "Click the button to signal step completion"}, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Complete step"}, - "action_id": "sample_click", - }, - } - ], - ) - except Exception as e: - fail(f"Failed to handle a function request (error: {e})") - -# Your listener will be called every time a block element with the action_id "sample_click" is triggered -@app.action("sample_click") -def handle_sample_click(ack, body, context, client, complete, fail): - ack() - try: - # Since the button no longer works, we should remove it - client.chat_update( - channel=context.channel_id, - ts=body["message"]["ts"], - text="Congrats! You clicked the button", - ) - - # Signal that the custom step completed successfully - complete({"user_id": context.actor_user_id}) - except Exception as e: - fail(f"Failed to handle a function request (error: {e})") -``` - -
    - -Example app manifest definition - - -```json -... -"functions": { - "custom_step_button": { - "title": "Custom step with a button", - "description": "Custom step that waits for a button click", - "input_parameters": { - "user_id": { - "type": "slack#/types/user_id", - "title": "User", - "description": "The recipient of a message with a button", - "is_required": true, - } - }, - "output_parameters": { - "user_id": { - "type": "slack#/types/user_id", - "title": "User", - "description": "The user that completed the function", - "is_required": true - } - } - } -} -``` - -
    - -Learn more about responding to interactivity, see the [Slack API documentation](https://docs.slack.dev/interactivity/handling-user-interaction). diff --git a/docs/content/concepts/web-api.md b/docs/content/concepts/web-api.md deleted file mode 100644 index 18b41a029..000000000 --- a/docs/content/concepts/web-api.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: Using the Web API -lang: en -slug: /concepts/web-api ---- - -You can call [any Web API method](https://docs.slack.dev/reference/methods) using the [`WebClient`](https://tools.slack.dev/python-slack-sdk/web) provided to your Bolt app as either `app.client` or `client` in middleware/listener arguments (given that your app has the appropriate scopes). When you call one the client's methods, it returns a `SlackResponse` which contains the response from Slack. - -The token used to initialize Bolt can be found in the `context` object, which is required to call most Web API methods. - -:::info - -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. - -::: - -```python -@app.message("wake me up") -def say_hello(client, message): - # Unix Epoch time for September 30, 2020 11:59:59 PM - when_september_ends = 1601510399 - channel_id = message["channel"] - client.chat_scheduleMessage( - channel=channel_id, - post_at=when_september_ends, - text="Summer has come and passed" - ) -``` diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js deleted file mode 100644 index 0d80161e6..000000000 --- a/docs/docusaurus.config.js +++ /dev/null @@ -1,114 +0,0 @@ -import { themes as prismThemes } from 'prism-react-renderer'; -const footer = require('./footerConfig'); -const navbar = require('./navbarConfig'); - -/** @type {import('@docusaurus/types').Config} */ -const config = { - title: "Bolt for Python", - tagline: "Official frameworks, libraries, and SDKs for Slack developers", - favicon: "img/favicon.ico", - url: "https://tools.slack.dev", - baseUrl: "/bolt-python/", - organizationName: "slackapi", - projectName: "bolt-python", - - onBrokenLinks: "ignore", - onBrokenAnchors: "warn", - onBrokenMarkdownLinks: "warn", - - i18n: { - defaultLocale: "en", - locales: ["en", "ja-jp"], - }, - - presets: [ - [ - "classic", - /** @type {import('@docusaurus/preset-classic').Options} */ - ({ - docs: { - path: "content", - breadcrumbs: false, - routeBasePath: "/", // Serve the docs at the site's root - sidebarPath: "./sidebars.js", - editUrl: "https://github.com/slackapi/bolt-python/tree/main/docs", - }, - blog: false, - theme: { - customCss: "./src/css/custom.css", - }, - }), - ], - ], - - plugins: [ - "docusaurus-theme-github-codeblock", - [ - "@docusaurus/plugin-client-redirects", - { - redirects: [ - { - to: "/getting-started", - from: ["/tutorial/getting-started"], - }, - { - to: "/", - from: ["/concepts", "/concepts/basic", "/concepts/advanced"], - }, - { - to: '/concepts/actions', - from: [ - '/concepts/action-listening', - '/concepts/action-responding' - ], - }, - { - to: '/legacy/steps-from-apps', - from: [ - '/concepts/steps', - '/concepts/creating-steps', - '/concepts/adding-editing-steps', - '/concepts/saving-steps', - '/concepts/executing-steps' - ], - }, - { - to: '/concepts/ai-apps', - from: '/concepts/assistant' - } - ], - }, - ], - ], - - themeConfig: - /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ - ({ - colorMode: { - respectPrefersColorScheme: true, - }, - docs: { - sidebar: { - autoCollapseCategories: true, - }, - }, - navbar, - footer, - prism: { - // switch to alucard when available in prism? - theme: prismThemes.github, - darkTheme: prismThemes.dracula, - additionalLanguages: ['bash'], - }, - codeblock: { - showGithubLink: true, - githubLinkLabel: "View on GitHub", - }, - // announcementBar: { - // id: `announcementBar`, - // content: `🎉️ Version 2.26.0 of the developer tools for the Slack automations platform is here! 🎉️ `, - // }, - }), -}; - -export default config; diff --git a/docs/english/_sidebar.json b/docs/english/_sidebar.json new file mode 100644 index 000000000..d42868543 --- /dev/null +++ b/docs/english/_sidebar.json @@ -0,0 +1,209 @@ +[ + { + "type": "doc", + "id": "tools/bolt-python/index", + "label": "Bolt for Python", + "className": "sidebar-title" + }, + "tools/bolt-python/getting-started", + { "type": "html", "value": "
    " }, + "tools/bolt-python/building-an-app", + { + "type": "category", + "label": "Slack API calls", + "items": [ + "tools/bolt-python/concepts/message-sending", + "tools/bolt-python/concepts/web-api" + ] + }, + { + "type": "category", + "label": "Events", + "items": [ + "tools/bolt-python/concepts/message-listening", + "tools/bolt-python/concepts/event-listening" + ] + }, + { + "type": "category", + "label": "App UI & Interactivity", + "items": [ + "tools/bolt-python/concepts/acknowledge", + "tools/bolt-python/concepts/shortcuts", + "tools/bolt-python/concepts/commands", + "tools/bolt-python/concepts/actions", + "tools/bolt-python/concepts/opening-modals", + "tools/bolt-python/concepts/updating-pushing-views", + "tools/bolt-python/concepts/view-submissions", + "tools/bolt-python/concepts/select-menu-options", + "tools/bolt-python/concepts/app-home" + ] + }, + "tools/bolt-python/concepts/ai-apps", + { + "type": "category", + "label": "Custom Steps", + "items": [ + "tools/bolt-python/concepts/custom-steps", + "tools/bolt-python/concepts/custom-steps-dynamic-options" + ] + }, + { + "type": "category", + "label": "App Configuration", + "items": [ + "tools/bolt-python/concepts/socket-mode", + "tools/bolt-python/concepts/errors", + "tools/bolt-python/concepts/logging", + "tools/bolt-python/concepts/async" + ] + }, + { + "type": "category", + "label": "Middleware & Context", + "items": [ + "tools/bolt-python/concepts/global-middleware", + "tools/bolt-python/concepts/listener-middleware", + "tools/bolt-python/concepts/context" + ] + }, + "tools/bolt-python/concepts/lazy-listeners", + { + "type": "category", + "label": "Adaptors", + "items": [ + "tools/bolt-python/concepts/adapters", + "tools/bolt-python/concepts/custom-adapters" + ] + }, + { + "type": "category", + "label": "Authorization & Security", + "items": [ + "tools/bolt-python/concepts/authenticating-oauth", + "tools/bolt-python/concepts/authorization", + "tools/bolt-python/concepts/token-rotation" + ] + }, + { + "type": "category", + "label": "Legacy", + "items": ["tools/bolt-python/legacy/steps-from-apps"] + }, + { "type": "html", "value": "
    " }, + { + "type": "category", + "label": "Tutorials", + "items": [ + "tools/bolt-python/tutorial/ai-chatbot/ai-chatbot", + "tools/bolt-python/tutorial/custom-steps", + "tools/bolt-python/tutorial/custom-steps-for-jira/custom-steps-for-jira", + "tools/bolt-python/tutorial/custom-steps-workflow-builder-new/custom-steps-workflow-builder-new", + "tools/bolt-python/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing", + "tools/bolt-python/tutorial/modals/modals" + ] + }, + { "type": "html", "value": "
    " }, + { + "type": "link", + "label": "Reference", + "href": "https://docs.slack.dev/tools/bolt-python/reference/index.html" + }, + { "type": "html", "value": "
    " }, + { + "type": "category", + "label": "日本語 (日本)", + "items": [ + "tools/bolt-python/ja-jp/getting-started", + { + "type": "category", + "label": "Slack API コール", + "items": [ + "tools/bolt-python/ja-jp/concepts/message-sending", + "tools/bolt-python/ja-jp/concepts/web-api" + ] + }, + { + "type": "category", + "label": "イベント API", + "items": [ + "tools/bolt-python/ja-jp/concepts/message-listening", + "tools/bolt-python/ja-jp/concepts/event-listening" + ] + }, + { + "type": "category", + "label": "インタラクティビティ & ショートカット", + "items": [ + "tools/bolt-python/ja-jp/concepts/acknowledge", + "tools/bolt-python/ja-jp/concepts/shortcuts", + "tools/bolt-python/ja-jp/concepts/commands", + "tools/bolt-python/ja-jp/concepts/actions", + "tools/bolt-python/ja-jp/concepts/opening-modals", + "tools/bolt-python/ja-jp/concepts/updating-pushing-views", + "tools/bolt-python/ja-jp/concepts/view-submissions", + "tools/bolt-python/ja-jp/concepts/select-menu-options", + "tools/bolt-python/ja-jp/concepts/app-home" + ] + }, + { + "type": "category", + "label": "App の設定", + "items": [ + "tools/bolt-python/ja-jp/concepts/socket-mode", + "tools/bolt-python/ja-jp/concepts/errors", + "tools/bolt-python/ja-jp/concepts/logging", + "tools/bolt-python/ja-jp/concepts/async" + ] + }, + { + "type": "category", + "label": "ミドルウェア & コンテキスト", + "items": [ + "tools/bolt-python/ja-jp/concepts/global-middleware", + "tools/bolt-python/ja-jp/concepts/listener-middleware", + "tools/bolt-python/ja-jp/concepts/context" + ] + }, + "tools/bolt-python/ja-jp/concepts/lazy-listeners", + { + "type": "category", + "label": "アダプター", + "items": [ + "tools/bolt-python/ja-jp/concepts/adapters", + "tools/bolt-python/ja-jp/concepts/custom-adapters" + ] + }, + { + "type": "category", + "label": "認可 & セキュリティ", + "items": [ + "tools/bolt-python/ja-jp/concepts/authenticating-oauth", + "tools/bolt-python/ja-jp/concepts/authorization", + "tools/bolt-python/ja-jp/concepts/token-rotation" + ] + }, + { + "type": "category", + "label": "レガシー(非推奨)", + "items": ["tools/bolt-python/ja-jp/legacy/steps-from-apps"] + } + ] + }, + { "type": "html", "value": "
    " }, + { + "type": "link", + "label": "Release notes", + "href": "https://github.com/slackapi/bolt-python/releases" + }, + { + "type": "link", + "label": "Code on GitHub", + "href": "https://github.com/SlackAPI/bolt-python" + }, + { + "type": "link", + "label": "Contributors Guide", + "href": "https://github.com/SlackAPI/bolt-python/blob/main/.github/contributing.md" + } +] diff --git a/docs/content/building-an-app.md b/docs/english/building-an-app.md similarity index 81% rename from docs/content/building-an-app.md rename to docs/english/building-an-app.md index deb3146b9..87b26163b 100644 --- a/docs/content/building-an-app.md +++ b/docs/english/building-an-app.md @@ -1,5 +1,4 @@ --- -title: Building an App with Bolt for Python sidebar_label: Building an App --- @@ -24,31 +23,31 @@ After you fill out an app name (_you can change it later_) and pick a workspace This page contains an overview of your app in addition to important credentials you'll need later. -![Basic Information page](/img/boltpy/basic-information-page.png "Basic Information page") +![Basic Information page](/img/bolt-python/basic-information-page.png "Basic Information page") Look around, add an app icon and description, and then let's start configuring your app 🔩 --- ### Tokens and installing apps {#tokens-and-installing-apps} -Slack apps use [OAuth to manage access to Slack's APIs](https://docs.slack.dev/authentication/installing-with-oauth). When an app is installed, you'll receive a token that the app can use to call API methods. +Slack apps use [OAuth to manage access to Slack's APIs](/authentication/installing-with-oauth). When an app is installed, you'll receive a token that the app can use to call API methods. There are three main token types available to a Slack app: user (`xoxp`), bot (`xoxb`), and app-level (`xapp`) tokens. -- [User tokens](https://docs.slack.dev/authentication/tokens#user) allow you to call API methods on behalf of users after they install or authenticate the app. There may be several user tokens for a single workspace. -- [Bot tokens](https://docs.slack.dev/authentication/tokens#bot) are associated with bot users, and are only granted once in a workspace where someone installs the app. The bot token your app uses will be the same no matter which user performed the installation. Bot tokens are the token type that _most_ apps use. -- [App-level tokens](https://docs.slack.dev/authentication/tokens#app-level) represent your app across organizations, including installations by all individual users on all workspaces in a given organization and are commonly used for creating WebSocket connections to your app. +- [User tokens](/authentication/tokens#user) allow you to call API methods on behalf of users after they install or authenticate the app. There may be several user tokens for a single workspace. +- [Bot tokens](/authentication/tokens#bot) are associated with bot users, and are only granted once in a workspace where someone installs the app. The bot token your app uses will be the same no matter which user performed the installation. Bot tokens are the token type that _most_ apps use. +- [App-level tokens](/authentication/tokens#app-level) represent your app across organizations, including installations by all individual users on all workspaces in a given organization and are commonly used for creating WebSocket connections to your app. We're going to use bot and app-level tokens for this guide. 1. Navigate to **OAuth & Permissions** on the left sidebar and scroll down to the **Bot Token Scopes** section. Click **Add an OAuth Scope**. -2. For now, we'll just add one scope: [`chat:write`](https://docs.slack.dev/reference/scopes/chat.write). This grants your app the permission to post messages in channels it's a member of. +2. For now, we'll just add one scope: [`chat:write`](/reference/scopes/chat.write). This grants your app the permission to post messages in channels it's a member of. 3. Scroll up to the top of the **OAuth & Permissions** page and click **Install App to Workspace**. You'll be led through Slack's OAuth UI, where you should allow your app to be installed to your development workspace. 4. Once you authorize the installation, you'll land on the **OAuth & Permissions** page and see a **Bot User OAuth Access Token**. -![OAuth Tokens](/img/boltpy/bot-token.png "Bot OAuth Token") +![OAuth Tokens](/img/bolt-python/bot-token.png "Bot OAuth Token") 5. Head over to **Basic Information** and scroll down under the App Token section and click **Generate Token and Scopes** to generate an app-level token. Add the `connections:write` scope to this token and save the generated `xapp` token. @@ -56,7 +55,7 @@ We're going to use bot and app-level tokens for this guide. :::tip[Not sharing is sometimes caring] -Treat your tokens like passwords and [keep them safe](https://docs.slack.dev/authentication/best-practices-for-security). Your app uses tokens to post and retrieve information from Slack workspaces. +Treat your tokens like passwords and [keep them safe](/authentication/best-practices-for-security). Your app uses tokens to post and retrieve information from Slack workspaces. ::: @@ -104,7 +103,7 @@ $ export SLACK_APP_TOKEN= :::warning[Keep it secret. Keep it safe.] -Remember to keep your tokens secure. At a minimum, you should avoid checking them into public version control, and access them via environment variables as we've done above. Check out the API documentation for more on [best practices for app security](https://docs.slack.dev/authentication/best-practices-for-security). +Remember to keep your tokens secure. At a minimum, you should avoid checking them into public version control, and access them via environment variables as we've done above. Check out the API documentation for more on [best practices for app security](/authentication/best-practices-for-security). ::: @@ -142,9 +141,9 @@ Your app should let you know that it's up and running. 🎉 ### Setting up events {#setting-up-events} Your app behaves similarly to people on your team — it can post messages, add emoji reactions, and listen and respond to events. -To listen for events happening in a Slack workspace (like when a message is posted or when a reaction is posted to a message) you'll use the [Events API to subscribe to event types](https://docs.slack.dev/apis/events-api/). +To listen for events happening in a Slack workspace (like when a message is posted or when a reaction is posted to a message) you'll use the [Events API to subscribe to event types](/apis/events-api/). -For those just starting, we recommend using [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode). Socket Mode allows your app to use the Events API and interactive features without exposing a public HTTP Request URL. This can be helpful during development, or if you're receiving requests from behind a firewall. +For those just starting, we recommend using [Socket Mode](/apis/events-api/using-socket-mode). Socket Mode allows your app to use the Events API and interactive features without exposing a public HTTP Request URL. This can be helpful during development, or if you're receiving requests from behind a firewall. That being said, you're welcome to set up an app with a public HTTP Request URL. HTTP is more useful for apps being deployed to hosting environments to respond within a large corporate Slack workspaces/organization, or apps intended for distribution via the Slack Marketplace. @@ -169,11 +168,11 @@ When an event occurs, Slack will send your app some information about the event, 1. Go back to your app configuration page (click on the app [from your app management page](https://api.slack.com/apps)). Click **Event Subscriptions** on the left sidebar. Toggle the switch labeled **Enable Events**. -2. Add your Request URL. Slack will send HTTP POST requests corresponding to events to this [Request URL](https://docs.slack.dev/apis/events-api/#subscribing) endpoint. Bolt uses the `/slack/events` path to listen to all incoming requests (whether shortcuts, events, or interactivity payloads). When configuring your Request URL within your app configuration, you'll append `/slack/events`, e.g. `https:///slack/events`. 💡 As long as your Bolt app is still running, your URL should become verified. +2. Add your Request URL. Slack will send HTTP POST requests corresponding to events to this [Request URL](/apis/events-api/#subscribing) endpoint. Bolt uses the `/slack/events` path to listen to all incoming requests (whether shortcuts, events, or interactivity payloads). When configuring your Request URL within your app configuration, you'll append `/slack/events`, e.g. `https:///slack/events`. 💡 As long as your Bolt app is still running, your URL should become verified. :::tip[Using proxy services] -For local development, you can use a proxy service like ngrok to create a public URL and tunnel requests to your development environment. Refer to [ngrok's getting started guide](https://ngrok.com/docs#getting-started-expose) on how to create this tunnel. And when you get to hosting your app, we've collected some of the most common hosting providers Slack developers use to host their apps [on our API site](https://docs.slack.dev/distribution/hosting-slack-apps/). +For local development, you can use a proxy service like ngrok to create a public URL and tunnel requests to your development environment. Refer to [ngrok's getting started guide](https://ngrok.com/docs#getting-started-expose) on how to create this tunnel. And when you get to hosting your app, we've collected some of the most common hosting providers Slack developers use to host their apps [on our API site](/app-management/hosting-slack-apps). ::: @@ -181,10 +180,10 @@ For local development, you can use a proxy service like ngrok to create a public
    Navigate to **Event Subscriptions** on the left sidebar and toggle to enable. Under **Subscribe to Bot Events**, you can add events for your bot to respond to. There are four events related to messages: -- [`message.channels`](https://docs.slack.dev/reference/events/message.channels) listens for messages in public channels that your app is added to. -- [`message.groups`](https://docs.slack.dev/reference/events/message.groups) listens for messages in 🔒 private channels that your app is added to. -- [`message.im`](https://docs.slack.dev/reference/events/message.im) listens for messages in your app's DMs with users. -- [`message.mpim`](https://docs.slack.dev/reference/events/message.mpim) listens for messages in multi-person DMs that your app is added to. +- [`message.channels`](/reference/events/message.channels) listens for messages in public channels that your app is added to. +- [`message.groups`](/reference/events/message.groups) listens for messages in 🔒 private channels that your app is added to. +- [`message.im`](/reference/events/message.im) listens for messages in your app's DMs with users. +- [`message.mpim`](/reference/events/message.mpim) listens for messages in multi-person DMs that your app is added to. If you want your bot to listen to messages from everywhere it is added to, choose all four message events. After you’ve selected the events you want your bot to listen to, click the green **Save Changes** button. @@ -208,7 +207,7 @@ app = App(token=os.environ.get("SLACK_BOT_TOKEN")) # Listens to incoming messages that contain "hello" # To learn available listener arguments, -# visit https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html +# visit https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html @app.message("hello") def message_hello(message, say): # say() sends a message to the channel where the event was triggered @@ -234,7 +233,7 @@ app = App( # Listens to incoming messages that contain "hello" # To learn available listener arguments, -# visit https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html +# visit https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html @app.message("hello") def message_hello(message, say): # say() sends a message to the channel where the event was triggered @@ -268,9 +267,9 @@ With Socket Mode on, basic interactivity is enabled by default, so no further ac Similar to events, you'll need to specify a URL for Slack to send the action (such as _user clicked a button_). Back on your app configuration page, click on **Interactivity & Shortcuts** on the left side. You'll see that there's another **Request URL** box. -:::tip +:::tip[By default, Bolt is configured to use the same endpoint for interactive components that it uses for events, so use the same request URL as above (for example, `https://8e8ec2d7.ngrok.io/slack/events`).] -By default, Bolt is configured to use the same endpoint for interactive components that it uses for events, so use the same request URL as above (for example, `https://8e8ec2d7.ngrok.io/slack/events`). Press the **Save Changes** button in the lower right hand corner, and that's it. Your app is set up to handle interactivity! +Press the **Save Changes** button in the lower right hand corner, and that's it. Your app is set up to handle interactivity! ::: @@ -476,8 +475,8 @@ Now that you have a basic app up and running, you can start exploring how to mak * Read through the concepts pages to learn about the different methods and features your Bolt app has access to. -* Explore the different events your bot can listen to with the [`app.event()`](/concepts/event-listening) method. All of the events are listed [on the API docs site](https://docs.slack.dev/reference/events). +* Explore the different events your bot can listen to with the [`app.event()`](/tools/bolt-python/concepts/event-listening) method. All of the events are listed [on the API docs site](/reference/events). -* Bolt allows you to [call Web API methods](/concepts/web-api) with the client attached to your app. There are [over 200 methods](https://docs.slack.dev/reference/methods) on our API site. +* Bolt allows you to [call Web API methods](/tools/bolt-python/concepts/web-api) with the client attached to your app. There are [over 200 methods](/reference/methods) on our API site. -* Learn more about the different token types [on the API docs site](https://docs.slack.dev/authentication/tokens). Your app may need different tokens depending on the actions you want it to perform. +* Learn more about the different token types [on the API docs site](/authentication/tokens). Your app may need different tokens depending on the actions you want it to perform. diff --git a/docs/content/concepts/acknowledge.md b/docs/english/concepts/acknowledge.md similarity index 60% rename from docs/content/concepts/acknowledge.md rename to docs/english/concepts/acknowledge.md index 5e5c0ed25..7d91e0851 100644 --- a/docs/content/concepts/acknowledge.md +++ b/docs/english/concepts/acknowledge.md @@ -1,22 +1,16 @@ ---- -title: Acknowledging requests -lang: en -slug: /concepts/acknowledge ---- +# Acknowledging requests Actions, commands, shortcuts, options requests, and view submissions must **always** be acknowledged using the `ack()` function. This lets Slack know that the request was received so that it may update the Slack user interface accordingly. -Depending on the type of request, your acknowledgement may be different. For example, when acknowledging a menu selection associated with an external data source, you would call `ack()` with a list of relevant [options](https://docs.slack.dev/reference/block-kit/composition-objects/option-object/). When acknowledging a view submission, you may supply a `response_action` as part of your acknowledgement to [update the view](/concepts/view_submissions). +Depending on the type of request, your acknowledgement may be different. For example, when acknowledging a menu selection associated with an external data source, you would call `ack()` with a list of relevant [options](/reference/block-kit/composition-objects/option-object/). When acknowledging a view submission, you may supply a `response_action` as part of your acknowledgement to [update the view](/tools/bolt-python/concepts/view-submissions). We recommend calling `ack()` right away before initiating any time-consuming processes such as fetching information from your database or sending a new message, since you only have 3 seconds to respond before Slack registers a timeout error. -:::info - -When working in a FaaS / serverless environment, our guidelines for when to `ack()` are different. See the section on [Lazy listeners (FaaS)](/concepts/lazy-listeners) for more detail on this. +:::info[When working in a FaaS / serverless environment, our guidelines for when to `ack()` are different. See the section on [Lazy listeners (FaaS)](/tools/bolt-python/concepts/lazy-listeners) for more detail on this.] ::: -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python # Example of responding to an external_select options request @app.options("menu_selection") diff --git a/docs/content/concepts/actions.md b/docs/english/concepts/actions.md similarity index 74% rename from docs/content/concepts/actions.md rename to docs/english/concepts/actions.md index 018642b4a..d7dfa6ba1 100644 --- a/docs/content/concepts/actions.md +++ b/docs/english/concepts/actions.md @@ -1,8 +1,4 @@ ---- -title: Listening & responding to actions -lang: en -slug: /concepts/actions ---- +# Listening & responding to actions Your app can listen and respond to user actions, like button clicks, and menu selects, using the `action` method. @@ -10,9 +6,9 @@ Your app can listen and respond to user actions, like button clicks, and menu se Actions can be filtered on an `action_id` parameter of type `str` or `re.Pattern`. The `action_id` parameter acts as a unique identifier for interactive components on the Slack platform. -You'll notice in all `action()` examples, `ack()` is used. It is required to call the `ack()` function within an action listener to acknowledge that the request was received from Slack. This is discussed in the [acknowledging requests guide](/concepts/acknowledge). +You'll notice in all `action()` examples, `ack()` is used. It is required to call the `ack()` function within an action listener to acknowledge that the request was received from Slack. This is discussed in the [acknowledging requests guide](/tools/bolt-python/concepts/acknowledge). -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python # Your listener will be called every time a block element with the action_id "approve_button" is triggered @@ -49,7 +45,7 @@ There are two main ways to respond to actions. The first (and most common) way i The second way to respond to actions is using `respond()`, which is a utility to use the `response_url` associated with the action. -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python # Your listener will be called every time an interactive component with the action_id “approve_button” is triggered @@ -62,7 +58,7 @@ def approve_request(ack, say): ### Using `respond()` method -Since `respond()` is a utility for calling the `response_url`, it behaves in the same way. You can pass [all the message payload properties](https://docs.slack.dev/messaging/#payloads) as keyword arguments along with optional properties like `response_type` (which has a value of `"in_channel"` or `"ephemeral"`), `replace_original`, `delete_original`, `unfurl_links`, and `unfurl_media`. With that, your app can send a new message payload that will be published back to the source of the original interaction. +Since `respond()` is a utility for calling the `response_url`, it behaves in the same way. You can pass [all the message payload properties](/messaging/#payloads) as keyword arguments along with optional properties like `response_type` (which has a value of `"in_channel"` or `"ephemeral"`), `replace_original`, `delete_original`, `unfurl_links`, and `unfurl_media`. With that, your app can send a new message payload that will be published back to the source of the original interaction. ```python # Listens to actions triggered with action_id of “user_select” diff --git a/docs/content/concepts/adapters.md b/docs/english/concepts/adapters.md similarity index 97% rename from docs/content/concepts/adapters.md rename to docs/english/concepts/adapters.md index ad4303b15..321dae0ab 100644 --- a/docs/content/concepts/adapters.md +++ b/docs/english/concepts/adapters.md @@ -1,8 +1,4 @@ ---- -title: Adapters -lang: en -slug: /concepts/adapters ---- +# Adapters Adapters are responsible for handling and parsing incoming requests from Slack to conform to [`BoltRequest`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/request/request.py), then dispatching those requests to your Bolt app. diff --git a/docs/content/concepts/ai-apps.md b/docs/english/concepts/ai-apps.md similarity index 79% rename from docs/content/concepts/ai-apps.md rename to docs/english/concepts/ai-apps.md index a8da2bf1f..b294c6688 100644 --- a/docs/content/concepts/ai-apps.md +++ b/docs/english/concepts/ai-apps.md @@ -1,40 +1,36 @@ ---- -title: Using AI in Apps -lang: en -slug: /concepts/ai-apps ---- +# Using AI in Apps -:::info This feature requires a paid plan +:::info[This feature requires a paid plan] If you don't have a paid workspace for development, you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. ::: -The Agents & AI Apps feature comprises a unique messaging experience for Slack. If you're unfamiliar with using the Agents & AI Apps feature within Slack, you'll want to read the [API documentation on the subject](https://docs.slack.dev/ai/). Then come back here to implement them with Bolt! +The Agents & AI Apps feature comprises a unique messaging experience for Slack. If you're unfamiliar with using the Agents & AI Apps feature within Slack, you'll want to read the [API documentation on the subject](/ai/). Then come back here to implement them with Bolt! ## Configuring your app to support AI features {#configuring-your-app} 1. Within [App Settings](https://api.slack.com/apps), enable the **Agents & AI Apps** feature. 2. Within the App Settings **OAuth & Permissions** page, add the following scopes: -* [`assistant:write`](https://docs.slack.dev/reference/scopes/assistant.write) -* [`chat:write`](https://docs.slack.dev/reference/scopes/chat.write) -* [`im:history`](https://docs.slack.dev/reference/scopes/im.history) +* [`assistant:write`](/reference/scopes/assistant.write) +* [`chat:write`](/reference/scopes/chat.write) +* [`im:history`](/reference/scopes/im.history) 3. Within the App Settings **Event Subscriptions** page, subscribe to the following events: -* [`assistant_thread_started`](https://docs.slack.dev/reference/events/assistant_thread_started) -* [`assistant_thread_context_changed`](https://docs.slack.dev/reference/events/assistant_thread_context_changed) -* [`message.im`](https://docs.slack.dev/reference/events/message.im) +* [`assistant_thread_started`](/reference/events/assistant_thread_started) +* [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) +* [`message.im`](/reference/events/message.im) -:::info -You _could_ go it alone and [listen](event-listening) for the `assistant_thread_started`, `assistant_thread_context_changed`, and `message.im` events (see implementation details below) in order to implement the AI features in your app. That being said, using the `Assistant` class will streamline the process. And we already wrote this nice guide for you! -::: +:::info[You _could_ implement your own AI app by [listening](event-listening) for the `assistant_thread_started`, `assistant_thread_context_changed`, and `message.im` events (see implementation details below).] + +That being said, using the `Assistant` class will streamline the process. And we already wrote this nice guide for you! ## The `Assistant` class instance {#assistant-class} The `Assistant` class can be used to handle the incoming events expected from a user interacting with an app in Slack that has the Agents & AI Apps feature enabled. A typical flow would look like: -1. [The user starts a thread](#handling-a-new-thread). The `Assistant` class handles the incoming [`assistant_thread_started`](https://docs.slack.dev/reference/events/assistant_thread_started) event. -2. [The thread context may change at any point](#handling-thread-context-changes). The `Assistant` class can handle any incoming [`assistant_thread_context_changed`](https://docs.slack.dev/reference/events/assistant_thread_context_changed) events. The class also provides a default context store to keep track of thread context changes as the user moves through Slack. -3. [The user responds](#handling-the-user-response). The `Assistant` class handles the incoming [`message.im`](https://docs.slack.dev/reference/events/message.im) event. +1. [The user starts a thread](#handling-a-new-thread). The `Assistant` class handles the incoming [`assistant_thread_started`](/reference/events/assistant_thread_started) event. +2. [The thread context may change at any point](#handling-thread-context-changes). The `Assistant` class can handle any incoming [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) events. The class also provides a default context store to keep track of thread context changes as the user moves through Slack. +3. [The user responds](#handling-the-user-response). The `Assistant` class handles the incoming [`message.im`](/reference/events/message.im) event. ```python @@ -97,25 +93,25 @@ def respond_in_assistant_thread( app.use(assistant) ``` -While the `assistant_thread_started` and `assistant_thread_context_changed` events do provide Slack-client thread context information, the `message.im` event does not. Any subsequent user message events won't contain thread context data. For that reason, Bolt not only provides a way to store thread context — the `threadContextStore` property — but it also provides an instance that is utilized by default. This implementation relies on storing and retrieving [message metadata](https://docs.slack.dev/messaging/message-metadata/) as the user interacts with the app. +While the `assistant_thread_started` and `assistant_thread_context_changed` events do provide Slack-client thread context information, the `message.im` event does not. Any subsequent user message events won't contain thread context data. For that reason, Bolt not only provides a way to store thread context — the `threadContextStore` property — but it also provides an instance that is utilized by default. This implementation relies on storing and retrieving [message metadata](/messaging/message-metadata/) as the user interacts with the app. If you do provide your own `threadContextStore` property, it must feature `get` and `save` methods. -:::tip -Refer to the [module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +:::tip[Refer to the [module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments.] ::: ## Handling a new thread {#handling-a-new-thread} -When the user opens a new thread with your AI-enabled app, the [`assistant_thread_started`](https://docs.slack.dev/reference/events/assistant_thread_started) event will be sent to your app. +When the user opens a new thread with your AI-enabled app, the [`assistant_thread_started`](/reference/events/assistant_thread_started) event will be sent to your app. + +:::tip[When a user opens an app thread while in a channel, the channel info is stored as the thread's `AssistantThreadContext` data.] -:::tip -When a user opens an app thread while in a channel, the channel info is stored as the thread's `AssistantThreadContext` data. You can grab that info by using the `get_thread_context` utility, as subsequent user message event payloads won't include the channel info. +You can grab that info by using the `get_thread_context` utility, as subsequent user message event payloads won't include the channel info. ::: ### Block Kit interactions in the app thread {#block-kit-interactions} -For advanced use cases, Block Kit buttons may be used instead of suggested prompts, as well as the sending of messages with structured [metadata](https://docs.slack.dev/messaging/message-metadata/) to trigger subsequent interactions with the user. +For advanced use cases, Block Kit buttons may be used instead of suggested prompts, as well as the sending of messages with structured [metadata](/messaging/message-metadata/) to trigger subsequent interactions with the user. For example, an app can display a button such as "Summarize the referring channel" in the initial reply. When the user clicks the button and submits detailed information (such as the number of messages, days to check, purpose of the summary, etc.), the app can handle that information and post a message that describes the request with structured metadata. @@ -241,9 +237,9 @@ def respond_to_bot_messages(logger: logging.Logger, set_status: SetStatus, say: ## Handling thread context changes {#handling-thread-context-changes} -When the user switches channels, the [`assistant_thread_context_changed`](https://docs.slack.dev/reference/events/assistant_thread_context_changed) event will be sent to your app. +When the user switches channels, the [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) event will be sent to your app. -If you use the built-in `Assistant` middleware without any custom configuration, the updated context data is automatically saved as [message metadata](https://docs.slack.dev/messaging/message-metadata/) of the first reply from the app. +If you use the built-in `Assistant` middleware without any custom configuration, the updated context data is automatically saved as [message metadata](/messaging/message-metadata/) of the first reply from the app. As long as you use the built-in approach, you don't need to store the context data within a datastore. The downside of this default behavior is the overhead of additional calls to the Slack API. These calls include those to `conversations.history`, which are used to look up the stored message metadata that contains the thread context (via `get_thread_context`). @@ -256,14 +252,14 @@ assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) ## Handling the user response {#handling-the-user-response} -When the user messages your app, the [`message.im`](https://docs.slack.dev/reference/events/message.im) event will be sent to your app. +When the user messages your app, the [`message.im`](/reference/events/message.im) event will be sent to your app. -Messages sent to the app do not contain a [subtype](https://docs.slack.dev/reference/events/message#subtypes) and must be deduced based on their shape and any provided [message metadata](https://docs.slack.dev/messaging/message-metadata/). +Messages sent to the app do not contain a [subtype](/reference/events/message#subtypes) and must be deduced based on their shape and any provided [message metadata](/messaging/message-metadata/). There are three utilities that are particularly useful in curating the user experience: -* [`say`](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/#slack_bolt.Say) -* [`setTitle`](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/#slack_bolt.SetTitle) -* [`setStatus`](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/#slack_bolt.SetStatus) +* [`say`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.Say) +* [`setTitle`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.SetTitle) +* [`setStatus`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.SetStatus) ```python ... diff --git a/docs/content/concepts/app-home.md b/docs/english/concepts/app-home.md similarity index 52% rename from docs/content/concepts/app-home.md rename to docs/english/concepts/app-home.md index d29bfc4d6..8b0e2cf11 100644 --- a/docs/content/concepts/app-home.md +++ b/docs/english/concepts/app-home.md @@ -1,14 +1,10 @@ ---- -title: Publishing views to App Home -lang: en -slug: /concepts/app-home ---- +# Publishing views to App Home -[Home tabs](https://docs.slack.dev/surfaces/app-home) are customizable surfaces accessible via the sidebar and search that allow apps to display views on a per-user basis. After enabling App Home within your app configuration, home tabs can be published and updated by passing a `user_id` and [view payload](https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission) to the [`views.publish`](https://docs.slack.dev/reference/methods/views.publis) method. +[Home tabs](/surfaces/app-home) are customizable surfaces accessible via the sidebar and search that allow apps to display views on a per-user basis. After enabling App Home within your app configuration, home tabs can be published and updated by passing a `user_id` and [view payload](/reference/interaction-payloads/view-interactions-payload/#view_submission) to the [`views.publish`](/reference/methods/views.publish) method. -You can subscribe to the [`app_home_opened`](https://docs.slack.dev/reference/events/app_home_opened) event to listen for when users open your App Home. +You can subscribe to the [`app_home_opened`](/reference/events/app_home_opened) event to listen for when users open your App Home. -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python @app.event("app_home_opened") def update_home_tab(client, event, logger): @@ -32,7 +28,7 @@ def update_home_tab(client, event, logger): "type": "section", "text": { "type": "mrkdwn", - "text": "Learn how home tabs can be more useful and interactive ." + "text": "Learn how home tabs can be more useful and interactive ." } } ] diff --git a/docs/content/concepts/async.md b/docs/english/concepts/async.md similarity index 97% rename from docs/content/concepts/async.md rename to docs/english/concepts/async.md index 197377f93..e6ae28fc6 100644 --- a/docs/content/concepts/async.md +++ b/docs/english/concepts/async.md @@ -1,8 +1,4 @@ ---- -title: Using async (asyncio) -lang: en -slug: /concepts/async ---- +# Using async (asyncio) To use the async version of Bolt, you can import and initialize an `AsyncApp` instance (rather than `App`). `AsyncApp` relies on [AIOHTTP](https://docs.aiohttp.org) to make API requests, which means you'll need to install `aiohttp` (by adding to `requirements.txt` or running `pip install aiohttp`). diff --git a/docs/content/concepts/authenticating-oauth.md b/docs/english/concepts/authenticating-oauth.md similarity index 89% rename from docs/content/concepts/authenticating-oauth.md rename to docs/english/concepts/authenticating-oauth.md index 1fabe3522..88b422949 100644 --- a/docs/content/concepts/authenticating-oauth.md +++ b/docs/english/concepts/authenticating-oauth.md @@ -1,18 +1,14 @@ ---- -title: Authenticating with OAuth -lang: en -slug: /concepts/authenticating-oauth ---- +# Authenticating with OAuth -Slack apps installed on multiple workspaces will need to implement OAuth, then store installation information (like access tokens) securely. By providing `client_id`, `client_secret`, `scopes`, `installation_store`, and `state_store` when initializing App, Bolt for Python will handle the work of setting up OAuth routes and verifying state. If you're implementing a custom adapter, you can make use of our [OAuth library](https://tools.slack.dev/python-slack-sdk/oauth/), which is what Bolt for Python uses under the hood. +Slack apps installed on multiple workspaces will need to implement OAuth, then store installation information (like access tokens) securely. By providing `client_id`, `client_secret`, `scopes`, `installation_store`, and `state_store` when initializing App, Bolt for Python will handle the work of setting up OAuth routes and verifying state. If you're implementing a custom adapter, you can make use of our [OAuth library](/tools/python-slack-sdk/oauth/), which is what Bolt for Python uses under the hood. Bolt for Python will create a **Redirect URL** `slack/oauth_redirect`, which Slack uses to redirect users after they complete your app's installation flow. You will need to add this **Redirect URL** in your app configuration settings under **OAuth and Permissions**. This path can be configured in the `OAuthSettings` argument described below. Bolt for Python will also create a `slack/install` route, where you can find an **Add to Slack** button for your app to perform direct installs of your app. If you need any additional authorizations (user tokens) from users inside a team when your app is already installed or a reason to dynamically generate an install URL, you can pass your own custom URL generator to `oauth_settings` as `authorize_url_generator`. -Bolt for Python automatically includes support for [org wide installations](https://docs.slack.dev/enterprise-grid/) in version `1.1.0+`. Org wide installations can be enabled in your app configuration settings under **Org Level Apps**. +Bolt for Python automatically includes support for [org wide installations](/enterprise-grid/) in version `1.1.0+`. Org wide installations can be enabled in your app configuration settings under **Org Level Apps**. -To learn more about the OAuth installation flow with Slack, [read the API documentation](https://docs.slack.dev/authentication/installing-with-oauth). +To learn more about the OAuth installation flow with Slack, [read the API documentation](/authentication/installing-with-oauth). ```python import os diff --git a/docs/content/concepts/authorization.md b/docs/english/concepts/authorization.md similarity index 94% rename from docs/content/concepts/authorization.md rename to docs/english/concepts/authorization.md index 4c293b5c2..242a86b39 100644 --- a/docs/content/concepts/authorization.md +++ b/docs/english/concepts/authorization.md @@ -1,12 +1,8 @@ ---- -title: Authorization -lang: en -slug: /concepts/authorization ---- +# Authorization Authorization is the process of determining which Slack credentials should be available while processing an incoming Slack request. -Apps installed on a single workspace can simply pass their bot token into the `App` constructor using the `token` parameter. However, if your app will be installed on multiple workspaces, you have two options. The easier option is to use the built-in OAuth support. This will handle setting up OAuth routes and verifying state. Read the section on [authenticating with OAuth](/concepts/authenticating-oauth) for details. +Apps installed on a single workspace can simply pass their bot token into the `App` constructor using the `token` parameter. However, if your app will be installed on multiple workspaces, you have two options. The easier option is to use the built-in OAuth support. This will handle setting up OAuth routes and verifying state. Read the section on [authenticating with OAuth](/tools/bolt-python/concepts/authenticating-oauth) for details. For a more custom solution, you can set the `authorize` parameter to a function upon `App` instantiation. The `authorize` function should return [an instance of `AuthorizeResult`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/authorization/authorize_result.py), which contains information about who and where the request is coming from. diff --git a/docs/content/concepts/commands.md b/docs/english/concepts/commands.md similarity index 74% rename from docs/content/concepts/commands.md rename to docs/english/concepts/commands.md index 4c99f6628..81167fb83 100644 --- a/docs/content/concepts/commands.md +++ b/docs/english/concepts/commands.md @@ -1,18 +1,14 @@ ---- -title: Listening & responding to commands -lang: en -slug: /concepts/commands ---- +# Listening & responding to commands Your app can use the `command()` method to listen to incoming slash command requests. The method requires a `command_name` of type `str`. Commands must be acknowledged with `ack()` to inform Slack your app has received the request. -There are two ways to respond to slash commands. The first way is to use `say()`, which accepts a string or JSON payload. The second is `respond()` which is a utility for the `response_url`. These are explained in more depth in the [responding to actions](/concepts/actions) section. +There are two ways to respond to slash commands. The first way is to use `say()`, which accepts a string or JSON payload. The second is `respond()` which is a utility for the `response_url`. These are explained in more depth in the [responding to actions](/tools/bolt-python/concepts/actions) section. When setting up commands within your app configuration, you'll append `/slack/events` to your request URL. -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python # The echo command simply echoes on command @app.command("/echo") diff --git a/docs/content/concepts/context.md b/docs/english/concepts/context.md similarity index 96% rename from docs/content/concepts/context.md rename to docs/english/concepts/context.md index cf7fa45f1..fb134c896 100644 --- a/docs/content/concepts/context.md +++ b/docs/english/concepts/context.md @@ -1,8 +1,4 @@ ---- -title: Adding context -lang: en -slug: /concepts/context ---- +# Adding context All listeners have access to a `context` dictionary, which can be used to enrich requests with additional information. Bolt automatically attaches information that is included in the incoming request, like `user_id`, `team_id`, `channel_id`, and `enterprise_id`. diff --git a/docs/content/concepts/custom-adapters.md b/docs/english/concepts/custom-adapters.md similarity index 92% rename from docs/content/concepts/custom-adapters.md rename to docs/english/concepts/custom-adapters.md index 55c73130d..62532e7cd 100644 --- a/docs/content/concepts/custom-adapters.md +++ b/docs/english/concepts/custom-adapters.md @@ -1,10 +1,6 @@ ---- -title: Custom adapters -lang: en -slug: /concepts/custom-adapters ---- +# Custom adapters -[Adapters](/concepts/adapters) are flexible and can be adjusted based on the framework you prefer. There are two necessary components of adapters: +[Adapters](/tools/bolt-python/concepts/adapters) are flexible and can be adjusted based on the framework you prefer. There are two necessary components of adapters: - `__init__(app: App)`: Constructor that accepts and stores an instance of the Bolt `App`. - `handle(req: Request)`: Function (typically named `handle()`) that receives incoming Slack requests, parses them to conform to an instance of [`BoltRequest`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/request/request.py), then dispatches them to the stored Bolt app. diff --git a/docs/content/concepts/custom-steps-dynamic-options.md b/docs/english/concepts/custom-steps-dynamic-options.md similarity index 75% rename from docs/content/concepts/custom-steps-dynamic-options.md rename to docs/english/concepts/custom-steps-dynamic-options.md index cab3f7a61..9a152daa0 100644 --- a/docs/content/concepts/custom-steps-dynamic-options.md +++ b/docs/english/concepts/custom-steps-dynamic-options.md @@ -2,7 +2,7 @@ ## Background {#background} -[Legacy steps from apps](https://docs.slack.dev/changelog/2023-08-workflow-steps-from-apps-step-back) previously enabled Slack apps to create and process custom workflow steps, which could then be shared and used by anyone in Workflow Builder. To support your transition away from them, custom steps used as dynamic options are available. These allow you to use data defined when referencing the step in Workflow Builder as inputs to the step. +[Legacy steps from apps](/changelog/2023-08-workflow-steps-from-apps-step-back) previously enabled Slack apps to create and process custom workflow steps, which could then be shared and used by anyone in Workflow Builder. To support your transition away from them, custom steps used as dynamic options are available. These allow you to use data defined when referencing the step in Workflow Builder as inputs to the step. ## Example use case {#use-case} @@ -88,13 +88,13 @@ The `inputs` attribute defines the parameters to be passed as inputs to the step The following format can be used to reference any input parameter defined by the step: `{{input_parameters.}}`. -In addition, the `{{client.query}}` parameter can be used as a placeholder for an input value. The `{{client.builder_context}}` parameter will inject the [`slack#/types/user_context`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types/#usercontext) of the user building the workflow as the value to the input parameter. +In addition, the `{{client.query}}` parameter can be used as a placeholder for an input value. The `{{client.builder_context}}` parameter will inject the [`slack#/types/user_context`](/tools/deno-slack-sdk/reference/slack-types/#usercontext) of the user building the workflow as the value to the input parameter. ### Types of dynamic options UIs {#dynamic-option-UIs} The above example demonstrates one possible UI to be rendered for builders: a single-select drop-down menu of dynamic options. However, dynamic options in Workflow Builder can be rendered in one of two ways: as a drop-down menu (single-select or multi-select), or as a set of fields. -The type is dictated by the output parameter of the custom step used as a dynamic option. In order to use a custom step in a dynamic option context, its output must adhere to a defined interface, that is, it must have an `options` parameter of type [`options_select`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_select) or [`options_field`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_field), as shown in the following code snippet. +The type is dictated by the output parameter of the custom step used as a dynamic option. In order to use a custom step in a dynamic option context, its output must adhere to a defined interface, that is, it must have an `options` parameter of type [`options_select`](/tools/deno-slack-sdk/reference/slack-types#options_select) or [`options_field`](/tools/deno-slack-sdk/reference/slack-types#options_field), as shown in the following code snippet. ```js "output_parameters": { @@ -109,9 +109,9 @@ The type is dictated by the output parameter of the custom step used as a dynami #### Drop-down menus {#drop-down} -Your dynamic input parameter can be rendered as a drop-down menu, which will use the options obtained from a custom step with an `options` output parameter of the type [`options_select`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_select). +Your dynamic input parameter can be rendered as a drop-down menu, which will use the options obtained from a custom step with an `options` output parameter of the type [`options_select`](/tools/deno-slack-sdk/reference/slack-types#options_select). -The drop-down menu UI component can be rendered in two ways: single-select, or multi-select. To render the dynamic input as a single-select menu, the input parameter defining the dynamic option must be of the type [`string`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#string). +The drop-down menu UI component can be rendered in two ways: single-select, or multi-select. To render the dynamic input as a single-select menu, the input parameter defining the dynamic option must be of the type [`string`](/tools/deno-slack-sdk/reference/slack-types#string). ```js "step-with-dynamic-input": { @@ -133,7 +133,7 @@ The drop-down menu UI component can be rendered in two ways: single-select, or m } ``` -To render the dynamic input as a multi-select menu, the input parameter defining the dynamic option must be of the type [`array`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#array), and its `items` must be of type [`string`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#string). +To render the dynamic input as a multi-select menu, the input parameter defining the dynamic option must be of the type [`array`](/tools/deno-slack-sdk/reference/slack-types#array), and its `items` must be of type [`string`](/tools/deno-slack-sdk/reference/slack-types#string). ```js "step-with-dynamic-input": { @@ -159,9 +159,9 @@ To render the dynamic input as a multi-select menu, the input parameter defining #### Fields {#fields} -In the code snippet below, the input parameter is rendered as a set of fields with keys and values. The option fields are obtained from a custom step with an `options` output parameter of type [`options_field`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_field). +In the code snippet below, the input parameter is rendered as a set of fields with keys and values. The option fields are obtained from a custom step with an `options` output parameter of type [`options_field`](/tools/deno-slack-sdk/reference/slack-types#options_field). -The input parameter that defines the dynamic option must be of type [`object`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#object), as the completed set of fields in Workflow Builder will be passed to the custom step as an [untyped object](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#untyped-object) during workflow execution. +The input parameter that defines the dynamic option must be of type [`object`](/tools/deno-slack-sdk/reference/slack-types#object), as the completed set of fields in Workflow Builder will be passed to the custom step as an [untyped object](/tools/deno-slack-sdk/reference/slack-types#untyped-object) during workflow execution. ```js "test-field-dynamic-options": { @@ -185,20 +185,20 @@ The input parameter that defines the dynamic option must be of type [`object`](h ### Dynamic option types {#dynamic-option-types} -As mentioned earlier, in order to use a custom step as a dynamic option, its output must adhere to a defined interface: it must have an `options` output parameter of the type either [`options_select`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_select) or [`options_field`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_field). +As mentioned earlier, in order to use a custom step as a dynamic option, its output must adhere to a defined interface: it must have an `options` output parameter of the type either [`options_select`](/tools/deno-slack-sdk/reference/slack-types#options_select) or [`options_field`](/tools/deno-slack-sdk/reference/slack-types#options_field). -To take a look at these in more detail, refer to our [Options Slack type](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options) documentation. +To take a look at these in more detail, refer to our [Options Slack type](/tools/deno-slack-sdk/reference/slack-types#options) documentation. ## Dynamic options handler {#dynamic-option-handler} Each custom step defined in the manifest needs a corresponding handler in your Slack app. Although implemented similarly to existing function execution event handlers, there are two key differences between regular custom step invocations and those used for dynamic options: -* The custom step must have an `options` output parameter that is of type [`options_select`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_select) or [`options_field`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#options_field). -* The [`function_executed`](https://docs.slack.dev/reference/events/function_executed) event must be handled synchronously. This optimizes the response time of returned dynamic options and provides a crisp builder experience. +* The custom step must have an `options` output parameter that is of type [`options_select`](/tools/deno-slack-sdk/reference/slack-types#options_select) or [`options_field`](/tools/deno-slack-sdk/reference/slack-types#options_field). +* The [`function_executed`](/reference/events/function_executed) event must be handled synchronously. This optimizes the response time of returned dynamic options and provides a crisp builder experience. ### Asynchronous event handling {#async} -By default, the [Bolt family of frameworks](https://tools.slack.dev/) handles `function_executed` events asynchronously. +By default, the Bolt family of frameworks handles `function_executed` events asynchronously. For example, the various modal-related API methods provide two ways to update a view: synchronously using a `response_action` HTTP response, or asynchronously using a separate HTTP API call. Using the asynchronous approach allows developers to handle events free of timeouts, but this isn't desired for dynamic options as it introduces delays and violates our stated goal of providing a crisp builder experience. @@ -208,13 +208,13 @@ Dynamic options support synchronous handling of `function_executed` events. By e ### Implementation {#implementation} -To optimize the response time of dynamic options, you must acknowledge the incoming event after calling the [`function.completeSuccess`](https://docs.slack.dev/reference/methods/functions.completeSuccess) or [`function.completeError`](https://docs.slack.dev/reference/methods/functions.completeError) API methods, minimizing asynchronous latency. The `function.completeSuccess` and `function.completeError` API methods are invoked in the complete and fail helper functions. ([For example](https://github.com/slackapi/bolt-python?tab=readme-ov-file#making-things-happen)). +To optimize the response time of dynamic options, you must acknowledge the incoming event after calling the [`function.completeSuccess`](/reference/methods/functions.completeSuccess) or [`function.completeError`](/reference/methods/functions.completeError) API methods, minimizing asynchronous latency. The `function.completeSuccess` and `function.completeError` API methods are invoked in the complete and fail helper functions. ([For example](https://github.com/slackapi/bolt-python?tab=readme-ov-file#making-things-happen)). A new `auto_acknowledge` flag allows you more granular control over whether specific event handlers should operate in synchronous or asynchronous response modes in order to enable a smooth dynamic options experience. #### Example {#bolt-py} -In [Bolt for Python](https://tools.slack.dev/bolt-python/), you can set `auto_acknowledge=False` on a specific function decorator. This allows you to manually control when the `ack()` event acknowledgement helper function is executed. It flips Bolt to synchronous `function_executed` event handling mode for the specific handler. +In [Bolt for Python](https://docs.slack.dev/tools/bolt-python/), you can set `auto_acknowledge=False` on a specific function decorator. This allows you to manually control when the `ack()` event acknowledgement helper function is executed. It flips Bolt to synchronous `function_executed` event handling mode for the specific handler. ```py @app.function("get-projects", auto_acknowledge=False) @@ -244,4 +244,4 @@ def handle_get_projects(ack: Ack, complete: Complete): ack() ``` -✨ **To learn more about the Bolt family of frameworks and tools**, check out our [Slack Developer Tools](https://tools.slack.dev/). +✨ **To learn more about the Bolt family of frameworks and tools**, check out our [Slack Developer Tools](/tools). diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/custom-steps.md b/docs/english/concepts/custom-steps.md similarity index 86% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/custom-steps.md rename to docs/english/concepts/custom-steps.md index e022c3e38..720c53421 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/custom-steps.md +++ b/docs/english/concepts/custom-steps.md @@ -1,17 +1,17 @@ --- -title: Listening and responding to custom steps -lang: ja-jp -slug: /concepts/custom-steps +sidebar_label: Custom steps --- -Your app can use the `function()` method to listen to incoming [custom step requests](https://docs.slack.dev/workflows/workflow-steps). Custom steps are used in Workflow Builder to build workflows. The method requires a step `callback_id` of type `str`. This `callback_id` must also be defined in your [Function](https://docs.slack.dev/reference/app-manifest#functions) definition. Custom steps must be finalized using the `complete()` or `fail()` listener arguments to notify Slack that your app has processed the request. +# Listening and responding to custom steps + +Your app can use the `function()` method to listen to incoming [custom step requests](/workflows/workflow-steps). Custom steps are used in Workflow Builder to build workflows. The method requires a step `callback_id` of type `str`. This `callback_id` must also be defined in your [Function](/reference/app-manifest#functions) definition. Custom steps must be finalized using the `complete()` or `fail()` listener arguments to notify Slack that your app has processed the request. * `complete()` requires **one** argument: `outputs` of type `dict`. It ends your custom step **successfully** and provides a dictionary containing the outputs of your custom step as per its definition. * `fail()` requires **one** argument: `error` of type `str`. It ends your custom step **unsuccessfully** and provides a message containing information regarding why your custom step failed. You can reference your custom step's inputs using the `inputs` listener argument of type `dict`. -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn about the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn about the available listener arguments. ```python # This sample custom step formats an input and outputs it @@ -68,13 +68,13 @@ Example app manifest definition Your app's custom steps may create interactivity points for users, for example: Post a message with a button. -If such interaction points originate from a custom step execution, the events sent to your app representing the end-user interaction with these points are considered to be _function-scoped interactivity events_. These interactivity events can be handled by your app using the same concepts we covered earlier, such as [Listening to actions](/concepts/action-listening). +If such interaction points originate from a custom step execution, the events sent to your app representing the end-user interaction with these points are considered to be _function-scoped interactivity events_. These interactivity events can be handled by your app using the same concepts we covered earlier, such as [Listening to actions](/tools/bolt-python/concepts/actions). _function-scoped interactivity events_ will contain data related to the custom step (`function_executed` event) they were spawned from, such as custom step `inputs` and access to `complete()` and `fail()` listener arguments. Your app can skip calling `complete()` or `fail()` in the `function()` handler method if the custom step creates an interaction point that requires user interaction before the step can end. However, in the relevant interactivity handler method, your app must invoke `complete()` or `fail()` to notify Slack that the custom step has been processed. -You’ll notice in all interactivity handler examples, `ack()` is used. It is required to call the `ack()` function within an interactivity listener to acknowledge that the request was received from Slack. This is discussed in the [acknowledging requests section](/concepts/acknowledge). +You’ll notice in all interactivity handler examples, `ack()` is used. It is required to call the `ack()` function within an interactivity listener to acknowledge that the request was received from Slack. This is discussed in the [acknowledging requests section](/tools/bolt-python/concepts/acknowledge). ```python # This sample custom step posts a message with a button @@ -150,4 +150,4 @@ Example app manifest definition -Learn more about responding to interactivity, see the [Slack API documentation](https://docs.slack.dev/interactivity/). +Learn more about responding to interactivity, see the [Slack API documentation](/interactivity/handling-user-interaction). diff --git a/docs/content/concepts/errors.md b/docs/english/concepts/errors.md similarity index 90% rename from docs/content/concepts/errors.md rename to docs/english/concepts/errors.md index d0e5cccad..ed41c5816 100644 --- a/docs/content/concepts/errors.md +++ b/docs/english/concepts/errors.md @@ -1,8 +1,4 @@ ---- -title: Handling errors -lang: en -slug: /concepts/errors ---- +# Handling errors If an error occurs in a listener, you can handle it directly using a try/except block. Errors associated with your app will be of type `BoltError`. Errors associated with calling Slack APIs will be of type `SlackApiError`. diff --git a/docs/content/concepts/event-listening.md b/docs/english/concepts/event-listening.md similarity index 60% rename from docs/content/concepts/event-listening.md rename to docs/english/concepts/event-listening.md index 7ffa9e3a2..d7b8e5930 100644 --- a/docs/content/concepts/event-listening.md +++ b/docs/english/concepts/event-listening.md @@ -1,14 +1,10 @@ ---- -title: Listening to events -lang: en -slug: /concepts/event-listening ---- +# Listening to events -You can listen to [any Events API event](https://docs.slack.dev/reference/events) using the `event()` method after subscribing to it in your app configuration. This allows your app to take action when something happens in a workspace where it's installed, like a user reacting to a message or joining a channel. +You can listen to [any Events API event](/reference/events) using the `event()` method after subscribing to it in your app configuration. This allows your app to take action when something happens in a workspace where it's installed, like a user reacting to a message or joining a channel. The `event()` method requires an `eventType` of type `str`. -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python # When a user joins the workspace, send a message in a predefined channel asking them to introduce themselves @app.event("team_join") @@ -23,7 +19,7 @@ def ask_for_introduction(event, say): The `message()` listener is equivalent to `event("message")`. -You can filter on subtypes of events by passing in the additional key `subtype`. Common message subtypes like `bot_message` and `message_replied` can be found [on the message event page](https://docs.slack.dev/reference/events/message#subtypes). +You can filter on subtypes of events by passing in the additional key `subtype`. Common message subtypes like `bot_message` and `message_replied` can be found [on the message event page](/reference/events/message#subtypes). You can explicitly filter for events without a subtype by explicitly setting `None`. ```python diff --git a/docs/content/concepts/global-middleware.md b/docs/english/concepts/global-middleware.md similarity index 82% rename from docs/content/concepts/global-middleware.md rename to docs/english/concepts/global-middleware.md index ec748c000..dbcdeae99 100644 --- a/docs/content/concepts/global-middleware.md +++ b/docs/english/concepts/global-middleware.md @@ -1,14 +1,10 @@ ---- -title: Global middleware -lang: en -slug: /concepts/global-middleware ---- +# Global middleware Global middleware is run for all incoming requests, before any listener middleware. You can add any number of global middleware to your app by passing middleware functions to `app.use()`. Middleware functions are called with the same arguments as listeners, with an additional `next()` function. Both global and listener middleware must call `next()` to pass control of the execution chain to the next middleware. -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python @app.use def auth_acme(client, context, logger, payload, next): diff --git a/docs/content/concepts/lazy-listeners.md b/docs/english/concepts/lazy-listeners.md similarity index 98% rename from docs/content/concepts/lazy-listeners.md rename to docs/english/concepts/lazy-listeners.md index d72c2f9c0..d775106b9 100644 --- a/docs/content/concepts/lazy-listeners.md +++ b/docs/english/concepts/lazy-listeners.md @@ -1,8 +1,4 @@ ---- -title: Lazy listeners (FaaS) -lang: en -slug: /concepts/lazy-listeners ---- +# Lazy listeners (FaaS) Lazy Listeners are a feature which make it easier to deploy Slack apps to FaaS (Function-as-a-Service) environments. Please note that this feature is only available in Bolt for Python, and we are not planning to add the same to other Bolt frameworks. diff --git a/docs/content/concepts/listener-middleware.md b/docs/english/concepts/listener-middleware.md similarity index 83% rename from docs/content/concepts/listener-middleware.md rename to docs/english/concepts/listener-middleware.md index 3507d7d97..c8bfc964e 100644 --- a/docs/content/concepts/listener-middleware.md +++ b/docs/english/concepts/listener-middleware.md @@ -1,14 +1,10 @@ ---- -title: Listener middleware -lang: en -slug: /concepts/listener-middleware ---- +# Listener middleware Listener middleware is only run for the listener in which it's passed. You can pass any number of middleware functions to the listener using the `middleware` parameter, which must be a list that contains one to many middleware functions. If your listener middleware is a quite simple one, you can use a listener matcher, which returns `bool` value (`True` for proceeding) instead of requiring `next()` method call. -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python # Listener middleware which filters out messages from a bot diff --git a/docs/content/concepts/logging.md b/docs/english/concepts/logging.md similarity index 94% rename from docs/content/concepts/logging.md rename to docs/english/concepts/logging.md index 5f82d168a..49e275d2d 100644 --- a/docs/content/concepts/logging.md +++ b/docs/english/concepts/logging.md @@ -1,8 +1,4 @@ ---- -title: Logging -lang: en -slug: /concepts/logging ---- +# Logging By default, Bolt will log information from your app to the output destination. After you've imported the `logging` module, you can customize the root log level by passing the `level` parameter to `basicConfig()`. The available log levels in order of least to most severe are `debug`, `info`, `warning`, `error`, and `critical`. diff --git a/docs/content/concepts/message-listening.md b/docs/english/concepts/message-listening.md similarity index 59% rename from docs/content/concepts/message-listening.md rename to docs/english/concepts/message-listening.md index b2f8bc05c..be6e74678 100644 --- a/docs/content/concepts/message-listening.md +++ b/docs/english/concepts/message-listening.md @@ -1,16 +1,9 @@ ---- -title: Listening to messages -lang: en -slug: /concepts/message-listening ---- - -To listen to messages that [your app has access to receive](https://docs.slack.dev/messaging/retrieving-messages), you can use the `message()` method which filters out events that aren't of type `message`. +# Listening to messages +To listen to messages that [your app has access to receive](/messaging/retrieving-messages), you can use the `message()` method which filters out events that aren't of type `message`. `message()` accepts an argument of type `str` or `re.Pattern` object that filters out any messages that don't match the pattern. -:::info - -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +:::info[Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments.] ::: diff --git a/docs/content/concepts/message-sending.md b/docs/english/concepts/message-sending.md similarity index 77% rename from docs/content/concepts/message-sending.md rename to docs/english/concepts/message-sending.md index b4a2f309e..228a7b6b8 100644 --- a/docs/content/concepts/message-sending.md +++ b/docs/english/concepts/message-sending.md @@ -1,14 +1,10 @@ ---- -title: Sending messages -lang: en -slug: /concepts/message-sending ---- +# Sending messages Within your listener function, `say()` is available whenever there is an associated conversation (for example, a conversation where the event or action which triggered the listener occurred). `say()` accepts a string to post simple messages and JSON payloads to send more complex messages. The message payload you pass in will be sent to the associated conversation. -In the case that you'd like to send a message outside of a listener or you want to do something more advanced (like handle specific errors), you can call `client.chat_postMessage` [using the client attached to your Bolt instance](/concepts/web-api). +In the case that you'd like to send a message outside of a listener or you want to do something more advanced (like handle specific errors), you can call `client.chat_postMessage` [using the client attached to your Bolt instance](/tools/bolt-python/concepts/web-api). -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python # Listens for messages containing "knock knock" and responds with an italicized "who's there?" @app.message("knock knock") @@ -20,7 +16,7 @@ def ask_who(message, say): `say()` accepts more complex message payloads to make it easy to add functionality and structure to your messages. -To explore adding rich message layouts to your app, read through [the guide on our API site](https://docs.slack.dev/messaging/#structure) and look through templates of common app flows [in the Block Kit Builder](https://api.slack.com/tools/block-kit-builder?template=1). +To explore adding rich message layouts to your app, read through [the guide on our API site](/messaging/#structure) and look through templates of common app flows [in the Block Kit Builder](https://api.slack.com/tools/block-kit-builder?template=1). ```python # Sends a section block with datepicker when someone reacts with a 📅 emoji diff --git a/docs/content/concepts/opening-modals.md b/docs/english/concepts/opening-modals.md similarity index 68% rename from docs/content/concepts/opening-modals.md rename to docs/english/concepts/opening-modals.md index e7daceafc..1f053539f 100644 --- a/docs/content/concepts/opening-modals.md +++ b/docs/english/concepts/opening-modals.md @@ -1,16 +1,12 @@ ---- -title: Opening modals -lang: en -slug: /concepts/opening-modals ---- +# Opening modals -[Modals](https://docs.slack.dev/surfaces/modals) are focused surfaces that allow you to collect user data and display dynamic information. You can open a modal by passing a valid `trigger_id` and a [view payload](https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission) to the built-in client's [`views.open`](https://docs.slack.dev/reference/methods/views.open/) method. +[Modals](/surfaces/modals) are focused surfaces that allow you to collect user data and display dynamic information. You can open a modal by passing a valid `trigger_id` and a [view payload](/reference/interaction-payloads/view-interactions-payload/#view_submission) to the built-in client's [`views.open`](/reference/methods/views.open/) method. Your app receives `trigger_id` parameters in payloads sent to your Request URL triggered user invocation like a slash command, button press, or interaction with a select menu. -Read more about modal composition in the [API documentation](https://docs.slack.dev/surfaces/modals#composing_views). +Read more about modal composition in the [API documentation](/surfaces/modals#composing_views). -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python # Listen for a shortcut invocation diff --git a/docs/content/concepts/select-menu-options.md b/docs/english/concepts/select-menu-options.md similarity index 67% rename from docs/content/concepts/select-menu-options.md rename to docs/english/concepts/select-menu-options.md index d699e6370..40d29472c 100644 --- a/docs/content/concepts/select-menu-options.md +++ b/docs/english/concepts/select-menu-options.md @@ -1,19 +1,15 @@ ---- -title: Listening & responding to select menu options -lang: en -slug: /concepts/options ---- +# Listening & responding to select menu options -The `options()` method listens for incoming option request payloads from Slack. [Similar to `action()`](/concepts/action-listening), +The `options()` method listens for incoming option request payloads from Slack. [Similar to `action()`](/tools/bolt-python/concepts/actions), an `action_id` or constraints object is required. In order to load external data into your select menus, you must provide an options load URL in your app configuration, appended with `/slack/events`. While it's recommended to use `action_id` for `external_select` menus, dialogs do not support Block Kit so you'll have to use the constraints object to filter on a `callback_id`. -To respond to options requests, you'll need to call `ack()` with a valid `options` or `option_groups` list. Both [external select response examples](https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select) and [dialog response examples](https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#conversation_multi_select) can be found on our API site. +To respond to options requests, you'll need to call `ack()` with a valid `options` or `option_groups` list. Both [external select response examples](/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select) and [dialog response examples](/reference/block-kit/block-elements/multi-select-menu-element#conversation_multi_select) can be found on our API site. -Additionally, you may want to apply filtering logic to the returned options based on user input. This can be accomplished by using the `payload` argument to your options listener and checking for the contents of the `value` property within it. Based on the `value` you can return different options. All listeners and middleware handlers in Bolt for Python have access to [many useful arguments](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) - be sure to check them out! +Additionally, you may want to apply filtering logic to the returned options based on user input. This can be accomplished by using the `payload` argument to your options listener and checking for the contents of the `value` property within it. Based on the `value` you can return different options. All listeners and middleware handlers in Bolt for Python have access to [many useful arguments](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) - be sure to check them out! -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python # Example of responding to an external_select options request @app.options("external_action") diff --git a/docs/content/concepts/shortcuts.md b/docs/english/concepts/shortcuts.md similarity index 74% rename from docs/content/concepts/shortcuts.md rename to docs/english/concepts/shortcuts.md index 6833d468d..b28f0b352 100644 --- a/docs/content/concepts/shortcuts.md +++ b/docs/english/concepts/shortcuts.md @@ -1,22 +1,18 @@ ---- -title: Listening & responding to shortcuts -lang: en -slug: /concepts/shortcuts ---- +# Listening & responding to shortcuts -The `shortcut()` method supports both [global shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts#global) and [message shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts#messages). +The `shortcut()` method supports both [global shortcuts](/interactivity/implementing-shortcuts#global) and [message shortcuts](/interactivity/implementing-shortcuts#messages). Shortcuts are invokable entry points to apps. Global shortcuts are available from within search and text composer area in Slack. Message shortcuts are available in the context menus of messages. Your app can use the `shortcut()` method to listen to incoming shortcut requests. The method requires a `callback_id` parameter of type `str` or `re.Pattern`. Shortcuts must be acknowledged with `ack()` to inform Slack that your app has received the request. -Shortcuts include a `trigger_id` which an app can use to [open a modal](/concepts/opening-modals) that confirms the action the user is taking. +Shortcuts include a `trigger_id` which an app can use to [open a modal](/tools/bolt-python/concepts/opening-modals) that confirms the action the user is taking. When setting up shortcuts within your app configuration, as with other URLs, you'll append `/slack/events` to your request URL. -⚠️ Note that global shortcuts do **not** include a channel ID. If your app needs access to a channel ID, you may use a [`conversations_select`](https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#conversation_multi_select) element within a modal. Message shortcuts do include a channel ID. +⚠️ Note that global shortcuts do **not** include a channel ID. If your app needs access to a channel ID, you may use a [`conversations_select`](/reference/block-kit/block-elements/multi-select-menu-element#conversation_multi_select) element within a modal. Message shortcuts do include a channel ID. -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python # The open_modal shortcut listens to a shortcut with the callback_id "open_modal" @app.shortcut("open_modal") @@ -36,7 +32,7 @@ def open_modal(ack, shortcut, client): "type": "section", "text": { "type": "mrkdwn", - "text": "About the simplest modal you could conceive of :smile:\n\nMaybe or ." + "text": "About the simplest modal you could conceive of :smile:\n\nMaybe or ." } }, { @@ -75,7 +71,7 @@ def open_modal(ack, shortcut, client): "type": "section", "text": { "type": "mrkdwn", - "text": "About the simplest modal you could conceive of :smile:\n\nMaybe or ." + "text": "About the simplest modal you could conceive of :smile:\n\nMaybe or ." } }, { diff --git a/docs/content/concepts/socket-mode.md b/docs/english/concepts/socket-mode.md similarity index 78% rename from docs/content/concepts/socket-mode.md rename to docs/english/concepts/socket-mode.md index 301fbf94e..5156f6e13 100644 --- a/docs/content/concepts/socket-mode.md +++ b/docs/english/concepts/socket-mode.md @@ -1,10 +1,6 @@ ---- -title: Using Socket Mode -lang: en -slug: /concepts/socket-mode ---- +# Using Socket Mode -With the introduction of [Socket Mode](https://docs.slack.dev/apis/events-api/using-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. +With the introduction of [Socket Mode](/apis/events-api/using-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. @@ -38,7 +34,7 @@ if __name__ == "__main__": To use the asyncio-based adapters such as aiohttp, your whole app needs to be compatible with asyncio's async/await programming model. `AsyncSocketModeHandler` is available for running `AsyncApp` and its async middleware and listeners. -To learn how to use `AsyncApp`, checkout the [using Async](/concepts/async) document and relevant [examples](https://github.com/slackapi/bolt-python/tree/main/examples). +To learn how to use `AsyncApp`, checkout the [using Async](/tools/bolt-python/concepts/async) document and relevant [examples](https://github.com/slackapi/bolt-python/tree/main/examples). ```python from slack_bolt.app.async_app import AsyncApp diff --git a/docs/content/concepts/token-rotation.md b/docs/english/concepts/token-rotation.md similarity index 73% rename from docs/content/concepts/token-rotation.md rename to docs/english/concepts/token-rotation.md index 88af29ffa..96a41bb3c 100644 --- a/docs/content/concepts/token-rotation.md +++ b/docs/english/concepts/token-rotation.md @@ -1,13 +1,9 @@ ---- -title: Token rotation -lang: en -slug: /concepts/token-rotation ---- +# Token rotation Supported in Bolt for Python as of [v1.7.0](https://github.com/slackapi/bolt-python/releases/tag/v1.7.0), token rotation provides an extra layer of security for your access tokens and is defined by the [OAuth V2 RFC](https://datatracker.ietf.org/doc/html/rfc6749#section-10.4). Instead of an access token representing an existing installation of your Slack app indefinitely, with token rotation enabled, access tokens expire. A refresh token acts as a long-lived way to refresh your access tokens. -Bolt for Python supports and will handle token rotation automatically so long as the [built-in OAuth](/concepts/authenticating-oauth) functionality is used. +Bolt for Python supports and will handle token rotation automatically so long as the [built-in OAuth](/tools/bolt-python/concepts/authenticating-oauth) functionality is used. -For more information about token rotation, please see the [documentation](https://docs.slack.dev/authentication/using-token-rotation). \ No newline at end of file +For more information about token rotation, please see the [documentation](/authentication/using-token-rotation). \ No newline at end of file diff --git a/docs/content/concepts/updating-pushing-views.md b/docs/english/concepts/updating-pushing-views.md similarity index 64% rename from docs/content/concepts/updating-pushing-views.md rename to docs/english/concepts/updating-pushing-views.md index aa1efa3fa..8c05e79c8 100644 --- a/docs/content/concepts/updating-pushing-views.md +++ b/docs/english/concepts/updating-pushing-views.md @@ -1,10 +1,6 @@ ---- -title: Updating & pushing views -lang: en -slug: /concepts/updating-pushing-views ---- +# Updating & pushing views -Modals contain a stack of views. When you call [`views_open`](https://api.https://docs.slack.dev/reference/methods/views.open/slack.com/methods/views.open), you add the root view to the modal. After the initial call, you can dynamically update a view by calling [`views_update`](https://docs.slack.dev/reference/methods/views.update/), or stack a new view on top of the root view by calling [`views_push`](https://docs.slack.dev/reference/methods/views.push/) +Modals contain a stack of views. When you call [`views_open`](https://api./reference/methods/views.open/slack.com/methods/views.open), you add the root view to the modal. After the initial call, you can dynamically update a view by calling [`views_update`](/reference/methods/views.update/), or stack a new view on top of the root view by calling [`views_push`](/reference/methods/views.push/) ## The `views_update` method @@ -12,11 +8,11 @@ To update a view, you can use the built-in client to call `views_update` with th ## The `views_push` method -To push a new view onto the view stack, you can use the built-in client to call `views_push` with a valid `trigger_id` a new [view payload](https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission). The arguments for `views_push` is the same as [opening modals](/concepts/creating-models). After you open a modal, you may only push two additional views onto the view stack. +To push a new view onto the view stack, you can use the built-in client to call `views_push` with a valid `trigger_id` a new [view payload](/reference/interaction-payloads/view-interactions-payload/#view_submission). The arguments for `views_push` is the same as [opening modals](/tools/bolt-python/concepts/opening-modals). After you open a modal, you may only push two additional views onto the view stack. -Learn more about updating and pushing views in our [API documentation](https://docs.slack.dev/surfaces/modals) +Learn more about updating and pushing views in our [API documentation](/surfaces/modals) -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python # Listen for a button invocation with action_id `button_abc` (assume it's inside of a modal) @app.action("button_abc") diff --git a/docs/content/concepts/view-submissions.md b/docs/english/concepts/view-submissions.md similarity index 74% rename from docs/content/concepts/view-submissions.md rename to docs/english/concepts/view-submissions.md index 60b78cd54..4ff4c2da7 100644 --- a/docs/content/concepts/view-submissions.md +++ b/docs/english/concepts/view-submissions.md @@ -1,10 +1,6 @@ ---- -title: Listening to views -lang: en -slug: /concepts/view_submissions ---- +# Listening to views -If a [view payload](https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission) contains any input blocks, you must listen to `view_submission` requests to receive their values. To listen to `view_submission` requests, you can use the built-in `view()` method. `view()` requires a `callback_id` of type `str` or `re.Pattern`. +If a [view payload](/reference/interaction-payloads/view-interactions-payload/#view_submission) contains any input blocks, you must listen to `view_submission` requests to receive their values. To listen to `view_submission` requests, you can use the built-in `view()` method. `view()` requires a `callback_id` of type `str` or `re.Pattern`. You can access the value of the `input` blocks by accessing the `state` object. `state` contains a `values` object that uses the `block_id` and unique `action_id` to store the input values. @@ -23,9 +19,9 @@ def handle_submission(ack, body): # https://app.slack.com/block-kit-builder/#%7B%22type%22:%22modal%22,%22callback_id%22:%22view_1%22,%22title%22:%7B%22type%22:%22plain_text%22,%22text%22:%22My%20App%22,%22emoji%22:true%7D,%22blocks%22:%5B%5D%7D ack(response_action="update", view=build_new_view(body)) ``` -Similarly, there are options for [displaying errors](https://docs.slack.dev/surfaces/modals#displaying_errors) in response to view submissions. +Similarly, there are options for [displaying errors](/surfaces/modals#displaying_errors) in response to view submissions. -Read more about view submissions in our [API documentation](https://docs.slack.dev/surfaces/modals#interactions) +Read more about view submissions in our [API documentation](/surfaces/modals#interactions) --- @@ -33,7 +29,7 @@ Read more about view submissions in our [API documentation](https://docs.slack.d When listening for `view_closed` requests, you must pass `callback_id` and add a `notify_on_close` property to the view during creation. See below for an example of this: -See the [API documentation](https://docs.slack.dev/surfaces/modals#interactions) for more information about `view_closed`. +See the [API documentation](/surfaces/modals#interactions) for more information about `view_closed`. ```python @@ -62,7 +58,7 @@ def handle_view_closed(ack, body, logger): logger.info(body) ``` -Refer to [the module document](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) to learn the available listener arguments. +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. ```python # Handle a view_submission request @app.view("view_1") diff --git a/docs/english/concepts/web-api.md b/docs/english/concepts/web-api.md new file mode 100644 index 000000000..9cf436851 --- /dev/null +++ b/docs/english/concepts/web-api.md @@ -0,0 +1,22 @@ +# Using the Web API + +You can call [any Web API method](/reference/methods) using the `WebClient` provided to your Bolt app as either `app.client` or `client` in middleware/listener arguments (given that your app has the appropriate scopes). When you call one the client's methods, it returns a `SlackResponse` which contains the response from Slack. + +The token used to initialize Bolt can be found in the `context` object, which is required to call most Web API methods. + +:::info[Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments.] + +::: + +```python +@app.message("wake me up") +def say_hello(client, message): + # Unix Epoch time for September 30, 2020 11:59:59 PM + when_september_ends = 1601510399 + channel_id = message["channel"] + client.chat_scheduleMessage( + channel=channel_id, + post_at=when_september_ends, + text="Summer has come and passed" + ) +``` diff --git a/docs/content/getting-started.md b/docs/english/getting-started.md similarity index 80% rename from docs/content/getting-started.md rename to docs/english/getting-started.md index a794f3176..ebdf47189 100644 --- a/docs/content/getting-started.md +++ b/docs/english/getting-started.md @@ -1,9 +1,8 @@ --- -title: Quickstart guide with Bolt for Python sidebar_label: Quickstart --- -# Getting started with Bolt for Python +# Quickstart guide with Bolt for Python This quickstart guide aims to help you get a Slack app using Bolt for Python up and running as soon as possible! @@ -14,20 +13,20 @@ When complete, you'll have a local environment configured with a customized [app :::tip[Reference for readers] -In search of the complete guide to building an app from scratch? Check out the [building an app](/building-an-app) guide. +In search of the complete guide to building an app from scratch? Check out the [building an app](/tools/bolt-python/building-an-app) guide. ::: #### Prerequisites -A few tools are needed for the following steps. We recommend using the [**Slack CLI**](https://tools.slack.dev/slack-cli/) for the smoothest experience, but other options remain available. +A few tools are needed for the following steps. We recommend using the [**Slack CLI**](/tools/slack-cli/) for the smoothest experience, but other options remain available. You can also begin by installing git and downloading [Python 3.6 or later](https://www.python.org/downloads/), or the latest stable version of Python. Refer to [Python's setup and building guide](https://devguide.python.org/getting-started/setup-building/) for more details. Install the latest version of the Slack CLI to get started: -- [Slack CLI for macOS & Linux](https://tools.slack.dev/slack-cli/guides/installing-the-slack-cli-for-mac-and-linux) -- [Slack CLI for Windows](https://tools.slack.dev/slack-cli/guides/installing-the-slack-cli-for-windows) +- [Slack CLI for macOS & Linux](/tools/slack-cli/guides/installing-the-slack-cli-for-mac-and-linux) +- [Slack CLI for Windows](/tools/slack-cli/guides/installing-the-slack-cli-for-windows) Then confirm a successful installation with the following command: @@ -45,7 +44,7 @@ $ slack login A workspace where development can happen is also needed. -We recommend using [developer sandboxes](https://docs.slack.dev/tools/developer-sandboxes) to avoid disruptions where real work gets done. +We recommend using [developer sandboxes](/tools/developer-sandboxes) to avoid disruptions where real work gets done. ::: @@ -133,9 +132,9 @@ Navigate to your list of apps and [create a new Slack app](https://api.slack.com You'll then land on your app's **Basic Information** page, which is an overview of your app and which contains important credentials: -![Basic Information page](/img/boltpy/basic-information-page.png "Basic Information page") +![Basic Information page](/img/bolt-python/basic-information-page.png "Basic Information page") -To listen for events happening in Slack (such as a new posted message) without opening a port or exposing an endpoint, we will use [Socket Mode](/concepts/socket-mode). This connection requires a specific app token: +To listen for events happening in Slack (such as a new posted message) without opening a port or exposing an endpoint, we will use [Socket Mode](/tools/bolt-python/concepts/socket-mode). This connection requires a specific app token: 1. On the **Basic Information** page, scroll to the **App-Level Tokens** section and click **Generate Token and Scopes**. 2. Name the token "Development" or something similar and add the `connections:write` scope, then click **Generate**. @@ -149,7 +148,7 @@ The above command works on Linux and macOS but [similar commands are available o :::warning[Keep it secret. Keep it safe.] -Treat your tokens like a password and [keep it safe](https://docs.slack.dev/authentication/best-practices-for-security). Your app uses these to retrieve and send information to Slack. +Treat your tokens like a password and [keep it safe](/authentication/best-practices-for-security). Your app uses these to retrieve and send information to Slack. ::: @@ -158,7 +157,7 @@ A bot token is also needed to interact with the Web API methods as your app's bo 1. Navigate to the **OAuth & Permissions** on the left sidebar and install your app to your workspace to generate a token. 2. After authorizing the installation, you'll return to the **OAuth & Permissions** page and find a **Bot User OAuth Token**: -![OAuth Tokens](/img/boltpy/bot-token.png "Bot OAuth Token") +![OAuth Tokens](/img/bolt-python/bot-token.png "Bot OAuth Token") 3. Copy the bot token beginning with `xoxb` from the **OAuth & Permissions page** and then store it in a new environment variable: @@ -252,7 +251,7 @@ Your app can be stopped again by pressing `CTRL+C` in the terminal to end these #### Customizing app settings -The created app will have some placeholder values and a small set of [scopes](https://docs.slack.dev/reference/scopes) to start, but we recommend exploring the customizations possible on app settings. +The created app will have some placeholder values and a small set of [scopes](/reference/scopes) to start, but we recommend exploring the customizations possible on app settings. @@ -265,7 +264,7 @@ $ slack app settings This will open the following page in a web browser: -![Basic Information page](/img/boltpy/basic-information-page.png "Basic Information page") +![Basic Information page](/img/bolt-python/basic-information-page.png "Basic Information page") @@ -274,7 +273,7 @@ Browse to https://api.slack.com/apps and select your app "Getting Started Bolt A This will open the following page: -![Basic Information page](/img/boltpy/basic-information-page.png "Basic Information page") +![Basic Information page](/img/bolt-python/basic-information-page.png "Basic Information page") @@ -287,14 +286,14 @@ Congrats once more on getting up and running with this quick start. :::info[Dive deeper] -Follow along with the steps that went into making this app on the [building an app](/building-an-app) guide for an educational overview. +Follow along with the steps that went into making this app on the [building an app](/tools/bolt-python/building-an-app) guide for an educational overview. ::: You can now continue customizing your app with various features to make it right for whatever job's at hand. Here are some ideas about what to explore next: -- Explore the different events your bot can listen to with the [`app.event()`](/concepts/event-listening) method. All of the [events](https://docs.slack.dev/reference/events) are listed on the API docs site. -- Bolt allows you to call [Web API](/concepts/web-api) methods with the client attached to your app. There are [over 200 methods](https://docs.slack.dev/reference/methods) on the API docs site. -- Learn more about the different [token types](https://docs.slack.dev/authentication/tokens) and [authentication setups](/concepts/authenticating-oauth). Your app might need different tokens depending on the actions you want to perform or for installations to multiple workspaces. -- Receive events using HTTP for various deployment methods, such as deploying to [Heroku](/deployments/heroku) or [AWS Lambda](/deployments/aws-lambda). -- Read on [app design](https://docs.slack.dev/surfaces/app-design) and compose fancy messages with blocks using [Block Kit Builder](https://app.slack.com/block-kit-builder) to prototype messages. +- Explore the different events your bot can listen to with the [`app.event()`](/tools/bolt-python/concepts/event-listening) method. All of the [events](/reference/events) are listed on the API docs site. +- Bolt allows you to call [Web API](/tools/bolt-python/concepts/web-api) methods with the client attached to your app. There are [over 200 methods](/reference/methods) on the API docs site. +- Learn more about the different [token types](/authentication/tokens) and [authentication setups](/tools/bolt-python/concepts/authenticating-oauth). Your app might need different tokens depending on the actions you want to perform or for installations to multiple workspaces. +- Receive events using HTTP for various deployment methods, such as deploying to Heroku or AWS Lambda. +- Read on [app design](/surfaces/app-design) and compose fancy messages with blocks using [Block Kit Builder](https://app.slack.com/block-kit-builder) to prototype messages. diff --git a/docs/content/index.md b/docs/english/index.md similarity index 93% rename from docs/content/index.md rename to docs/english/index.md index 33204bca1..212bd9690 100644 --- a/docs/content/index.md +++ b/docs/english/index.md @@ -1,6 +1,6 @@ # Bolt for Python -Bolt for Python is a Python framework to build Slack apps with the latest Slack platform features. Read the [Getting Started Guide](/getting-started) to set up and run your first Bolt app. +Bolt for Python is a Python framework to build Slack apps with the latest Slack platform features. Read the [Getting Started Guide](/tools/bolt-python/getting-started) to set up and run your first Bolt app. Then, explore the rest of the pages within the Guides section. The documentation there will help you build a Bolt app for whatever use case you may have. diff --git a/docs/content/concepts/steps-from-apps.md b/docs/english/legacy/steps-from-apps.md similarity index 67% rename from docs/content/concepts/steps-from-apps.md rename to docs/english/legacy/steps-from-apps.md index 09becda0c..03b9fa8ff 100644 --- a/docs/content/concepts/steps-from-apps.md +++ b/docs/english/legacy/steps-from-apps.md @@ -1,20 +1,14 @@ ---- -title: Steps from apps -lang: en -slug: /legacy/steps-from-apps ---- +# Steps from apps -:::danger +:::danger[Steps from Apps is a deprecated feature.] -Steps from Apps is a deprecated feature. +Steps from Apps are different than, and not interchangeable with, Slack automation workflows. We encourage those who are currently publishing steps from apps to consider the new [Slack automation features](/workflows/), such as [custom steps for Bolt](/workflows/workflow-steps). -Steps from Apps are different than, and not interchangeable with, Slack automation workflows. We encourage those who are currently publishing steps from apps to consider the new [Slack automation features](https://docs.slack.dev/workflows/), such as [custom steps for Bolt](https://docs.slack.dev/workflows/workflow-steps). - -Please [read the Slack API changelog entry](https://docs.slack.dev/changelog/2023-08-workflow-steps-from-apps-step-back) for more information. +Please [read the Slack API changelog entry](/changelog/2023-08-workflow-steps-from-apps-step-back) for more information. ::: -Steps from apps allow your app to create and process steps that users can add using [Workflow Builder](https://docs.slack.dev/workflows/workflow-builder). +Steps from apps allow your app to create and process steps that users can add using [Workflow Builder](/workflows/workflow-builder). Steps from apps are made up of three distinct user events: @@ -24,7 +18,7 @@ Steps from apps are made up of three distinct user events: All three events must be handled for a step from app to function. -Read more about steps from apps in the [API documentation](https://docs.slack.dev/workflows/workflow-steps). +Read more about steps from apps in the [API documentation](/workflows/workflow-steps). ## Creating steps from apps @@ -36,9 +30,9 @@ The configuration object contains three keys: `edit`, `save`, and `execute`. Eac After instantiating a `WorkflowStep`, you can pass it into `app.step()`. Behind the scenes, your app will listen and respond to the step’s events using the callbacks provided in the configuration object. -Alternatively, steps from apps can also be created using the `WorkflowStepBuilder` class alongside a decorator pattern. For more information, including an example of this approach, [refer to the documentation](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/workflows/step/step.html#slack_bolt.workflows.step.step.WorkflowStepBuilder). +Alternatively, steps from apps can also be created using the `WorkflowStepBuilder` class alongside a decorator pattern. For more information, including an example of this approach, [refer to the documentation](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/step.html#slack_bolt.workflows.step.step.WorkflowStepBuilder). -Refer to the module documents ([common](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) / [step-specific](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/workflows/step/utilities/index.html)) to learn the available arguments. +Refer to the module documents ([common](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [step-specific](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) to learn the available arguments. ```python import os @@ -74,15 +68,15 @@ app.step(ws) ## Adding or editing steps from apps -When a builder adds (or later edits) your step in their workflow, your app will receive a [`workflow_step_edit` event](https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step_edit-payload). The `edit` callback in your `WorkflowStep` configuration will be run when this event is received. +When a builder adds (or later edits) your step in their workflow, your app will receive a [`workflow_step_edit` event](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step_edit-payload). The `edit` callback in your `WorkflowStep` configuration will be run when this event is received. -Whether a builder is adding or editing a step, you need to send them a [step from app configuration modal](https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-configuration-view-object). This modal is where step-specific settings are chosen, and it has more restrictions than typical modals—most notably, it cannot include `title`, `submit`, or `close` properties. By default, the configuration modal's `callback_id` will be the same as the step from app. +Whether a builder is adding or editing a step, you need to send them a [step from app configuration modal](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-configuration-view-object). This modal is where step-specific settings are chosen, and it has more restrictions than typical modals—most notably, it cannot include `title`, `submit`, or `close` properties. By default, the configuration modal's `callback_id` will be the same as the step from app. Within the `edit` callback, the `configure()` utility can be used to easily open your step's configuration modal by passing in the view's blocks with the corresponding `blocks` argument. To disable saving the configuration before certain conditions are met, you can also pass in `submit_disabled` with a value of `True`. -To learn more about opening configuration modals, [read the documentation](https://docs.slack.dev/legacy/legacy-steps-from-apps/). +To learn more about opening configuration modals, [read the documentation](/legacy/legacy-steps-from-apps/). -Refer to the module documents ([common](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) / [step-specific](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/workflows/step/utilities/index.html)) to learn the available arguments. +Refer to the module documents ([common](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [step-specific](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) to learn the available arguments. ```python def edit(ack, step, configure): @@ -132,9 +126,9 @@ Within the `save` callback, the `update()` method can be used to save the builde - `step_name` overrides the default Step name - `step_image_url` overrides the default Step image -To learn more about how to structure these parameters, [read the documentation](https://docs.slack.dev/legacy/legacy-steps-from-apps/). +To learn more about how to structure these parameters, [read the documentation](/legacy/legacy-steps-from-apps/). -Refer to the module documents ([common](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) / [step-specific](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/workflows/step/utilities/index.html)) to learn the available arguments. +Refer to the module documents ([common](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [step-specific](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) to learn the available arguments. ```python def save(ack, view, update): @@ -173,13 +167,13 @@ app.step(ws) ## Executing steps from apps -When your step from app is executed by an end user, your app will receive a [`workflow_step_execute` event](https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object). The `execute` callback in your `WorkflowStep` configuration will be run when this event is received. +When your step from app is executed by an end user, your app will receive a [`workflow_step_execute` event](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object). The `execute` callback in your `WorkflowStep` configuration will be run when this event is received. Using the `inputs` from the `save` callback, this is where you can make third-party API calls, save information to a database, update the user's Home tab, or decide the outputs that will be available to subsequent steps from apps by mapping values to the `outputs` object. Within the `execute` callback, your app must either call `complete()` to indicate that the step's execution was successful, or `fail()` to indicate that the step's execution failed. -Refer to the module documents ([common](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html) / [step-specific](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/workflows/step/utilities/index.html)) to learn the available arguments. +Refer to the module documents ([common](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [step-specific](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) to learn the available arguments. ```python def execute(step, complete, fail): diff --git a/docs/static/img/tutorials/ai-chatbot/1.png b/docs/english/tutorial/ai-chatbot/1.png similarity index 100% rename from docs/static/img/tutorials/ai-chatbot/1.png rename to docs/english/tutorial/ai-chatbot/1.png diff --git a/docs/static/img/tutorials/ai-chatbot/2.png b/docs/english/tutorial/ai-chatbot/2.png similarity index 100% rename from docs/static/img/tutorials/ai-chatbot/2.png rename to docs/english/tutorial/ai-chatbot/2.png diff --git a/docs/static/img/tutorials/ai-chatbot/3.png b/docs/english/tutorial/ai-chatbot/3.png similarity index 100% rename from docs/static/img/tutorials/ai-chatbot/3.png rename to docs/english/tutorial/ai-chatbot/3.png diff --git a/docs/static/img/tutorials/ai-chatbot/4.png b/docs/english/tutorial/ai-chatbot/4.png similarity index 100% rename from docs/static/img/tutorials/ai-chatbot/4.png rename to docs/english/tutorial/ai-chatbot/4.png diff --git a/docs/static/img/tutorials/ai-chatbot/5.png b/docs/english/tutorial/ai-chatbot/5.png similarity index 100% rename from docs/static/img/tutorials/ai-chatbot/5.png rename to docs/english/tutorial/ai-chatbot/5.png diff --git a/docs/static/img/tutorials/ai-chatbot/6.png b/docs/english/tutorial/ai-chatbot/6.png similarity index 100% rename from docs/static/img/tutorials/ai-chatbot/6.png rename to docs/english/tutorial/ai-chatbot/6.png diff --git a/docs/static/img/tutorials/ai-chatbot/7.png b/docs/english/tutorial/ai-chatbot/7.png similarity index 100% rename from docs/static/img/tutorials/ai-chatbot/7.png rename to docs/english/tutorial/ai-chatbot/7.png diff --git a/docs/static/img/tutorials/ai-chatbot/8.png b/docs/english/tutorial/ai-chatbot/8.png similarity index 100% rename from docs/static/img/tutorials/ai-chatbot/8.png rename to docs/english/tutorial/ai-chatbot/8.png diff --git a/docs/content/tutorial/ai-chatbot.md b/docs/english/tutorial/ai-chatbot/ai-chatbot.md similarity index 88% rename from docs/content/tutorial/ai-chatbot.md rename to docs/english/tutorial/ai-chatbot/ai-chatbot.md index 7db10b722..fa4da90a7 100644 --- a/docs/content/tutorial/ai-chatbot.md +++ b/docs/english/tutorial/ai-chatbot/ai-chatbot.md @@ -32,7 +32,7 @@ If you'd rather skip the tutorial and just head straight to the code, you can us Before you'll be able to successfully run the app, you'll need to first obtain and set some environment variables. 1. On the **Install App** page, copy your **Bot User OAuth Token**. You will store this in your environment as `SLACK_BOT_TOKEN` (we'll get to that next). -2. Navigate to **Basic Information** and in the **App-Level Tokens** section , click **Generate Token and Scopes**. Add the [`connections:write`](https://docs.slack.dev/reference/scopes/connections.write) scope, name the token, and click **Generate**. (For more details, refer to [understanding OAuth scopes for bots](https://docs.slack.dev/authentication/tokens#bot)). Copy this token. You will store this in your environment as `SLACK_APP_TOKEN`. +2. Navigate to **Basic Information** and in the **App-Level Tokens** section , click **Generate Token and Scopes**. Add the [`connections:write`](/reference/scopes/connections.write) scope, name the token, and click **Generate**. (For more details, refer to [understanding OAuth scopes for bots](/authentication/tokens#bot)). Copy this token. You will store this in your environment as `SLACK_APP_TOKEN`. To store your tokens and environment variables, run the following commands in the terminal. Replace the placeholder values with your bot and app tokens collected above, as well as the key or keys for the AI provider or providers you want to use: @@ -100,7 +100,7 @@ Navigate to the Bolty **App Home** and select a provider from the drop-down menu If you don't see Bolty listed under **Apps** in your workspace right away, never fear! You can mention **@Bolty** in a public channel to add the app, then navigate to your **App Home**. -![Choose your AI provider](/img/tutorials/ai-chatbot/6.png) +![Choose your AI provider](6.png) ## Setting up your workflow {#workflow} @@ -108,11 +108,11 @@ Within your development workspace, open Workflow Builder by clicking on your wor Click **Untitled Workflow** at the top to rename your workflow. For this tutorial, we'll call the workflow **Welcome to the channel**. Enter a description, such as _Summarizes channels for new members_, and click **Save**. -![Setting up a new workflow](/img/tutorials/ai-chatbot/1.png) +![Setting up a new workflow](1.png) Select **Choose an event** under **Start the workflow...**, and then choose **When a person joins a channel**. Select the channel name from the drop-down menu and click **Save**. -![Start the workflow](/img/tutorials/ai-chatbot/2.png) +![Start the workflow](2.png) Under **Then, do these things**, click **Add steps** and complete the following: @@ -121,20 +121,20 @@ Under **Then, do these things**, click **Add steps** and complete the following: 3. Under **Add a message**, enter a short message, such as _Hi! Welcome to `{}The channel that the user joined`. Would you like a summary of the recent conversation?_ Note that the _`{}The channel that the user joined`_ is a variable; you can insert it by selecting **{}Insert a variable** at the bottom of the message text box. 4. Select the **Add Button** button, and name the button _Yes, give me a summary_. Click **Done**. -![Send a message](/img/tutorials/ai-chatbot/3.png) +![Send a message](3.png) We'll add two more steps under the **Then, do these things** section. First, scroll to the bottom of the list of steps and choose **Custom**, then choose **Bolty** and **Bolty Custom Function**. In the **Channel** drop-down menu, select **Channel that the user joined**. Click **Save**. -![Bolty custom function](/img/tutorials/ai-chatbot/4.png) +![Bolty custom function](4.png) For the final step, complete the following: 1. Choose **Messages** and then **Send a message to a person**. Under **Select a member**, choose **Person who clicked the button** from the drop-down menu. 2. Under **Add a message**, click **Insert a variable** and choose **`{}Summary`** under the **Bolty Custom Function** section in the list that appears. Click **Save**. -![Summary](/img/tutorials/ai-chatbot/5.png) +![Summary](5.png) When finished, click **Finish Up**, then click **Publish** to make the workflow available in your workspace. @@ -149,9 +149,9 @@ In order for Bolty to provide summaries of recent conversation in a channel, Bol To test this, leave the channel you just invited Bolty to and rejoin it. This will kick off your workflow and you'll receive a direct message from **Welcome to the channel**. Click the **Yes, give me a summary** button, and Bolty will summarize the recent conversations in the channel you joined. -![Channel summary](/img/tutorials/ai-chatbot/7.png) +![Channel summary](7.png) -The central part of this functionality is shown in the following code snippet. Note the use of the [`user_context`](https://tools.slack.dev/deno-slack-sdk/reference/slack-types#usercontext) object, a Slack type that represents the user who is interacting with our workflow, as well as the `history` of the channel that will be summarized, which includes the ten most recent messages. +The central part of this functionality is shown in the following code snippet. Note the use of the [`user_context`](/tools/deno-slack-sdk/reference/slack-types#usercontext) object, a Slack type that represents the user who is interacting with our workflow, as well as the `history` of the channel that will be summarized, which includes the ten most recent messages. ```python from ai.providers import get_provider_response @@ -191,12 +191,12 @@ To ask Bolty a question, you can chat with Bolty in any channel the app is in. U You can also navigate to **Bolty** in your **Apps** list and select the **Messages** tab to chat with Bolty directly. -![Ask Bolty](/img/tutorials/ai-chatbot/8.png) +![Ask Bolty](8.png) ## Next steps {#next-steps} Congratulations! You've successfully integrated the power of AI into your workspace. Check out these links to take the next steps in your Bolt for Python journey. -* To learn more about Bolt for Python, refer to the [Getting started](../getting-started) documentation. -* For more details about creating workflow steps using the Bolt SDK, refer to the [workflow steps for Bolt](https://docs.slack.dev/workflows/workflow-steps) guide. -* To use the Bolt for Python SDK to develop on the automations platform, refer to the [Create a workflow step for Workflow Builder: Bolt for Python](/bolt-python/tutorial/custom-steps) tutorial. +* To learn more about Bolt for Python, refer to the [Getting started](/tools/bolt-python/getting-started) documentation. +* For more details about creating workflow steps using the Bolt SDK, refer to the [workflow steps for Bolt](/workflows/workflow-steps) guide. +* To use the Bolt for Python SDK to develop on the automations platform, refer to the [Create a workflow step for Workflow Builder: Bolt for Python](/tools/bolt-python/tutorial/custom-steps-workflow-builder-new) tutorial. diff --git a/docs/static/img/tutorials/custom-steps-jira/1.png b/docs/english/tutorial/custom-steps-for-jira/1.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-jira/1.png rename to docs/english/tutorial/custom-steps-for-jira/1.png diff --git a/docs/static/img/tutorials/custom-steps-jira/2.png b/docs/english/tutorial/custom-steps-for-jira/2.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-jira/2.png rename to docs/english/tutorial/custom-steps-for-jira/2.png diff --git a/docs/static/img/tutorials/custom-steps-jira/3.png b/docs/english/tutorial/custom-steps-for-jira/3.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-jira/3.png rename to docs/english/tutorial/custom-steps-for-jira/3.png diff --git a/docs/static/img/tutorials/custom-steps-jira/4.png b/docs/english/tutorial/custom-steps-for-jira/4.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-jira/4.png rename to docs/english/tutorial/custom-steps-for-jira/4.png diff --git a/docs/static/img/tutorials/custom-steps-jira/5.png b/docs/english/tutorial/custom-steps-for-jira/5.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-jira/5.png rename to docs/english/tutorial/custom-steps-for-jira/5.png diff --git a/docs/static/img/tutorials/custom-steps-jira/6.png b/docs/english/tutorial/custom-steps-for-jira/6.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-jira/6.png rename to docs/english/tutorial/custom-steps-for-jira/6.png diff --git a/docs/static/img/tutorials/custom-steps-jira/7.png b/docs/english/tutorial/custom-steps-for-jira/7.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-jira/7.png rename to docs/english/tutorial/custom-steps-for-jira/7.png diff --git a/docs/content/tutorial/custom-steps-for-jira.md b/docs/english/tutorial/custom-steps-for-jira/custom-steps-for-jira.md similarity index 86% rename from docs/content/tutorial/custom-steps-for-jira.md rename to docs/english/tutorial/custom-steps-for-jira/custom-steps-for-jira.md index b38f9337c..f310e75cc 100644 --- a/docs/content/tutorial/custom-steps-for-jira.md +++ b/docs/english/tutorial/custom-steps-for-jira/custom-steps-for-jira.md @@ -11,7 +11,7 @@ In this tutorial, you'll learn how to configure custom steps for use with JIRA. Before getting started, you will need the following: -* a development workspace where you have permissions to install apps. If you don’t have a workspace, go ahead and set that up now—you can [go here](https://slack.com/get-started#create) to create one, or you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. +* a development workspace where you have permissions to install apps. If you don’t have a workspace, go ahead and set that up now—you can [go here](https://slack.com/get-started#create) to create one, or you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. * a development environment with [Python 3.6](https://www.python.org/downloads/) or later. **Skip to the code** @@ -35,7 +35,7 @@ https://github.com/slack-samples/bolt-python-jira-functions/blob/main/manifest.j Before you'll be able to successfully run the app, you'll need to obtain and set some environment variables. 1. Once you have installed the app to your workspace, copy the **Bot User OAuth Token** from the **Install App** page. You will store this in your environment as `SLACK_BOT_TOKEN` (we'll get to that next). -2. Navigate to **Basic Information** and in the **App-Level Tokens** section , click **Generate Token and Scopes**. Add the [`connections:write`](https://docs.slack.dev/reference/scopes/connections.write) scope, name the token, and click **Generate**. Copy this token. You will store this in your environment as `SLACK_APP_TOKEN`. +2. Navigate to **Basic Information** and in the **App-Level Tokens** section , click **Generate Token and Scopes**. Add the [`connections:write`](/reference/scopes/connections.write) scope, name the token, and click **Generate**. Copy this token. You will store this in your environment as `SLACK_APP_TOKEN`. 3. Follow [these instructions](https://confluence.atlassian.com/adminjiraserver0909/configure-an-incoming-link-1251415519.html) to create an external app link and to generate its redirect URL (the base of which will be stored as your APP_BASE_URL variable below), client ID, and client secret. 4. Run the following commands in your terminal to store your environment variables, client ID, and client secret. 5. You'll also need to know your team ID (found by opening your Slack instance in a web browser and copying the value within the link that starts with the letter **T**) and your app ID (found under **Basic Information**). @@ -127,21 +127,21 @@ If your app is up and running, you'll see a message noting that the app is start 2. Select **New Workflow** > **Build Workflow**. 3. Click **Untitled Workflow** at the top of the pane to rename your workflow. We'll call it **Create Issue**. For the description, enter _Creates a new issue_, then click **Save**. -![Workflow details](/img/tutorials/custom-steps-jira/1.png) +![Workflow details](1.png) 4. Select **Choose an event** under **Start the workflow...**, and then select **From a link in Slack**. Click **Continue**. -![Start the workflow](/img/tutorials/custom-steps-jira/2.png) +![Start the workflow](2.png) 5. Under **Then, do these things** click **Add steps** to add the custom step. Your custom step will be the function defined in the [`create_issue.py`](https://github.com/slack-samples/bolt-python-jira-functions/blob/main/listeners/functions/create_issue.py) file. Scroll down to the bottom of the list on the right-hand pane and select **Custom**, then **BoltPy Jira Functions** > **Create an issue**. Enter the project details, issue type (optional), summary (optional), and description (optional). Click **Save**. -![Custom function](/img/tutorials/custom-steps-jira/3.png) +![Custom function](3.png) 6. Add another step and select **Messages** > **Send a message to a channel**. Select **Channel where the workflow was used** from the drop-down list and then select **Insert a variable** and **Issue url**. Click **Save**. -![Insert variable for issue URL](/img/tutorials/custom-steps-jira/4.png) +![Insert variable for issue URL](4.png) 7. Click **Publish** to make the workflow available to your workspace. @@ -150,16 +150,16 @@ If your app is up and running, you'll see a message noting that the app is start 1. Copy your workflow link. 2. Navigate to your app's home tab and click **Connect an Account** to connect your JIRA account to the app. -![Connect account](/img/tutorials/custom-steps-jira/5.png) +![Connect account](5.png) 3. Click **Allow** on the screen that appears. -![Allow connection](/img/tutorials/custom-steps-jira/6.png) +![Allow connection](6.png) 4. In any channel, post the workflow link you copied. 5. Click **Start Workflow** and observe as the link to a new JIRA ticket is posted in the channel. Click the link to be directed to the newly-created issue within your JIRA project. -![JIRA issue](/img/tutorials/custom-steps-jira/7.png) +![JIRA issue](7.png) When finished, you can click the **Disconnect Account** button in the home tab to disconnect your app from your JIRA account. @@ -167,6 +167,6 @@ When finished, you can click the **Disconnect Account** button in the home tab t Congratulations! You've successfully customized your workspace with custom steps in Workflow Builder. Check out these links to take the next steps in your journey. -* To learn more about Bolt for Python, refer to the [getting started](/getting-started) documentation. -* For more details about creating workflow steps using the Bolt SDK, refer to the [workflow steps for Bolt](https://docs.slack.dev/workflows/workflow-steps) guide. -* For information about custom steps dynamic options, refer to [custom steps dynamic options in Workflow Builder](https://docs.slack.dev/workflows/creating-custom-steps-dynamic-options). +* To learn more about Bolt for Python, refer to the [getting started](/tools/bolt-python/getting-started) documentation. +* For more details about creating workflow steps using the Bolt SDK, refer to the [workflow steps for Bolt](/workflows/workflow-steps) guide. +* For information about custom steps dynamic options, refer to [custom steps dynamic options in Workflow Builder](/tools/bolt-python/concepts/custom-steps-dynamic-options). diff --git a/docs/static/img/tutorials/custom-steps-wfb-existing/add-step.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/add-step.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-existing/add-step.png rename to docs/english/tutorial/custom-steps-workflow-builder-existing/add-step.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-existing/app-message.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/app-message.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-existing/app-message.png rename to docs/english/tutorial/custom-steps-workflow-builder-existing/app-message.png diff --git a/docs/content/tutorial/custom-steps-workflow-builder-existing.md b/docs/english/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing.md similarity index 91% rename from docs/content/tutorial/custom-steps-workflow-builder-existing.md rename to docs/english/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing.md index e5c584a4c..0441b033c 100644 --- a/docs/content/tutorial/custom-steps-workflow-builder-existing.md +++ b/docs/english/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing.md @@ -1,12 +1,10 @@ ---- -title: Custom Steps for Workflow Builder (existing app) ---- +# Custom Steps for Workflow Builder (existing app) :::info[This feature requires a paid plan] If you don't have a paid workspace for development, you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. ::: -If you followed along with our [create a custom step for Workflow Builder: new app](/tutorial/custom-steps-workflow-builder-new) tutorial, you have seen how to add custom steps to a brand new app. But what if you have an app up and running currently to which you'd like to add custom steps? You've come to the right place! +If you followed along with our [create a custom step for Workflow Builder: new app](/tools/bolt-python/tutorial/custom-steps-workflow-builder-new) tutorial, you have seen how to add custom steps to a brand new app. But what if you have an app up and running currently to which you'd like to add custom steps? You've come to the right place! In this tutorial we will: - Start with an existing Bolt app @@ -28,7 +26,7 @@ In order to add custom workflow steps to an app, the app also needs to be org-re Navigate to **Org Level Apps** in the left nav and click **Opt-In**, then confirm **Yes, Opt-In**. -![Make your app org-ready](/img/tutorials/custom-steps-wfb-existing/org-ready.png) +![Make your app org-ready](org-ready.png) ## Adding a new workflow step {#add-step} @@ -54,19 +52,19 @@ Navigate to **App Manifest** in the left nav and add the `function_executed` eve Navigate to **Workflow Steps** in the left nav and click **Add Step**. This is where we'll configure our step's inputs, outputs, name, and description. -![Add step](/img/tutorials/custom-steps-wfb-existing/add-step.png) +![Add step](add-step.png) For illustration purposes in this tutorial, we're going to write a custom step called Request Time Off. When the step is invoked, a message will be sent to the provided manager with an option to approve or deny the time-off request. When the manager takes an action (approves or denies the request), a message is posted with the decision and the manager who made the decision. The step will take two user IDs as inputs, representing the requesting user and their manager, and it will output both of those user IDs as well as the decision made. Add the pertinent details to the step: -![Define step](/img/tutorials/custom-steps-wfb-existing/define-step.png) +![Define step](define-step.png) Remember this `callback_id`. We will use this later when implementing a function listener. Then add the input and output parameters: -![Add inputs](/img/tutorials/custom-steps-wfb-existing/inputs.png) +![Add inputs](inputs.png) -![Add outputs](/img/tutorials/custom-steps-wfb-existing/outputs.png) +![Add outputs](outputs.png) Save your changes. @@ -260,11 +258,11 @@ Click the button to create a **New Workflow**, then **Build Workflow**. Choose t In the **Steps** pane to the right, search for your app name and locate the **Request time off** step we created. -![Find step](/img/tutorials/custom-steps-wfb-existing/find-step.png) +![Find step](find-step.png) Select the step and choose the desired inputs and click **Save**. -![Step inputs](/img/tutorials/custom-steps-wfb-existing/step-inputs.png) +![Step inputs](step-inputs.png) Next, click **Finish Up**, give your workflow a name and description, then click **Publish**. Copy the link for your workflow on the next screen, then click **Done**. @@ -272,12 +270,12 @@ Next, click **Finish Up**, give your workflow a name and description, then click In any channel where your app is installed, paste the link you copied and send it as a message. The link will unfurl into a button to start the workflow. Click the button to start the workflow. If you set yourself up as the manager, you will then see a message from your app. Pressing either button will return a confirmation or denial of your time off request. -![Message](/img/tutorials/custom-steps-wfb-existing/app-message.png) +![Message](app-message.png) ## Next steps {#next-steps} Nice work! Now that you've added a workflow step to your Bolt app, a world of possibilities is open to you! Create and share workflow steps across your organization to optimize Slack users' time and make their working lives more productive. -If you're looking to create a brand new Bolt app with custom workflow steps, check out [the tutorial here](/tutorial/custom-steps-workflow-builder-new). +If you're looking to create a brand new Bolt app with custom workflow steps, check out [the tutorial here](/tools/bolt-python/tutorial/custom-steps-workflow-builder-new). -If you're interested in exploring how to create custom steps to use in Workflow Builder as steps with our Deno Slack SDK, too, that tutorial can be found [here](https://tools.slack.dev/deno-slack-sdk/tutorials/workflow-builder-custom-step/). +If you're interested in exploring how to create custom steps to use in Workflow Builder as steps with our Deno Slack SDK, too, that tutorial can be found [here](/tools/deno-slack-sdk/tutorials/workflow-builder-custom-step/). diff --git a/docs/static/img/tutorials/custom-steps-wfb-existing/define-step.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/define-step.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-existing/define-step.png rename to docs/english/tutorial/custom-steps-workflow-builder-existing/define-step.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-existing/find-step.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/find-step.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-existing/find-step.png rename to docs/english/tutorial/custom-steps-workflow-builder-existing/find-step.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-existing/inputs.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/inputs.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-existing/inputs.png rename to docs/english/tutorial/custom-steps-workflow-builder-existing/inputs.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-existing/org-ready.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/org-ready.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-existing/org-ready.png rename to docs/english/tutorial/custom-steps-workflow-builder-existing/org-ready.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-existing/outputs.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/outputs.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-existing/outputs.png rename to docs/english/tutorial/custom-steps-workflow-builder-existing/outputs.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-existing/step-inputs.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/step-inputs.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-existing/step-inputs.png rename to docs/english/tutorial/custom-steps-workflow-builder-existing/step-inputs.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/app-token.png b/docs/english/tutorial/custom-steps-workflow-builder-new/app-token.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/app-token.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/app-token.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/bot-token.png b/docs/english/tutorial/custom-steps-workflow-builder-new/bot-token.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/bot-token.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/bot-token.png diff --git a/docs/content/tutorial/custom-steps-workflow-builder-new.md b/docs/english/tutorial/custom-steps-workflow-builder-new/custom-steps-workflow-builder-new.md similarity index 90% rename from docs/content/tutorial/custom-steps-workflow-builder-new.md rename to docs/english/tutorial/custom-steps-workflow-builder-new/custom-steps-workflow-builder-new.md index 9d01b8676..1dceed45a 100644 --- a/docs/content/tutorial/custom-steps-workflow-builder-new.md +++ b/docs/english/tutorial/custom-steps-workflow-builder-new/custom-steps-workflow-builder-new.md @@ -1,12 +1,10 @@ ---- -title: Custom Steps for Workflow Builder (new app) ---- +# Custom Steps for Workflow Builder (new app) :::info[This feature requires a paid plan] If you don't have a paid workspace for development, you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. ::: -Adding a workflow step to your app and implementing a corresponding function listener is how you define a custom Workflow Builder step. In this tutorial, you'll use [Bolt for Python](/bolt-python/) to add your workflow step, then wire it up in [Workflow Builder](https://slack.com/help/articles/360035692513-Guide-to-Workflow-Builder). +Adding a workflow step to your app and implementing a corresponding function listener is how you define a custom Workflow Builder step. In this tutorial, you'll use [Bolt for Python](/tools/bolt-python/) to add your workflow step, then wire it up in [Workflow Builder](https://slack.com/help/articles/360035692513-Guide-to-Workflow-Builder). When finished, you'll be ready to build scalable and innovative workflow steps for anyone using Workflow Builder in your workspace. @@ -58,7 +56,7 @@ We now have a Bolt app ready for development! Open the `manifest.json` file and Open a browser and navigate to [your apps page](https://api.slack.com/apps). This is where we will create a new app with our previously copied manifest details. Click the **Create New App** button, then select **From an app manifest** when prompted to choose how you'd like to configure your app's settings. -![Create app from manifest](/img/tutorials/custom-steps-wfb-new/manifest.png) +![Create app from manifest](manifest.png) Next, select a workspace where you have permissions to install apps, and click **Next**. Select the **JSON** tab and clear the existing contents. Paste the contents of the `manifest.json` file you previously copied. @@ -70,11 +68,11 @@ All of your app's settings can be configured within these screens. By creating a Navigate to **Event Subscriptions** and expand **Subscribe to bot events** to see that we have subscribed to the `function_executed` event. This is also a requirement for adding workflow steps to our app, as it lets our app know when a step has been triggered, allowing our app to respond to it. -Another configuration setting to note is **Socket Mode**. We have turned this on for our local development, but socket mode is not intended for use in a production environment. When you are satisfied with your app and ready to deploy it to a production environment, you should switch to using public HTTP request URLs. Read more about getting started with HTTP in [Bolt for Python here](/bolt-python/getting-started). +Another configuration setting to note is **Socket Mode**. We have turned this on for our local development, but socket mode is not intended for use in a production environment. When you are satisfied with your app and ready to deploy it to a production environment, you should switch to using public HTTP request URLs. Read more about getting started with HTTP in [Bolt for Python here](/tools/bolt-python/getting-started). Clicking on **Workflow Steps** in the left nav will show you that one workflow step has been added! This reflects the `function` defined in our manifest: functions are workflow steps. We will get to this step's implementation later. -![Workflow step](/img/tutorials/custom-steps-wfb-new/workflow-step.png) +![Workflow step](workflow-step.png) ### Tokens {#tokens} @@ -85,17 +83,17 @@ In order to connect our app here with the logic of our sample code set up locall To generate an app token, navigate to **Basic Information** and scroll down to **App-Level Token**. -![App token](/img/tutorials/custom-steps-wfb-new/app-token.png) +![App token](app-token.png) Click **Generate Token and Scopes**, then **Add Scope** and choose `connections:write`. Choose a name for your token and click **Generate**. Copy that value, save it somewhere accessible, and click **Done** to close out of the modal. Next up is the bot token. We can only get this token by installing the app into the workspace. Navigate to **Install App** and click the button to install, choosing **Allow** at the next screen. -![Install app](/img/tutorials/custom-steps-wfb-new/install.png) +![Install app](install.png) You will then have a bot token. Again, copy that value and save it somewhere accessible. -![Bot token](/img/tutorials/custom-steps-wfb-new/bot-token.png) +![Bot token](bot-token.png) 💡 Treat your tokens like passwords and keep them safe. Your app uses them to post and retrieve information from Slack workspaces. Minimally, do NOT commit them to version control. @@ -120,8 +118,7 @@ You'll know the local development server is up and running successfully when it With your development server running, continue to the next step. -:::info -If you need to stop running the local development server, press `` + `c` to end the process. +:::info[If you need to stop running the local development server, press `` + `c` to end the process.] ::: ## Wiring up the sample step in Workflow Builder {#wfb} @@ -130,15 +127,15 @@ The starter project you cloned contains a sample custom step lovingly titled “ In the Slack Client of your development workspace, open Workflow Builder by clicking on the workspace name, **Tools**, then **Workflow Builder**. Create a new workflow, then select **Build Workflow**: -![Creating a new workflow](/img/tutorials/custom-steps-wfb-new/wfb-1.png) +![Creating a new workflow](wfb-1.png) Select **Choose an event** under **Start the workflow...**, then **From a link in Slack** to configure this workflow to start when someone clicks its shortcut link: -![Starting a new workflow from a shortcut link](/img/tutorials/custom-steps-wfb-new/wfb-2.png) +![Starting a new workflow from a shortcut link](wfb-2.png) Click the **Continue** button to confirm that this is workflow should start with a shortcut link: -![Confirming a new shortcut workflow setup](/img/tutorials/custom-steps-wfb-new/wfb-3.png) +![Confirming a new shortcut workflow setup](wfb-3.png) Find the sample step provided in the template by either searching for the name of your app (e.g., `Bolt Custom Step`) or the name of your step (e.g. `Sample step`) in the Steps search bar. @@ -146,43 +143,43 @@ If you search by app name, any custom step that your app has defined will be lis Add the “Sample step" in the search results to the workflow: -![Adding the sample step to the workflow](/img/tutorials/custom-steps-wfb-new/wfb-4.png) +![Adding the sample step to the workflow](wfb-4.png) As soon as you add the “Sample step" to the workflow, a modal will appear to configure the step's input—in this case, a user variable: -![Configuring the sample step's inputs](/img/tutorials/custom-steps-wfb-new/wfb-5.png) +![Configuring the sample step's inputs](wfb-5.png) Configure the user input to be “Person who used this workflow”, then click the **Save** button: -![Saving the sample step after configuring the user input](/img/tutorials/custom-steps-wfb-new/wfb-6.png) +![Saving the sample step after configuring the user input](wfb-6.png) Click the **Finish Up** button, then provide a name and description for your workflow. Finally, click the **Publish** button: -![Publishing a workflow](/img/tutorials/custom-steps-wfb-new/wfb-7.png) +![Publishing a workflow](wfb-7.png) Copy the shortcut link, then exit Workflow Builder and paste the link to a message in any channel you’re in: -![Copying a workflow link](/img/tutorials/custom-steps-wfb-new/wfb-8.png) +![Copying a workflow link](wfb-8.png) After you send a message containing the shortcut link, the link will unfurl and you’ll see a **Start Workflow** button. Click the **Start Workflow** button: -![Starting your new workflow](/img/tutorials/custom-steps-wfb-new/wfb-9.png) +![Starting your new workflow](wfb-9.png) You should see a new direct message from your app: -![A new direct message from your app](/img/tutorials/custom-steps-wfb-new/wfb-10.png) +![A new direct message from your app](wfb-10.png) The message from your app asks you to click the **Complete step** button: -![A new direct message from your app](/img/tutorials/custom-steps-wfb-new/wfb-11.png) +![A new direct message from your app](wfb-11.png) Once you click the button, the direct message to you will be updated to let you know that the step interaction was successfully completed: -![Sample step finished successfully](/img/tutorials/custom-steps-wfb-new/wfb-12.png) +![Sample step finished successfully](wfb-12.png) Now that we’ve gotten a feel for how we will use the custom step, let’s learn more about how function listeners work. @@ -354,6 +351,6 @@ Slack will send an action event payload to your app when the button is clicked o That's it — we hope you learned a lot! -In this tutorial, we added custom steps via the manifest, but if you'd like to see how to add custom steps in the [app settings](https://api.slack.com/apps) to an existing app, follow along with the [Create a custom step for Workflow Builder: existing Bolt app](/tutorials/custom-steps-workflow-builder-existing) tutorial. +In this tutorial, we added custom steps via the manifest, but if you'd like to see how to add custom steps in the [app settings](https://api.slack.com/apps) to an existing app, follow along with the [Create a custom step for Workflow Builder: existing Bolt app](/tools/bolt-python/tutorial/custom-steps-workflow-builder-existing) tutorial. -If you're interested in exploring how to create custom steps to use in Workflow Builder as steps with our Deno Slack SDK, too, that tutorial can be found [here](https://tools.slack.dev/deno-slack-sdk/tutorials/workflow-builder-custom-step/). +If you're interested in exploring how to create custom steps to use in Workflow Builder as steps with our Deno Slack SDK, too, that tutorial can be found [here](/tools/deno-slack-sdk/tutorials/workflow-builder-custom-step/). diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/install.png b/docs/english/tutorial/custom-steps-workflow-builder-new/install.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/install.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/install.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/manifest.png b/docs/english/tutorial/custom-steps-workflow-builder-new/manifest.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/manifest.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/manifest.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/wfb-1.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-1.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/wfb-1.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/wfb-1.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/wfb-10.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-10.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/wfb-10.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/wfb-10.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/wfb-11.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-11.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/wfb-11.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/wfb-11.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/wfb-12.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-12.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/wfb-12.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/wfb-12.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/wfb-2.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-2.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/wfb-2.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/wfb-2.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/wfb-3.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-3.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/wfb-3.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/wfb-3.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/wfb-4.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-4.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/wfb-4.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/wfb-4.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/wfb-5.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-5.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/wfb-5.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/wfb-5.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/wfb-6.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-6.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/wfb-6.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/wfb-6.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/wfb-7.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-7.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/wfb-7.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/wfb-7.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/wfb-8.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-8.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/wfb-8.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/wfb-8.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/wfb-9.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-9.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/wfb-9.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/wfb-9.png diff --git a/docs/static/img/tutorials/custom-steps-wfb-new/workflow-step.png b/docs/english/tutorial/custom-steps-workflow-builder-new/workflow-step.png similarity index 100% rename from docs/static/img/tutorials/custom-steps-wfb-new/workflow-step.png rename to docs/english/tutorial/custom-steps-workflow-builder-new/workflow-step.png diff --git a/docs/content/tutorial/custom-steps.md b/docs/english/tutorial/custom-steps.md similarity index 94% rename from docs/content/tutorial/custom-steps.md rename to docs/english/tutorial/custom-steps.md index 2486a49ef..66dc16198 100644 --- a/docs/content/tutorial/custom-steps.md +++ b/docs/english/tutorial/custom-steps.md @@ -9,7 +9,7 @@ If you don't have a paid workspace for development, you can join the [Developer With custom steps for Bolt apps, your app can create and process workflow steps that users later add in Workflow Builder. This guide goes through how to build a custom step for your app using the [app settings](https://api.slack.com/apps). -If you're looking to build a custom step using the Deno Slack SDK, check out our guide on [creating a custom step for Workflow Builder with the Deno Slack SDK](https://tools.slack.dev/deno-slack-sdk/tutorials/workflow-builder-custom-step/). +If you're looking to build a custom step using the Deno Slack SDK, check out our guide on [creating a custom step for Workflow Builder with the Deno Slack SDK](/tools/deno-slack-sdk/tutorials/workflow-builder-custom-step/). You can also take a look at the template for the [Bolt for Python custom workflow step](https://github.com/slack-samples/bolt-python-custom-step-template) on GitHub. @@ -69,7 +69,7 @@ Field | Type | Description `type` | String | Defines the data type and can fall into one of two categories: primitives or Slack-specific. `title` | String | The label that appears in Workflow Builder when a user sets up this step in their workflow. `description` | String | The description that accompanies the input when a user sets up this step in their workflow. -`dynamic_options` | Object | For custom steps dynamic options in Workflow Builder, define this property and point to a custom step designed to return the set of dynamic elements once the step is added to a workflow within Workflow Builder. Dynamic options in Workflow Builder can be rendered in one of two ways: as a drop-down menu (single-select or multi-select), or as a set of fields. Refer to custom steps dynamic options for Workflow Builder using [Bolt for JavaScript](https://tools.slack.dev/bolt-js/concepts/custom-steps-dynamic-options/) or [Bolt for Python](https://tools.slack.dev/bolt-python/concepts/custom-steps-dynamic-options/) for more details. +`dynamic_options` | Object | For custom steps dynamic options in Workflow Builder, define this property and point to a custom step designed to return the set of dynamic elements once the step is added to a workflow within Workflow Builder. Dynamic options in Workflow Builder can be rendered in one of two ways: as a drop-down menu (single-select or multi-select), or as a set of fields. Refer to custom steps dynamic options for Workflow Builder using [Bolt for JavaScript](/tools/bolt-js/concepts/custom-steps-dynamic-options/) or [Bolt for Python](https://docs.slack.dev/tools/bolt-python/concepts/custom-steps-dynamic-options/) for more details. `is_required` | Boolean | Indicates whether or not the input is required by the step in order to run. If it’s required and not provided, the user will not be able to save the configuration nor use the step in their workflow. This property is available only in v1 of the manifest. We recommend v2, using the `required` array as noted in the example above. `hint` | String | Helper text that appears below the input when a user sets up this step in their workflow. @@ -225,7 +225,7 @@ The second argument is the callback function, or the logic that will run when yo Field | Description ------|------------ -`client` | A `WebClient` instance used to make things happen in Slack. From sending messages to opening modals, `client` makes it all happen. For a full list of available methods, refer to the [Web API methods](/methods). Read more about the `WebClient` for Bolt Python [here](https://tools.slack.dev/bolt-python/concepts/web-api/). +`client` | A `WebClient` instance used to make things happen in Slack. From sending messages to opening modals, `client` makes it all happen. For a full list of available methods, refer to the [Web API methods](/reference/methods). Read more about the `WebClient` for Bolt Python [here](https://docs.slack.dev/tools/bolt-python/concepts/web-api/). `complete` | A utility method that invokes `functions.completeSuccess`. This method indicates to Slack that a step has completed successfully without issue. When called, `complete` requires you include an `outputs` object that matches your step definition in [`output_parameters`](#inputs-outputs). `fail` | A utility method that invokes `functions.completeError`. True to its name, this method signals to Slack that a step has failed to complete. The `fail` method requires an argument of `error` to be sent along with it, which is used to help users understand what went wrong. `inputs` | An alias for the `input_parameters` that were provided to the step upon execution. @@ -255,7 +255,7 @@ When you're ready to deploy your steps for wider use, you'll need to decide *whe ### Control step access {#access} -You can choose who has access to your custom steps. To define this, refer to the [custom function access](/automation/functions/access) page. +You can choose who has access to your custom steps. To define this, refer to the [custom function access](/tools/deno-slack-sdk/guides/controlling-access-to-custom-functions) page. ### Distribution {#distribution} @@ -268,5 +268,5 @@ Apps containing custom steps cannot be distributed publicly or submitted to the ## Related tutorials {#tutorials} -* [Custom steps for Workflow Builder (new app)](/tutorial/custom-steps-WB-new) -* [Custom steps for Workflow Builder (existing app)](/tutorial/custom-steps-WB-existing) +* [Custom steps for Workflow Builder (new app)](/tools/bolt-python/tutorial/custom-steps-workflow-builder-new) +* [Custom steps for Workflow Builder (existing app)](/tools/bolt-python/tutorial/custom-steps-workflow-builder-existing/) \ No newline at end of file diff --git a/docs/static/img/tutorials/modals/base_link.gif b/docs/english/tutorial/modals/base_link.gif similarity index 100% rename from docs/static/img/tutorials/modals/base_link.gif rename to docs/english/tutorial/modals/base_link.gif diff --git a/docs/static/img/tutorials/modals/final_product.gif b/docs/english/tutorial/modals/final_product.gif similarity index 100% rename from docs/static/img/tutorials/modals/final_product.gif rename to docs/english/tutorial/modals/final_product.gif diff --git a/docs/static/img/tutorials/modals/heart_icon.gif b/docs/english/tutorial/modals/heart_icon.gif similarity index 100% rename from docs/static/img/tutorials/modals/heart_icon.gif rename to docs/english/tutorial/modals/heart_icon.gif diff --git a/docs/static/img/tutorials/modals/interactivity_url.png b/docs/english/tutorial/modals/interactivity_url.png similarity index 100% rename from docs/static/img/tutorials/modals/interactivity_url.png rename to docs/english/tutorial/modals/interactivity_url.png diff --git a/docs/content/tutorial/modals.md b/docs/english/tutorial/modals/modals.md similarity index 83% rename from docs/content/tutorial/modals.md rename to docs/english/tutorial/modals/modals.md index b6d672d08..ee6d1e0d8 100644 --- a/docs/content/tutorial/modals.md +++ b/docs/english/tutorial/modals/modals.md @@ -1,4 +1,3 @@ - # Modals If you're learning about Slack apps, modals, or slash commands for the first time, you've come to the right place! In this tutorial, we'll take a look at setting up your very own server using GitHub Codespaces, then using that server to run your Slack app built with the [**Bolt for Python framework**](https://github.com/SlackAPI/bolt-python). @@ -13,9 +12,9 @@ At the end of this tutorial, your final app will look like this: ![announce](https://github.com/user-attachments/assets/0bf1c2f0-4b22-4c9c-98b3-b21e9bcc14a8) And will make use of these Slack concepts: -* [**Block Kit**](https://docs.slack.dev/block-kit/) is a UI framework for Slack apps that allows you to create beautiful, interactive messages within Slack. If you've ever seen a message in Slack with buttons or a select menu, that's Block Kit. -* [**Modals**](https://docs.slack.dev/surfaces/modals) are a pop-up window that displays right in Slack. They grab the attention of the user, and are normally used to prompt users to provide some kind of information or input in a form. -* [**Slash Commands**](https://docs.slack.dev/interactivity/implementing-slash-commands) allow you to invoke your app within Slack by just typing into the message composer box. e.g. `/remind`, `/topic`. +* [**Block Kit**](/block-kit/) is a UI framework for Slack apps that allows you to create beautiful, interactive messages within Slack. If you've ever seen a message in Slack with buttons or a select menu, that's Block Kit. +* [**Modals**](/surfaces/modals) are a pop-up window that displays right in Slack. They grab the attention of the user, and are normally used to prompt users to provide some kind of information or input in a form. +* [**Slash Commands**](/interactivity/implementing-slash-commands) allow you to invoke your app within Slack by just typing into the message composer box. e.g. `/remind`, `/topic`. If you're familiar with using Heroku you can also deploy directly to Heroku with the following button. @@ -66,7 +65,7 @@ You'll need to create an app and configure it properly within App Settings befor } ``` -2. Once your app has been created, scroll down to `App-Level Tokens` and create a token that requests for the [`connections:write`](https://docs.slack.dev/reference/scopes/connections.write) scope, which allows you to use [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode), a secure way to develop on Slack through the use of WebSockets. Copy the value of your app token and keep it for safe-keeping. +2. Once your app has been created, scroll down to `App-Level Tokens` and create a token that requests for the [`connections:write`](/reference/scopes/connections.write) scope, which allows you to use [Socket Mode](/apis/events-api/using-socket-mode), a secure way to develop on Slack through the use of WebSockets. Copy the value of your app token and keep it for safe-keeping. 3. Install your app by heading to `Install App` in the left sidebar. Hit `Allow`, which means you're agreeing to install your app with the permissions that it is requesting. Be sure to copy the token that you receive, and keep it somewhere secret and safe. @@ -132,4 +131,4 @@ All done! 🎉 You've created your first slash command using Block Kit and modal ## Next steps {#next-steps} -If you want to learn more about Bolt for Python, refer to the [Getting Started guide](https://tools.slack.dev/bolt-python/getting-started). \ No newline at end of file +If you want to learn more about Bolt for Python, refer to the [Getting Started guide](https://docs.slack.dev/tools/bolt-python/getting-started). \ No newline at end of file diff --git a/docs/static/img/tutorials/modals/slash_command.png b/docs/english/tutorial/modals/slash_command.png similarity index 100% rename from docs/static/img/tutorials/modals/slash_command.png rename to docs/english/tutorial/modals/slash_command.png diff --git a/docs/footerConfig.js b/docs/footerConfig.js deleted file mode 100644 index 6433c049d..000000000 --- a/docs/footerConfig.js +++ /dev/null @@ -1,21 +0,0 @@ -const footer = { - links: [ - { - items: [ - { - html: ` - -
    - ©2025 Slack Technologies, LLC, a Salesforce company. All rights reserved. Various trademarks held by their respective owners. -
    - `, - }, - ], - }, - ], -}; - -module.exports = footer; diff --git a/docs/i18n/ja-jp/README.md b/docs/i18n/ja-jp/README.md deleted file mode 100644 index e23cb969b..000000000 --- a/docs/i18n/ja-jp/README.md +++ /dev/null @@ -1,121 +0,0 @@ -# Bolt for Python Japanese documentation - -This README describes how the Japanese documentation is created. Please read the [/docs README](./docs/README) for information on _all_ the documentation. - -[Docusaurus](https://docusaurus.io) supports using different languages. Each language is a different version of the same site. The English site is the default. The English page will be viewable if the page is not translated into Japanese. - -There will be English pages on the Japanese site for any non-translated pages. Japanese readers will not miss any content, but they may be confused seeing English and Japanese mixed together. Please give us your thoughts on this setup. - -Because of this, the sidebar does not need to be updated for the Japanese documentation. It's always the same as the English documentation! - -## Testing the Japanese site. - -Please read the [/docs README](./docs/README.md) for instructions. Be sure to run the site in Japanese: - -``` -npm run start -- --locale ja-jp -``` - ---- - -## Japanese documentation files - -``` -docs/ -├── content/ -│ ├── getting-started.md -│ └── concepts -│ └── sending-message.md -├── i18n/ja-jp -│ ├── code.json -│ ├── docusaurus-theme-classic/ -│ │ ├── footer.json -│ │ └── navbar.json -│ └── docusaurus-plugin-content-docs/ -│ └── current/ -│ ├── getting-started.md -│ └── concepts -│ └── sending-message.md -``` - -The Japanese documentation is in `i18n/ja-jp/`. The folder contains `docusaurus-plugin-content-docs`, `docusaurus-theme-classic`, and `code.json`. - -### `docusaurus-plugin-content-docs` - -``` -docs/ -├── content/ (English pages) -│ ├── example-page.md -│ ├── getting-started.md -│ └── concepts -│ └── sending-message.md -├── i18n/ja-jp -│ └── docusaurus-plugin-content-docs/ -│ └── current/ (Japanese pages) -│ ├── getting-started.md -│ └── concepts -│ └── sending-message.md -``` - -If the file is not in `i18n/ja-jp/docusaurus-plugin-content-docs/current/`, then the English file will be used. In the example above, `example-page.md` is not in `i18n/ja-jp/docusaurus-plugin-content-docs/current/`. Therefore, the English version of `example-page.md` will appear on the Japanese site. - -The Japanese page file formats in `i18n/ja-jp/docusaurus-plugin-content-docs/current/` must be the same as the English page files in `docs/content/`. Please keep the file names in English (example: `sending-message.md`). - -Please provide a title in Japanese. It will show up in the sidebar. There are two options: - -``` ---- -title: こんにちは ---- - -# こんにちは - -``` - -[Read the Docusaurus documentation for info on writing pages in markdown](https://docusaurus.io/docs/markdown-features). - -### `docusaurus-theme-classic` - -``` -└── i18n/ja-jp - └── docusaurus-theme-classic/ - ├── footer.json - └── navbar.json -``` - -`docusaurus-theme-classic` You can translate site components (footer and navbar) for the Japanese site. Each JSON object has a `messages` and `description` value: - * `message` - The Japanese translation. It will be in English if not translated. - * `description` - What and where the message is. This stays in English. - -For example: - -``` -{ - "item.label.Hello": { - "message": "こんにちは", - "description": "The title of the page" - } -} -``` - -The JSON files are created with the `npm run write-translations -- --locale ja-jp` command. [Please read the Docusaurus documentation](https://docusaurus.io/docs/i18n/tutorial#translate-your-react-code) for more info. - -### `code.json` - -``` -└── i18n/ja-jp - └── code.json -``` - -The `code.json` file is similar to `docusaurus-theme-classic` JSON objects. `code.json` has translations provided by Docusaurus for site elements. - -For example: - -``` - "theme.CodeBlock.copy": { - "message": "コピー", - "description": "The copy button label on code blocks" - }, -``` - -Be careful changing `code.json`. If you change something in this repo, it will likely need to be changed in the other tools.slack.dev repos too, like the Bolt-Python repo. We want these translations to match for all tools.slack.dev sites. \ No newline at end of file diff --git a/docs/i18n/ja-jp/code.json b/docs/i18n/ja-jp/code.json deleted file mode 100644 index 2b3c80254..000000000 --- a/docs/i18n/ja-jp/code.json +++ /dev/null @@ -1,321 +0,0 @@ -{ - "theme.NotFound.title": { - "message": "ページが見つかりません", - "description": "The title of the 404 page" - }, - "theme.NotFound.p1": { - "message": "お探しのページが見つかりませんでした", - "description": "The first paragraph of the 404 page" - }, - "theme.NotFound.p2": { - "message": "このページにリンクしているサイトの所有者にリンクが壊れていることを伝えてください", - "description": "The 2nd paragraph of the 404 page" - }, - "theme.ErrorPageContent.title": { - "message": "エラーが発生しました", - "description": "The title of the fallback page when the page crashed" - }, - "theme.BackToTopButton.buttonAriaLabel": { - "message": "先頭へ戻る", - "description": "The ARIA label for the back to top button" - }, - "theme.blog.archive.title": { - "message": "アーカイブ", - "description": "The page & hero title of the blog archive page" - }, - "theme.blog.archive.description": { - "message": "アーカイブ", - "description": "The page & hero description of the blog archive page" - }, - "theme.blog.paginator.navAriaLabel": { - "message": "ブログ記事一覧のナビゲーション", - "description": "The ARIA label for the blog pagination" - }, - "theme.blog.paginator.newerEntries": { - "message": "新しい記事", - "description": "The label used to navigate to the newer blog posts page (previous page)" - }, - "theme.blog.paginator.olderEntries": { - "message": "過去の記事", - "description": "The label used to navigate to the older blog posts page (next page)" - }, - "theme.blog.post.paginator.navAriaLabel": { - "message": "ブログ記事のナビゲーション", - "description": "The ARIA label for the blog posts pagination" - }, - "theme.blog.post.paginator.newerPost": { - "message": "新しい記事", - "description": "The blog post button label to navigate to the newer/previous post" - }, - "theme.blog.post.paginator.olderPost": { - "message": "過去の記事", - "description": "The blog post button label to navigate to the older/next post" - }, - "theme.blog.post.plurals": { - "message": "{count}件", - "description": "Pluralized label for \"{count} posts\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)" - }, - "theme.blog.tagTitle": { - "message": "「{tagName}」タグの記事が{nPosts}件あります", - "description": "The title of the page for a blog tag" - }, - "theme.tags.tagsPageLink": { - "message": "全てのタグを見る", - "description": "The label of the link targeting the tag list page" - }, - "theme.colorToggle.ariaLabel": { - "message": "ダークモードを切り替える(現在は{mode})", - "description": "The ARIA label for the navbar color mode toggle" - }, - "theme.colorToggle.ariaLabel.mode.dark": { - "message": "ダークモード", - "description": "The name for the dark color mode" - }, - "theme.colorToggle.ariaLabel.mode.light": { - "message": "ライトモード", - "description": "The name for the light color mode" - }, - "theme.docs.breadcrumbs.navAriaLabel": { - "message": "パンくずリストのナビゲーション", - "description": "The ARIA label for the breadcrumbs" - }, - "theme.docs.DocCard.categoryDescription.plurals": { - "message": "{count}項目", - "description": "The default description for a category card in the generated index about how many items this category includes" - }, - "theme.docs.paginator.navAriaLabel": { - "message": "ドキュメントページ", - "description": "The ARIA label for the docs pagination" - }, - "theme.docs.paginator.previous": { - "message": "前へ", - "description": "The label used to navigate to the previous doc" - }, - "theme.docs.paginator.next": { - "message": "次へ", - "description": "The label used to navigate to the next doc" - }, - "theme.docs.tagDocListPageTitle.nDocsTagged": { - "message": "{count}記事", - "description": "Pluralized label for \"{count} docs tagged\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)" - }, - "theme.docs.tagDocListPageTitle": { - "message": "「{tagName}」タグのついた{nDocsTagged}", - "description": "The title of the page for a docs tag" - }, - "theme.docs.versionBadge.label": { - "message": "バージョン: {versionLabel}" - }, - "theme.docs.versions.unreleasedVersionLabel": { - "message": "これはリリース前のバージョン{versionLabel}の{siteTitle}のドキュメントです。", - "description": "The label used to tell the user that he's browsing an unreleased doc version" - }, - "theme.docs.versions.unmaintainedVersionLabel": { - "message": "これはバージョン{versionLabel}の{siteTitle}のドキュメントで現在はメンテナンスされていません", - "description": "The label used to tell the user that he's browsing an unmaintained doc version" - }, - "theme.docs.versions.latestVersionSuggestionLabel": { - "message": "最新のドキュメントは{latestVersionLink} ({versionLabel}) を見てください", - "description": "The label used to tell the user to check the latest version" - }, - "theme.docs.versions.latestVersionLinkLabel": { - "message": "最新バージョン", - "description": "The label used for the latest version suggestion link label" - }, - "theme.common.editThisPage": { - "message": "このページを編集", - "description": "The link label to edit the current page" - }, - "theme.lastUpdated.atDate": { - "message": "{date}に", - "description": "The words used to describe on which date a page has been last updated" - }, - "theme.lastUpdated.byUser": { - "message": "{user}が", - "description": "The words used to describe by who the page has been last updated" - }, - "theme.lastUpdated.lastUpdatedAtBy": { - "message": "{atDate}{byUser}最終更新", - "description": "The sentence used to display when a page has been last updated, and by who" - }, - "theme.common.headingLinkTitle": { - "message": "{heading} への直接リンク", - "description": "Title for link to heading" - }, - "theme.navbar.mobileVersionsDropdown.label": { - "message": "他のバージョン", - "description": "The label for the navbar versions dropdown on mobile view" - }, - "theme.tags.tagsListLabel": { - "message": "タグ:", - "description": "The label alongside a tag list" - }, - "theme.admonition.caution": { - "message": "注意", - "description": "The default label used for the Caution admonition (:::caution)" - }, - "theme.admonition.danger": { - "message": "危険", - "description": "The default label used for the Danger admonition (:::danger)" - }, - "theme.admonition.info": { - "message": "備考", - "description": "The default label used for the Info admonition (:::info)" - }, - "theme.admonition.note": { - "message": "注記", - "description": "The default label used for the Note admonition (:::note)" - }, - "theme.admonition.tip": { - "message": "ヒント", - "description": "The default label used for the Tip admonition (:::tip)" - }, - "theme.admonition.warning": { - "message": "警告", - "description": "The default label used for the Warning admonition (:::warning)" - }, - "theme.AnnouncementBar.closeButtonAriaLabel": { - "message": "閉じる", - "description": "The ARIA label for close button of announcement bar" - }, - "theme.blog.sidebar.navAriaLabel": { - "message": "最近のブログ記事のナビゲーション", - "description": "The ARIA label for recent posts in the blog sidebar" - }, - "theme.CodeBlock.copied": { - "message": "コピーしました", - "description": "The copied button label on code blocks" - }, - "theme.CodeBlock.copyButtonAriaLabel": { - "message": "クリップボードにコードをコピー", - "description": "The ARIA label for copy code blocks button" - }, - "theme.CodeBlock.copy": { - "message": "コピー", - "description": "The copy button label on code blocks" - }, - "theme.CodeBlock.wordWrapToggle": { - "message": "折り返し", - "description": "The title attribute for toggle word wrapping button of code block lines" - }, - "theme.DocSidebarItem.expandCategoryAriaLabel": { - "message": "'{label}'の目次を開く", - "description": "The ARIA label to expand the sidebar category" - }, - "theme.DocSidebarItem.collapseCategoryAriaLabel": { - "message": "'{label}'の目次を隠す", - "description": "The ARIA label to collapse the sidebar category" - }, - "theme.NavBar.navAriaLabel": { - "message": "ナビゲーション", - "description": "The ARIA label for the main navigation" - }, - "theme.navbar.mobileLanguageDropdown.label": { - "message": "他の言語", - "description": "The label for the mobile language switcher dropdown" - }, - "theme.TOCCollapsible.toggleButtonLabel": { - "message": "このページの見出し", - "description": "The label used by the button on the collapsible TOC component" - }, - "theme.blog.post.readMore": { - "message": "もっと見る", - "description": "The label used in blog post item excerpts to link to full blog posts" - }, - "theme.blog.post.readMoreLabel": { - "message": "{title}についてもっと見る", - "description": "The ARIA label for the link to full blog posts from excerpts" - }, - "theme.blog.post.readingTime.plurals": { - "message": "約{readingTime}分", - "description": "Pluralized label for \"{readingTime} min read\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)" - }, - "theme.docs.breadcrumbs.home": { - "message": "ホームページ", - "description": "The ARIA label for the home page in the breadcrumbs" - }, - "theme.docs.sidebar.collapseButtonTitle": { - "message": "サイドバーを隠す", - "description": "The title attribute for collapse button of doc sidebar" - }, - "theme.docs.sidebar.collapseButtonAriaLabel": { - "message": "サイドバーを隠す", - "description": "The title attribute for collapse button of doc sidebar" - }, - "theme.docs.sidebar.navAriaLabel": { - "message": "ドキュメントのサイドバー", - "description": "The ARIA label for the sidebar navigation" - }, - "theme.docs.sidebar.closeSidebarButtonAriaLabel": { - "message": "ナビゲーションバーを閉じる", - "description": "The ARIA label for close button of mobile sidebar" - }, - "theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": { - "message": "← メインメニューに戻る", - "description": "The label of the back button to return to main menu, inside the mobile navbar sidebar secondary menu (notably used to display the docs sidebar)" - }, - "theme.docs.sidebar.toggleSidebarButtonAriaLabel": { - "message": "ナビゲーションバーを開く", - "description": "The ARIA label for hamburger menu button of mobile navigation" - }, - "theme.docs.sidebar.expandButtonTitle": { - "message": "サイドバーを開く", - "description": "The ARIA label and title attribute for expand button of doc sidebar" - }, - "theme.docs.sidebar.expandButtonAriaLabel": { - "message": "サイドバーを開く", - "description": "The ARIA label and title attribute for expand button of doc sidebar" - }, - "theme.ErrorPageContent.tryAgain": { - "message": "もう一度試してください", - "description": "The label of the button to try again rendering when the React error boundary captures an error" - }, - "theme.common.skipToMainContent": { - "message": "メインコンテンツまでスキップ", - "description": "The skip to content label used for accessibility, allowing to rapidly navigate to main content with keyboard tab/enter navigation" - }, - "theme.tags.tagsPageTitle": { - "message": "タグ", - "description": "The title of the tag list page" - }, - "theme.unlistedContent.title": { - "message": "非公開のページ", - "description": "The unlisted content banner title" - }, - "theme.unlistedContent.message": { - "message": "このページは非公開です。 検索対象外となり、このページのリンクに直接アクセスできるユーザーのみに公開されます。", - "description": "The unlisted content banner message" - }, - "theme.blog.author.pageTitle": { - "message": "{authorName} - {nPosts}", - "description": "The title of the page for a blog author" - }, - "theme.blog.authorsList.pageTitle": { - "message": "著者一覧", - "description": "The title of the authors page" - }, - "theme.blog.authorsList.viewAll": { - "message": "すべての著者を見る", - "description": "The label of the link targeting the blog authors page" - }, - "theme.blog.author.noPosts": { - "message": "この著者による投稿はまだありません。", - "description": "The text for authors with 0 blog post" - }, - "theme.contentVisibility.unlistedBanner.title": { - "message": "非公開のページ", - "description": "The unlisted content banner title" - }, - "theme.contentVisibility.unlistedBanner.message": { - "message": "このページは非公開です。 検索対象外となり、このページのリンクに直接アクセスできるユーザーのみに公開されます。", - "description": "The unlisted content banner message" - }, - "theme.contentVisibility.draftBanner.title": { - "message": "下書きのページ", - "description": "The draft content banner title" - }, - "theme.contentVisibility.draftBanner.message": { - "message": "このページは下書きです。開発環境でのみ表示され、本番環境のビルドには含まれません。", - "description": "The draft content banner message" - } -} diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current.json b/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current.json deleted file mode 100644 index eb3b5be26..000000000 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "version.label": { - "message": "Next", - "description": "The label for version current" - }, - "sidebar.sidebarBoltPy.category.Basic concepts": { - "message": "基本的な概念", - "description": "The label for category Basic concepts in sidebar sidebarBoltPy" - }, - "sidebar.sidebarBoltPy.category.Advanced concepts": { - "message": "応用コンセプト", - "description": "The label for category Advanced concepts in sidebar sidebarBoltPy" - }, - "sidebar.sidebarBoltPy.category.steps from apps (Deprecated)": { - "message": "ワークフローステップ 非推奨", - "description": "The label for category steps from apps (Deprecated) in sidebar sidebarBoltPy" - }, - "sidebar.sidebarBoltPy.category.Tutorials": { - "message": "チュートリアル", - "description": "The label for category Tutorials in sidebar sidebarBoltPy" - }, - "sidebar.sidebarBoltPy.link.Code on GitHub": { - "message": "Code on GitHub", - "description": "The label for link Code on GitHub in sidebar sidebarBoltPy, linking to https://github.com/SlackAPI/bolt-python" - }, - "sidebar.sidebarBoltPy.link.Contributors Guide": { - "message": "貢献", - "description": "The label for link Contributors Guide in sidebar sidebarBoltPy, linking to https://github.com/SlackAPI/bolt-python/blob/main/.github/contributing.md" - }, - "sidebar.sidebarBoltPy.category.Guides": { - "message": "ガイド", - "description": "The label for category Guides in sidebar sidebarBoltPy" - }, - "sidebar.sidebarBoltPy.category.Slack API calls": { - "message": "Slack API コール", - "description": "The label for category Slack API calls in sidebar sidebarBoltPy" - }, - "sidebar.sidebarBoltPy.category.Events": { - "message": "イベント API", - "description": "The label for category Events in sidebar sidebarBoltPy" - }, - "sidebar.sidebarBoltPy.category.App UI & Interactivity": { - "message": "インタラクティビティ & ショートカット", - "description": "The label for category App UI & Interactivity in sidebar sidebarBoltPy" - }, - "sidebar.sidebarBoltPy.category.App Configuration": { - "message": "App の設定", - "description": "The label for category App Configuration in sidebar sidebarBoltPy" - }, - "sidebar.sidebarBoltPy.category.Middleware & Context": { - "message": "ミドルウェア & コンテキスト", - "description": "The label for category Middleware & Context in sidebar sidebarBoltPy" - }, - "sidebar.sidebarBoltPy.category.Adaptors": { - "message": "アダプター", - "description": "The label for category Adaptors in sidebar sidebarBoltPy" - }, - "sidebar.sidebarBoltPy.category.Authorization & Security": { - "message": "認可 & セキュリティ", - "description": "The label for category Authorization & Security in sidebar sidebarBoltPy" - }, - "sidebar.sidebarBoltPy.category.Legacy": { - "message": "レガシー(非推奨)", - "description": "The label for category Legacy in sidebar sidebarBoltPy" - }, - "sidebar.sidebarBoltPy.link.Reference": { - "message": "リファレンス", - "description": "The label for link Reference in sidebar sidebarBoltPy, linking to https://tools.slack.dev/bolt-python/api-docs/slack_bolt/" - }, - "sidebar.sidebarBoltPy.link.Release notes": { - "message": "リリースノート", - "description": "The label for link Release notes in sidebar sidebarBoltPy, linking to https://github.com/slackapi/bolt-python/releases" - }, - "sidebar.sidebarBoltPy.doc.Bolt for Python": { - "message": "Bolt for Python", - "description": "The label for the doc item Bolt for Python in sidebar sidebarBoltPy, linking to the doc index" - } -} diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/app-home.md b/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/app-home.md deleted file mode 100644 index 2dc5fd6c0..000000000 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/app-home.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: ホームタブの更新 -lang: ja-jp -slug: /concepts/app-home ---- - -ホームタブは、サイドバーや検索画面からアクセス可能なサーフェスエリアです。アプリはこのエリアを使ってユーザーごとのビューを表示することができます。アプリ設定ページで App Home の機能を有効にすると、`views.publish` API メソッドの呼び出しで `user_id` と[ビューのペイロード](https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission)を指定して、ホームタブを公開・更新することができるようになります。 - -`app_home_opened` イベントをサブスクライブすると、ユーザーが App Home を開く操作をリッスンできます。 - -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 -```python -@app.event("app_home_opened") -def update_home_tab(client, event, logger): - try: - # 組み込みのクライアントを使って views.publish を呼び出す - client.views_publish( - # イベントに関連づけられたユーザー ID を使用 - user_id=event["user"], - # アプリの設定で予めホームタブが有効になっている必要がある - view={ - "type": "home", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*Welcome home, <@" + event["user"] + "> :house:*" - } - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text":"Learn how home tabs can be more useful and interactive ." - } - } - ] - } - ) - except Exception as e: - logger.error(f"Error publishing home tab: {e}") -``` \ No newline at end of file diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/web-api.md b/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/web-api.md deleted file mode 100644 index 75953b5bd..000000000 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/web-api.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: Web API の使い方 -lang: ja-jp -slug: /concepts/web-api ---- - -`app.client`、またはミドルウェア・リスナーの引数 `client` として Bolt アプリに提供されている [`WebClient`](https://tools.slack.dev/python-slack-sdk/basic_usage.html) は必要な権限を付与されており、これを利用することで[あらゆる Web API メソッド](https://docs.slack.dev/reference/methods)を呼び出すことができます。このクライアントのメソッドを呼び出すと `SlackResponse` という Slack からの応答情報を含むオブジェクトが返されます。 - -Bolt の初期化に使用するトークンは `context` オブジェクトに設定されます。このトークンは、多くの Web API メソッドを呼び出す際に必要となります。 - -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 -```python -@app.message("wake me up") -def say_hello(client, message): - # 2020 年 9 月 30 日午後 11:59:59 を示す Unix エポック秒 - when_september_ends = 1601510399 - channel_id = message["channel"] - client.chat_scheduleMessage( - channel=channel_id, - post_at=when_september_ends, - text="Summer has come and passed" - ) -``` \ No newline at end of file diff --git a/docs/i18n/ja-jp/docusaurus-theme-classic/footer.json b/docs/i18n/ja-jp/docusaurus-theme-classic/footer.json deleted file mode 100644 index 5a2d1bc10..000000000 --- a/docs/i18n/ja-jp/docusaurus-theme-classic/footer.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "copyright": { - "message": "

    Made with ♡ by Slack and friends

    ", - "description": "The footer copyright" - } -} diff --git a/docs/i18n/ja-jp/docusaurus-theme-classic/navbar.json b/docs/i18n/ja-jp/docusaurus-theme-classic/navbar.json deleted file mode 100644 index 3eee009ee..000000000 --- a/docs/i18n/ja-jp/docusaurus-theme-classic/navbar.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "title": { - "message": "Slack Developer Tools", - "description": "The title in the navbar" - }, - "item.label.SDKs": { - "message": "SDKs", - "description": "Navbar item with label SDKs" - }, - "item.label.Java": { - "message": "Java", - "description": "Navbar item with label Java" - }, - "item.label.JavaScript": { - "message": "JavaScript", - "description": "Navbar item with label JavaScript" - }, - "item.label.Python": { - "message": "Python", - "description": "Navbar item with label Python" - }, - "item.label.Community": { - "message": "Community", - "description": "Navbar item with label Community" - }, - "item.label.Bolt": { - "message": "Bolt", - "description": "Navbar item with label Bolt" - }, - "item.label.API Docs": { - "message": "API Docs", - "description": "Navbar item with label API Docs" - }, - "item.label.Java Slack SDK": { - "message": "Java Slack SDK", - "description": "Navbar item with label Java Slack SDK" - }, - "item.label.Node Slack SDK": { - "message": "Node Slack SDK", - "description": "Navbar item with label Node Slack SDK" - }, - "item.label.Python Slack SDK": { - "message": "Python Slack SDK", - "description": "Navbar item with label Python Slack SDK" - }, - "item.label.Deno Slack SDK": { - "message": "Deno Slack SDK", - "description": "Navbar item with label Deno Slack SDK" - }, - "item.label.Community tools": { - "message": "Community tools", - "description": "Navbar item with label Community tools" - }, - "item.label.Slack Community": { - "message": "Slack Community", - "description": "Navbar item with label Slack Community" - }, - "item.label.Slack CLI": { - "message": "Slack CLI", - "description": "Navbar item with label Slack CLI" - } -} diff --git a/docs/static/img/boltpy/basic-information-page.png b/docs/img/basic-information-page.png similarity index 100% rename from docs/static/img/boltpy/basic-information-page.png rename to docs/img/basic-information-page.png diff --git a/docs/static/img/boltpy/bot-token.png b/docs/img/bot-token.png similarity index 100% rename from docs/static/img/boltpy/bot-token.png rename to docs/img/bot-token.png diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/acknowledge.md b/docs/japanese/concepts/acknowledge.md similarity index 69% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/acknowledge.md rename to docs/japanese/concepts/acknowledge.md index 72cc39257..2b3756009 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/acknowledge.md +++ b/docs/japanese/concepts/acknowledge.md @@ -1,18 +1,14 @@ ---- -title: リクエストの確認 -lang: ja-jp -slug: /concepts/acknowledge ---- +# リクエストの確認 アクション(action)、コマンド(command)、ショートカット(shortcut)、オプション(options)、およびモーダルからのデータ送信(view_submission)の各リクエストは、**必ず** `ack()` 関数を使って確認を行う必要があります。これによってリクエストが受信されたことが Slack に認識され、Slack のユーザーインターフェイスが適切に更新されます。 -リクエストの種類によっては、確認で通知方法が異なる場合があります。例えば、外部データソースを使用する選択メニューのオプションのリクエストに対する確認では、適切な[オプション](https://docs.slack.dev/reference/block-kit/composition-objects/option-object)のリストとともに `ack()` を呼び出します。モーダルからのデータ送信に対する確認では、 `response_action` を渡すことで[モーダルの更新](/concepts/view_submissions)などを行えます。 +リクエストの種類によっては、確認で通知方法が異なる場合があります。例えば、外部データソースを使用する選択メニューのオプションのリクエストに対する確認では、適切な[オプション](/reference/block-kit/composition-objects/option-object)のリストとともに `ack()` を呼び出します。モーダルからのデータ送信に対する確認では、 `response_action` を渡すことで[モーダルの更新](/tools/bolt-python/concepts/view-submissions)などを行えます。 確認までの猶予は 3 秒しかないため、新しいメッセージの送信やデータベースからの情報の取得といった時間のかかる処理は、`ack()` を呼び出した後で行うことをおすすめします。 - FaaS / serverless 環境を使う場合、 `ack()` するタイミングが異なります。 これに関する詳細は [Lazy listeners (FaaS)](/concepts/lazy-listeners) を参照してください。 + FaaS / serverless 環境を使う場合、 `ack()` するタイミングが異なります。 これに関する詳細は [Lazy listeners (FaaS)](/tools/bolt-python/concepts/lazy-listeners) を参照してください。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # 外部データを使用する選択メニューオプションに応答するサンプル @app.options("menu_selection") diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/actions.md b/docs/japanese/concepts/actions.md similarity index 76% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/actions.md rename to docs/japanese/concepts/actions.md index 82d243e99..60019ebb7 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/actions.md +++ b/docs/japanese/concepts/actions.md @@ -1,8 +1,4 @@ ---- -title: アクション -lang: ja-jp -slug: /concepts/actions ---- +# アクション ## アクションのリスニング @@ -10,9 +6,9 @@ Bolt アプリは `action` メソッドを用いて、ボタンのクリック アクションは `str` 型または `re.Pattern` 型の `action_id` でフィルタリングできます。`action_id` は、Slack プラットフォーム上のインタラクティブコンポーネントを区別する一意の識別子として機能します。 -`action()` を使ったすべての例で `ack()` が使用されていることに注目してください。アクションのリスナー内では、Slack からのリクエストを受信したことを確認するために、`ack()` 関数を呼び出す必要があります。これについては、[リクエストの確認](/concepts/acknowledge)セクションで説明しています。 +`action()` を使ったすべての例で `ack()` が使用されていることに注目してください。アクションのリスナー内では、Slack からのリクエストを受信したことを確認するために、`ack()` 関数を呼び出す必要があります。これについては、[リクエストの確認](/tools/bolt-python/concepts/acknowledge)セクションで説明しています。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # 'approve_button' という action_id のブロックエレメントがトリガーされるたびに、このリスナーが呼び出させれる @app.action("approve_button") @@ -49,7 +45,7 @@ def update_message(ack, body, client): 2 つ目は、`respond()` を使用する方法です。これは、アクションに関連づけられた `response_url` を使ったメッセージ送信を行うためのユーティリティです。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # 'approve_button' という action_id のインタラクティブコンポーネントがトリガーされると、このリスナーが呼ばれる @app.action("approve_button") @@ -61,7 +57,7 @@ def approve_request(ack, say): ### respond() の利用 -`respond()` は `response_url` を使って送信するときに便利なメソッドで、これらと同じような動作をします。投稿するメッセージのペイロードには、全ての[メッセージペイロードのプロパティ](https://docs.slack.dev/messaging/#payloads)とオプションのプロパティとして `response_type`(値は `"in_channel"` または `"ephemeral"`)、`replace_original`、`delete_original`、`unfurl_links`、`unfurl_media` などを指定できます。こうすることによってアプリから送信されるメッセージは、やり取りの発生元に反映されます。 +`respond()` は `response_url` を使って送信するときに便利なメソッドで、これらと同じような動作をします。投稿するメッセージのペイロードには、全ての[メッセージペイロードのプロパティ](/messaging/#payloads)とオプションのプロパティとして `response_type`(値は `"in_channel"` または `"ephemeral"`)、`replace_original`、`delete_original`、`unfurl_links`、`unfurl_media` などを指定できます。こうすることによってアプリから送信されるメッセージは、やり取りの発生元に反映されます。 ```python # 'user_select' という action_id を持つアクションのトリガーをリッスン diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/adapters.md b/docs/japanese/concepts/adapters.md similarity index 97% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/adapters.md rename to docs/japanese/concepts/adapters.md index 78c94fca4..a58ed34a2 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/adapters.md +++ b/docs/japanese/concepts/adapters.md @@ -1,8 +1,4 @@ ---- -title: アダプター -lang: ja-jp -slug: /concepts/adapters ---- +# アダプター アダプターは Slack から届く受信リクエストの受付とパーズを担当し、それらのリクエストを `BoltRequest` の形式に変換して Bolt アプリに引き渡します。 diff --git a/docs/japanese/concepts/app-home.md b/docs/japanese/concepts/app-home.md new file mode 100644 index 000000000..d950221ad --- /dev/null +++ b/docs/japanese/concepts/app-home.md @@ -0,0 +1,40 @@ +# ホームタブの更新 + +[ホームタブ](/surfaces/app-home)は、サイドバーや検索画面からアクセス可能なサーフェスエリアです。アプリはこのエリアを使ってユーザーごとのビューを表示することができます。アプリ設定ページで App Home の機能を有効にすると、[`views.publish`](/reference/methods/views.publish) API メソッドの呼び出しで `user_id` と[ビューのペイロード](/reference/interaction-payloads/view-interactions-payload/#view_submission)を指定して、ホームタブを公開・更新することができるようになります。 + +[`app_home_opened`](/reference/events/app_home_opened) イベントをサブスクライブすると、ユーザーが App Home を開く操作をリッスンできます。 + +指定可能な引数の一覧は [モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 + +```python +@app.event("app_home_opened") +def update_home_tab(client, event, logger): + try: + # 組み込みのクライアントを使って views.publish を呼び出す + client.views_publish( + # イベントに関連づけられたユーザー ID を使用 + user_id=event["user"], + # アプリの設定で予めホームタブが有効になっている必要がある + view={ + "type": "home", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Welcome home, <@" + event["user"] + "> :house:*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text":"Learn how home tabs can be more useful and interactive ." + } + } + ] + } + ) + except Exception as e: + logger.error(f"Error publishing home tab: {e}") +``` \ No newline at end of file diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/assistant.md b/docs/japanese/concepts/assistant.md similarity index 91% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/assistant.md rename to docs/japanese/concepts/assistant.md index 5ffcb6f10..e819f5361 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/assistant.md +++ b/docs/japanese/concepts/assistant.md @@ -1,12 +1,8 @@ ---- -title: エージェント・アシスタント -lang: en -slug: /concepts/assistant ---- +# エージェント・アシスタント -このページは、Bolt を使ってエージェント・アシスタントを実装するための方法を紹介します。この機能に関する一般的な情報については、[こちらのドキュメントページ(英語)](https://docs.slack.dev/ai/)を参照してください。 +このページは、Bolt を使ってエージェント・アシスタントを実装するための方法を紹介します。この機能に関する一般的な情報については、[こちらのドキュメントページ(英語)](/ai/)を参照してください。 -この機能を実装するためには、まず[アプリの設定画面](https://api.slack.com/apps)で **Agents & Assistants** 機能を有効にし、**OAuth & Permissions** のページで [`assistant:write`](https://docs.slack.dev/reference/scopes/assistant.write)、[chat:write](https://docs.slack.dev/reference/scopes/chat.write)、[`im:history`](https://docs.slack.dev/reference/scopes/im.history) を**ボットの**スコープに追加し、**Event Subscriptions** のページで [`assistant_thread_started`](https://docs.slack.dev/reference/events/assistant_thread_started)、[`assistant_thread_context_changed`](https://docs.slack.dev/reference/events/assistant_thread_context_changed)、[`message.im`](https://docs.slack.dev/reference/events/message.im) イベントを有効にしてください。 +この機能を実装するためには、まず[アプリの設定画面](https://api.slack.com/apps)で **Agents & Assistants** 機能を有効にし、**OAuth & Permissions** のページで [`assistant:write`](/reference/scopes/assistant.write)、[chat:write](/reference/scopes/chat.write)、[`im:history`](/reference/scopes/im.history) を**ボットの**スコープに追加し、**Event Subscriptions** のページで [`assistant_thread_started`](/reference/events/assistant_thread_started)、[`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed)、[`message.im`](/reference/events/message.im) イベントを有効にしてください。 また、この機能は Slack の有料プランでのみ利用可能です。もし開発用の有料プランのワークスペースをお持ちでない場合は、[Developer Program](https://api.slack.com/developer-program) に参加し、全ての有料プラン向け機能を利用可能なサンドボックス環境をつくることができます。 @@ -72,7 +68,7 @@ def respond_in_assistant_thread( app.use(assistant) ``` -リスナーに指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +リスナーに指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ユーザーがチャンネルの横でアシスタントスレッドを開いた場合、そのチャンネルの情報は、そのスレッドの `AssistantThreadContext` データとして保持され、 `get_thread_context` ユーティリティを使ってアクセスすることができます。Bolt がこのユーティリティを提供している理由は、後続のユーザーメッセージ投稿のイベントペイロードに最新のスレッドのコンテキスト情報は含まれないためです。そのため、アプリはコンテキスト情報が変更されたタイミングでそれを何らかの方法で保存し、後続のメッセージイベントのリスナーコードから参照できるようにする必要があります。 @@ -92,7 +88,7 @@ assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) ## アシスタントスレッドでの Block Kit インタラクション -より高度なユースケースでは、上のようなプロンプト例の提案ではなく Block Kit のボタンなどを使いたいという場合があるかもしれません。そして、後続の処理のために[構造化されたメッセージメタデータ](https://docs.slack.dev/messaging/message-metadata/)を含むメッセージを送信したいという場合もあるでしょう。 +より高度なユースケースでは、上のようなプロンプト例の提案ではなく Block Kit のボタンなどを使いたいという場合があるかもしれません。そして、後続の処理のために[構造化されたメッセージメタデータ](/messaging/message-metadata/)を含むメッセージを送信したいという場合もあるでしょう。 例えば、アプリが最初の返信で「参照しているチャンネルを要約」のようなボタンを表示し、ユーザーがそれをクリックして、より詳細な情報(例:要約するメッセージ数・日数、要約の目的など)を送信、アプリがそれを構造化されたメータデータに整理した上でリクエスト内容をボットのメッセージとして送信するようなシナリオです。 @@ -107,7 +103,7 @@ app = App( assistant = Assistant() -# リスナーに指定可能な引数の一覧は https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html を参照してください +# リスナーに指定可能な引数の一覧は https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html を参照してください @assistant.thread_started def start_assistant_thread(say: Say): diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/async.md b/docs/japanese/concepts/async.md similarity index 97% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/async.md rename to docs/japanese/concepts/async.md index 19609be89..cc38886d4 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/async.md +++ b/docs/japanese/concepts/async.md @@ -1,8 +1,4 @@ ---- -title: Async(asyncio)の使用 -lang: ja-jp -slug: /concepts/async ---- +# Async(asyncio)の使用 非同期バージョンの Bolt を使用する場合は、`App` の代わりに `AsyncApp` インスタンスをインポートして初期化します。`AsyncApp` では AIOHTTP を使って API リクエストを行うため、`aiohttp` をインストールする必要があります(`requirements.txt` に追記するか、`pip install aiohttp` を実行します)。 diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/authenticating-oauth.md b/docs/japanese/concepts/authenticating-oauth.md similarity index 89% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/authenticating-oauth.md rename to docs/japanese/concepts/authenticating-oauth.md index 82bbaf562..e58743f69 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/authenticating-oauth.md +++ b/docs/japanese/concepts/authenticating-oauth.md @@ -1,18 +1,14 @@ ---- -title: OAuth を使った認証 -lang: ja-jp -slug: /concepts/authenticating-oauth ---- +# OAuth を使った認証 -Slack アプリを複数のワークスペースにインストールできるようにするためには、OAuth フローを実装した上で、アクセストークンなどのインストールに関する情報をセキュアな方法で保存する必要があります。アプリを初期化する際に `client_id`、`client_secret`、`scopes`、`installation_store`、`state_store` を指定することで、OAuth のエンドポイントのルート情報や stateパラメーターの検証をBolt for Python にハンドリングさせることができます。カスタムのアダプターを実装する場合は、SDK が提供する組み込みの[OAuth ライブラリ](https://tools.slack.dev/python-slack-sdk/oauth/)を利用するのが便利です。これは Slack が開発したモジュールで、Bolt for Python 内部でも利用しています。 +Slack アプリを複数のワークスペースにインストールできるようにするためには、OAuth フローを実装した上で、アクセストークンなどのインストールに関する情報をセキュアな方法で保存する必要があります。アプリを初期化する際に `client_id`、`client_secret`、`scopes`、`installation_store`、`state_store` を指定することで、OAuth のエンドポイントのルート情報や stateパラメーターの検証をBolt for Python にハンドリングさせることができます。カスタムのアダプターを実装する場合は、SDK が提供する組み込みの[OAuth ライブラリ](/tools/python-slack-sdk/oauth/)を利用するのが便利です。これは Slack が開発したモジュールで、Bolt for Python 内部でも利用しています。 Bolt for Python によって `slack/oauth_redirect` という**リダイレクト URL** が生成されます。Slack はアプリのインストールフローを完了させたユーザーをこの URL にリダイレクトします。この**リダイレクト URL** は、アプリの設定の「**OAuth and Permissions**」であらかじめ追加しておく必要があります。この URL は、後ほど説明するように `OAuthSettings` というコンストラクタの引数で指定することもできます。 Bolt for Python は `slack/install` というルートも生成します。これはアプリを直接インストールするための「**Add to Slack**」ボタンを表示するために使われます。すでにワークスペースへのアプリのインストールが済んでいる場合に追加で各ユーザーのユーザートークンなどの情報を取得する場合や、カスタムのインストール用の URL を動的に生成したい場合などは、`oauth_settings` の `authorize_url_generator` でカスタムの URL ジェネレーターを指定することができます。 -バージョン 1.1.0 以降の Bolt for Python では、[OrG 全体へのインストール](https://docs.slack.dev/enterprise-grid/)がデフォルトでサポートされています。OrG 全体へのインストールは、アプリの設定の「**Org Level Apps**」で有効化できます。 +バージョン 1.1.0 以降の Bolt for Python では、[OrG 全体へのインストール](/enterprise-grid/)がデフォルトでサポートされています。OrG 全体へのインストールは、アプリの設定の「**Org Level Apps**」で有効化できます。 -Slack での OAuth を使ったインストールフローについて詳しくは、[API ドキュメントを参照してください](https://docs.slack.dev/authentication/installing-with-oauth)。 +Slack での OAuth を使ったインストールフローについて詳しくは、[API ドキュメントを参照してください](/authentication/installing-with-oauth)。 ```python import os diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/authorization.md b/docs/japanese/concepts/authorization.md similarity index 94% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/authorization.md rename to docs/japanese/concepts/authorization.md index 1a8797bb5..b6a14b30a 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/authorization.md +++ b/docs/japanese/concepts/authorization.md @@ -1,13 +1,9 @@ ---- -title: 認可(Authorization) -lang: ja-jp -slug: /concepts/authorization ---- +# 認可(Authorization) 認可(Authorization)は、Slack からの受信リクエストを処理するにあたって、どのようなSlack クレデンシャル (ボットトークンなど) を使用可能にするかを決定するプロセスです。 -単一のワークスペースにインストールされるアプリでは、`token` パラメーターを使って `App` のコンストラクターにボットトークンを渡すという、シンプルな方法が使えます。それに対して、複数のワークスペースにインストールされるアプリでは、次の 2 つの方法のいずれかを使用する必要があります。簡単なのは、組み込みの OAuth サポートを使用する方法です。OAuth サポートは、OAuth フロー用のURLのセットアップとstateの検証を行います。詳細は「[OAuth を使った認証](/concepts/authenticating-oauth)」セクションを参照してください。 +単一のワークスペースにインストールされるアプリでは、`token` パラメーターを使って `App` のコンストラクターにボットトークンを渡すという、シンプルな方法が使えます。それに対して、複数のワークスペースにインストールされるアプリでは、次の 2 つの方法のいずれかを使用する必要があります。簡単なのは、組み込みの OAuth サポートを使用する方法です。OAuth サポートは、OAuth フロー用のURLのセットアップとstateの検証を行います。詳細は「[OAuth を使った認証](/tools/bolt-python/concepts/authenticating-oauth)」セクションを参照してください。 よりカスタマイズできる方法として、`App` をインスタンス化する関数に`authorize` パラメーターを指定する方法があります。`authorize` 関数から返される [`AuthorizeResult` のインスタンス](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/authorization/authorize_result.py)には、どのユーザーがどこで発生させたリクエストかを示す情報が含まれます。 diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/commands.md b/docs/japanese/concepts/commands.md similarity index 72% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/commands.md rename to docs/japanese/concepts/commands.md index 73d262446..c89568dbe 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/commands.md +++ b/docs/japanese/concepts/commands.md @@ -1,18 +1,14 @@ ---- -title: コマンドのリスニングと応答 -lang: ja-jp -slug: /concepts/commands ---- +# コマンドのリスニングと応答 スラッシュコマンドが実行されたリクエストをリッスンするには、`command()` メソッドを使用します。このメソッドでは `str` 型の `command_name` の指定が必要です。 コマンドリクエストをアプリが受信し確認したことを Slack に通知するため、`ack()` を呼び出す必要があります。 -スラッシュコマンドに応答する方法は 2 つあります。1 つ目は `say()` を使う方法で、文字列または JSON のペイロードを渡すことができます。2 つ目は `respond()` を使う方法です。これは `response_url` がある場合に活躍します。これらの方法は[アクションへの応答](/concepts/action-respond)セクションで詳しく説明しています。 +スラッシュコマンドに応答する方法は 2 つあります。1 つ目は `say()` を使う方法で、文字列または JSON のペイロードを渡すことができます。2 つ目は `respond()` を使う方法です。これは `response_url` がある場合に活躍します。これらの方法は[アクションへの応答](/tools/bolt-python/concepts/actions)セクションで詳しく説明しています。 アプリの設定でコマンドを登録するときは、リクエスト URL の末尾に `/slack/events` をつけます。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # echoコマンドは受け取ったコマンドをそのまま返す @app.command("/echo") diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/context.md b/docs/japanese/concepts/context.md similarity index 96% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/context.md rename to docs/japanese/concepts/context.md index 6f4dd8257..13a287728 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/context.md +++ b/docs/japanese/concepts/context.md @@ -1,8 +1,4 @@ ---- -title: コンテキストの追加 -lang: ja-jp -slug: /concepts/context ---- +# コンテキストの追加 すべてのリスナーは `context` ディクショナリにアクセスできます。リスナーはこれを使ってリクエストの付加情報を得ることができます。受信リクエストに含まれる `user_id`、`team_id`、`channel_id`、`enterprise_id` などの情報は、Bolt によって自動的に設定されます。 diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/custom-adapters.md b/docs/japanese/concepts/custom-adapters.md similarity index 90% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/custom-adapters.md rename to docs/japanese/concepts/custom-adapters.md index b72d48ded..584893511 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/custom-adapters.md +++ b/docs/japanese/concepts/custom-adapters.md @@ -1,10 +1,6 @@ ---- -title: カスタムのアダプター -lang: ja-jp -slug: /concepts/custom-adapters ---- +# カスタムのアダプター -[アダプター](/concepts/adapters)はフレキシブルで、あなたが使用したいフレームワークに合わせた調整も可能です。アダプターでは、次の 2 つの要素が必須となっています。 +[アダプター](/tools/bolt-python/concepts/adapters)はフレキシブルで、あなたが使用したいフレームワークに合わせた調整も可能です。アダプターでは、次の 2 つの要素が必須となっています。 - `__init__(app:App)` : コンストラクター。Bolt の `App` のインスタンスを受け取り、保持します。 - `handle(req:Request)` : Slack からの受信リクエストを受け取り、解析を行う関数。通常は `handle()` という名前です。リクエストを [`BoltRequest`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/request/request.py) のインスタンスに合った形にして、保持している Bolt アプリに引き渡します。 diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/errors.md b/docs/japanese/concepts/errors.md similarity index 92% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/errors.md rename to docs/japanese/concepts/errors.md index 6d715c1d7..2e8e27f90 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/errors.md +++ b/docs/japanese/concepts/errors.md @@ -1,8 +1,4 @@ ---- -title: エラーの処理 -lang: ja-jp -slug: /concepts/errors ---- +# エラーの処理 リスナー内でエラーが発生した場合に try/except ブロックを使用して直接エラーを処理することができます。アプリに関連するエラーは、`BoltError` 型です。Slack API の呼び出しに関連するエラーは、`SlackApiError` 型となります。 diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/event-listening.md b/docs/japanese/concepts/event-listening.md similarity index 50% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/event-listening.md rename to docs/japanese/concepts/event-listening.md index 2790ad5b7..c13638226 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/event-listening.md +++ b/docs/japanese/concepts/event-listening.md @@ -1,14 +1,10 @@ ---- -title: イベントのリスニング -lang: ja-jp -slug: /concepts/event-listening ---- +# イベントのリスニング -`event()` メソッドを使うと、[Events API](https://docs.slack.dev/reference/events) の任意のイベントをリッスンできます。リッスンするイベントは、アプリの設定であらかじめサブスクライブしておく必要があります。これを利用することで、アプリがインストールされたワークスペースで何らかのイベント(例:ユーザーがメッセージにリアクションをつけた、ユーザーがチャンネルに参加した)が発生したときに、アプリに何らかのアクションを実行させることができます。 +`event()` メソッドを使うと、[Events API](/reference/events) の任意のイベントをリッスンできます。リッスンするイベントは、アプリの設定であらかじめサブスクライブしておく必要があります。これを利用することで、アプリがインストールされたワークスペースで何らかのイベント(例:ユーザーがメッセージにリアクションをつけた、ユーザーがチャンネルに参加した)が発生したときに、アプリに何らかのアクションを実行させることができます。 `event()` メソッドには `str` 型の `eventType` を指定する必要があります。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # ユーザーがワークスペースに参加した際に、自己紹介を促すメッセージを指定のチャンネルに送信 @app.event("team_join") @@ -23,7 +19,7 @@ def ask_for_introduction(event, say): `message()` リスナーは `event("message")` と等価の機能を提供します。 -`subtype` という追加のキーを指定して、イベントのサブタイプでフィルタリングすることもできます。よく使われるサブタイプには、`bot_message` や `message_replied` があります。詳しくは[メッセージイベントページ](https://docs.slack.dev/reference/events/message#subtypes)を参照してください。サブタイプなしのイベントだけにフィルターするために明に `None` を指定することもできます。 +`subtype` という追加のキーを指定して、イベントのサブタイプでフィルタリングすることもできます。よく使われるサブタイプには、`bot_message` や `message_replied` があります。詳しくは[メッセージイベントページ](/reference/events/message#subtypes)を参照してください。サブタイプなしのイベントだけにフィルターするために明に `None` を指定することもできます。 ```python # 変更されたすべてのメッセージに一致 diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/global-middleware.md b/docs/japanese/concepts/global-middleware.md similarity index 81% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/global-middleware.md rename to docs/japanese/concepts/global-middleware.md index caace0621..884008090 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/global-middleware.md +++ b/docs/japanese/concepts/global-middleware.md @@ -1,15 +1,10 @@ ---- -title: グローバルミドルウェア -lang: ja-jp -slug: /concepts/global-middleware -order: 8 ---- +# グローバルミドルウェア グローバルミドルウェアは、すべての受信リクエストに対して、リスナーミドルウェアが呼ばれる前に実行されるものです。ミドルウェア関数を `app.use()` に渡すことで、アプリにはグローバルミドルウェアをいくつでも追加できます。ミドルウェア関数で受け取れる引数はリスナー関数と同じものに加えて`next()` 関数があります。 グローバルミドルウェアでもリスナーミドルウェアでも、次のミドルウェアに実行チェーンの制御をリレーするために、`next()` を呼び出す必要があります。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python @app.use diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/lazy-listeners.md b/docs/japanese/concepts/lazy-listeners.md similarity index 98% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/lazy-listeners.md rename to docs/japanese/concepts/lazy-listeners.md index 25eb6294c..029f61d99 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/lazy-listeners.md +++ b/docs/japanese/concepts/lazy-listeners.md @@ -1,8 +1,4 @@ ---- -title: Lazy リスナー(FaaS) -lang: ja-jp -slug: /concepts/lazy-listeners ---- +# Lazy リスナー(FaaS Lazy リスナー関数は、FaaS 環境への Slack アプリのデプロイを容易にする機能です。この機能は Bolt for Python でのみ利用可能で、他の Bolt フレームワークでこの機能に対応することは予定していません。 diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/listener-middleware.md b/docs/japanese/concepts/listener-middleware.md similarity index 83% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/listener-middleware.md rename to docs/japanese/concepts/listener-middleware.md index a013dde42..2b3ea9323 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/listener-middleware.md +++ b/docs/japanese/concepts/listener-middleware.md @@ -1,14 +1,10 @@ ---- -title: リスナーミドルウェア -lang: ja-jp -slug: /concepts/listener-middleware ---- +# リスナーミドルウェア リスナーミドルウェアは、それを渡したリスナーでのみ実行されるミドルウェアです。リスナーには、`middleware` パラメーターを使ってミドルウェア関数をいくつでも渡すことができます。このパラメーターには、1 つまたは複数のミドルウェア関数からなるリストを指定します。 非常にシンプルなリスナーミドルウェアの場合であれば、`next()` メソッドを呼び出す代わりに `bool` 値(処理を継続したい場合は `True`)を返すだけで済む「リスナーマッチャー」を使うとよいでしょう。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # ボットからのメッセージをフィルタリングするリスナーミドルウェア diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/logging.md b/docs/japanese/concepts/logging.md similarity index 95% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/logging.md rename to docs/japanese/concepts/logging.md index 22223fb08..3afa46539 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/logging.md +++ b/docs/japanese/concepts/logging.md @@ -1,8 +1,4 @@ ---- -title: ロギング -lang: ja-jp -slug: /concepts/logging ---- +# ロギング デフォルトでは、アプリからのログ情報は、既定の出力先に出力されます。`logging` モジュールをインポートすれば、`basicConfig()` の `level` パラメーターでrootのログレベルを変更することができます。指定できるログレベルは、重要度の低い方から `debug`、`info`、`warning`、`error`、および `critical` です。 diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/message-listening.md b/docs/japanese/concepts/message-listening.md similarity index 55% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/message-listening.md rename to docs/japanese/concepts/message-listening.md index e7d538e69..824ac67c8 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/message-listening.md +++ b/docs/japanese/concepts/message-listening.md @@ -1,14 +1,10 @@ ---- -title: メッセージのリスニング -lang: ja-jp -slug: /concepts/message-listening ---- +# メッセージのリスニング -[あなたのアプリがアクセス権限を持つ](https://docs.slack.dev/messaging/retrieving-messages)メッセージの投稿イベントをリッスンするには `message()` メソッドを利用します。このメソッドは `type` が `message` ではないイベントを処理対象から除外します。 +[あなたのアプリがアクセス権限を持つ](/messaging/retrieving-messages)メッセージの投稿イベントをリッスンするには `message()` メソッドを利用します。このメソッドは `type` が `message` ではないイベントを処理対象から除外します。 `message()` の引数には `str` 型または `re.Pattern` オブジェクトを指定できます。この条件のパターンに一致しないメッセージは除外されます。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # '👋' が含まれるすべてのメッセージに一致 @app.message(":wave:") diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/message-sending.md b/docs/japanese/concepts/message-sending.md similarity index 74% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/message-sending.md rename to docs/japanese/concepts/message-sending.md index fbcf0ac6b..a299144b6 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/message-sending.md +++ b/docs/japanese/concepts/message-sending.md @@ -1,14 +1,10 @@ ---- -title: メッセージの送信 -lang: ja-jp -slug: /concepts/message-sending ---- +# メッセージの送信 リスナー関数内では、関連づけられた会話(例:リスナー実行のトリガーとなったイベントまたはアクションの発生元の会話)がある場合はいつでも `say()` を使用できます。`say()` には文字列または JSON ペイロードを指定できます。文字列の場合、送信できるのはテキストベースの単純なメッセージです。より複雑なメッセージを送信するには JSON ペイロードを指定します。指定したメッセージのペイロードは、関連づけられた会話内のメッセージとして送信されます。 -リスナー関数の外でメッセージを送信したい場合や、より高度な処理(特定のエラーの処理など)を実行したい場合は、[Bolt インスタンスにアタッチされたクライアント](/concepts/web-api)の `client.chat_postMessage` を呼び出します。 +リスナー関数の外でメッセージを送信したい場合や、より高度な処理(特定のエラーの処理など)を実行したい場合は、[Bolt インスタンスにアタッチされたクライアント](/tools/bolt-python/concepts/web-api)の `client.chat_postMessage` を呼び出します。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # 'knock knock' が含まれるメッセージをリッスンし、イタリック体で 'Who's there?' と返信 @app.message("knock knock") @@ -20,7 +16,7 @@ def ask_who(message, say): `say()` は、より複雑なメッセージペイロードを受け付けるので、メッセージに機能やリッチな構造を与えることが容易です。 -リッチなメッセージレイアウトをアプリに追加する方法については、[API サイトのガイド](https://docs.slack.dev/messaging/#structure)を参照してください。また、[Block Kit ビルダー](https://api.slack.com/tools/block-kit-builder?template=1)の一般的なアプリフローのテンプレートも見てみてください。 +リッチなメッセージレイアウトをアプリに追加する方法については、[API サイトのガイド](/messaging/#structure)を参照してください。また、[Block Kit ビルダー](https://api.slack.com/tools/block-kit-builder?template=1)の一般的なアプリフローのテンプレートも見てみてください。 ```python # ユーザーが 📅 のリアクションをつけたら、日付ピッカーのついた section ブロックを送信 diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/opening-modals.md b/docs/japanese/concepts/opening-modals.md similarity index 62% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/opening-modals.md rename to docs/japanese/concepts/opening-modals.md index a954a658b..68e3b947c 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/opening-modals.md +++ b/docs/japanese/concepts/opening-modals.md @@ -1,16 +1,12 @@ ---- -title: モーダルの開始 -lang: ja-jp -slug: /concepts/opening-modals ---- +# モーダルの開始 -モーダルは、ユーザーからのデータの入力を受け付けたり、動的な情報を表示したりするためのインターフェイスです。組み込みの APIクライアントの `views.open` メソッドに、有効な `trigger_id` とビューのペイロードを指定してモーダルを開始します。 +モーダルは、ユーザーからのデータの入力を受け付けたり、動的な情報を表示したりするためのインターフェイスです。組み込みの APIクライアントの `views.open` メソッドに、有効な `trigger_id` とビューのペイロードを指定してモーダルを開始します。 ショートカットの実行、ボタンを押下、選択メニューの操作などの操作の場合、Request URL に送信されるペイロードには `trigger_id` が含まれます。 -モーダルの生成方法についての詳細は、API ドキュメントを参照してください。 +モーダルの生成方法についての詳細は、API ドキュメントを参照してください。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # ショートカットの呼び出しをリッスン diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/select-menu-options.md b/docs/japanese/concepts/select-menu-options.md similarity index 68% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/select-menu-options.md rename to docs/japanese/concepts/select-menu-options.md index 598fb1cc6..1c2d41c58 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/select-menu-options.md +++ b/docs/japanese/concepts/select-menu-options.md @@ -1,20 +1,16 @@ ---- -title: オプションのリスニングと応答 -lang: ja-jp -slug: /concepts/options ---- +# オプションのリスニングと応答 -`options()` メソッドは、Slack からのオプション(セレクトメニュー内の動的な選択肢)をリクエストするペイロードをリッスンします。 [`action()` と同様に](/concepts/action-listening)、文字列型の `action_id` または制約付きオブジェクトが必要です。 +`options()` メソッドは、Slack からのオプション(セレクトメニュー内の動的な選択肢)をリクエストするペイロードをリッスンします。 [`action()` と同様に](/tools/bolt-python/concepts/actions)、文字列型の `action_id` または制約付きオブジェクトが必要です。 外部データソースを使って選択メニューをロードするためには、末部に `/slack/events` が付加された URL を Options Load URL として予め設定しておく必要があります。 `external_select` メニューでは `action_id` を指定することをおすすめしています。ただし、ダイアログを利用している場合、ダイアログが Block Kit に対応していないため、`callback_id` をフィルタリングするための制約オブジェクトを使用する必要があります。 -オプションのリクエストに応答するときは、有効なオプションを含む `options` または `option_groups` のリストとともに `ack()` を呼び出す必要があります。API サイトにある[外部データを使用する選択メニューに応答するサンプル例](https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select)と、[ダイアログでの応答例](https://docs.slack.dev/legacy/legacy-dialogs/#dynamic_select_elements_external)を参考にしてください。 +オプションのリクエストに応答するときは、有効なオプションを含む `options` または `option_groups` のリストとともに `ack()` を呼び出す必要があります。API サイトにある[外部データを使用する選択メニューに応答するサンプル例](/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select)と、[ダイアログでの応答例](/legacy/legacy-dialogs/#dynamic_select_elements_external)を参考にしてください。 -さらに、ユーザーが入力したキーワードに基づいたオプションを返すようフィルタリングロジックを適用することもできます。 これは `payload` という引数の ` value` の値に基づいて、それぞれのパターンで異なるオプションの一覧を返すように実装することができます。 Bolt for Python のすべてのリスナーやミドルウェアでは、[多くの有用な引数](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html)にアクセスすることができますので、チェックしてみてください。 +さらに、ユーザーが入力したキーワードに基づいたオプションを返すようフィルタリングロジックを適用することもできます。 これは `payload` という引数の ` value` の値に基づいて、それぞれのパターンで異なるオプションの一覧を返すように実装することができます。 Bolt for Python のすべてのリスナーやミドルウェアでは、[多くの有用な引数](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)にアクセスすることができますので、チェックしてみてください。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # 外部データを使用する選択メニューオプションに応答するサンプル例 @app.options("external_action") diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/shortcuts.md b/docs/japanese/concepts/shortcuts.md similarity index 77% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/shortcuts.md rename to docs/japanese/concepts/shortcuts.md index 995b6e0d7..d9a8ba050 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/shortcuts.md +++ b/docs/japanese/concepts/shortcuts.md @@ -1,22 +1,18 @@ ---- -title: ショートカットのリスニングと応答 -lang: ja-jp -slug: /concepts/shortcuts ---- +# ショートカットのリスニングと応答 -`shortcut()` メソッドは、[グローバルショートカット](https://docs.slack.dev/interactivity/implementing-shortcuts#global)と[メッセージショートカット](https://docs.slack.dev/interactivity/implementing-shortcuts#messages)の 2 つをサポートしています。 +`shortcut()` メソッドは、[グローバルショートカット](/interactivity/implementing-shortcuts#global)と[メッセージショートカット](/interactivity/implementing-shortcuts#messages)の 2 つをサポートしています。 ショートカットは、いつでも呼び出せるアプリのエントリーポイントを提供するものです。グローバルショートカットは Slack のテキスト入力エリアや検索ウィンドウからアクセスできます。メッセージショートカットはメッセージのコンテキストメニューからアクセスできます。アプリは、ショートカットリクエストをリッスンするために `shortcut()` メソッドを使用します。このメソッドには `str` 型または `re.Pattern` 型の `callback_id` パラメーターを指定します。 ショートカットリクエストがアプリによって確認されたことを Slack に伝えるため、`ack()` を呼び出す必要があります。 -ショートカットのペイロードには `trigger_id` が含まれます。アプリはこれを使って、ユーザーにやろうとしていることを確認するための[モーダルを開く](/concepts/opening-modals)ことができます。 +ショートカットのペイロードには `trigger_id` が含まれます。アプリはこれを使って、ユーザーにやろうとしていることを確認するための[モーダルを開く](/tools/bolt-python/concepts/opening-modals)ことができます。 アプリの設定でショートカットを登録する際は、他の URL と同じように、リクエスト URL の末尾に `/slack/events` をつけます。 -⚠️ グローバルショートカットのペイロードにはチャンネル ID が **含まれません**。アプリでチャンネル ID を取得する必要がある場合は、モーダル内に [`conversations_select`](https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#conversation_multi_select) エレメントを配置します。メッセージショートカットにはチャンネル ID が含まれます。 +⚠️ グローバルショートカットのペイロードにはチャンネル ID が **含まれません**。アプリでチャンネル ID を取得する必要がある場合は、モーダル内に [`conversations_select`](/reference/block-kit/block-elements/multi-select-menu-element#conversation_multi_select) エレメントを配置します。メッセージショートカットにはチャンネル ID が含まれます。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # 'open_modal' という callback_id のショートカットをリッスン @app.shortcut("open_modal") @@ -36,7 +32,7 @@ def open_modal(ack, shortcut, client): "type": "section", "text": { "type": "mrkdwn", - "text":"About the simplest modal you could conceive of :smile:\n\nMaybe or ." + "text":"About the simplest modal you could conceive of :smile:\n\nMaybe or ." } }, { @@ -76,7 +72,7 @@ def open_modal(ack, shortcut, client): "type": "section", "text": { "type": "mrkdwn", - "text":"About the simplest modal you could conceive of :smile:\n\nMaybe or ." + "text":"About the simplest modal you could conceive of :smile:\n\nMaybe or ." } }, { diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/socket-mode.md b/docs/japanese/concepts/socket-mode.md similarity index 86% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/socket-mode.md rename to docs/japanese/concepts/socket-mode.md index 061daf853..92922d2de 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/socket-mode.md +++ b/docs/japanese/concepts/socket-mode.md @@ -1,10 +1,6 @@ ---- -title: ソケットモードの利用 -lang: ja-jp -slug: /concepts/socket-mode ---- +# ソケットモードの利用 -[ソケットモード](https://docs.slack.dev/apis/events-api/using-socket-mode)は、アプリに WebSocket での接続と、そのコネクション経由でのデータ受信を可能とします。Bolt for Python は、バージョン 1.2.0 からこれに対応しています。 +[ソケットモード](/apis/events-api/using-socket-mode)は、アプリに WebSocket での接続と、そのコネクション経由でのデータ受信を可能とします。Bolt for Python は、バージョン 1.2.0 からこれに対応しています。 ソケットモードでは、Slack からのペイロード送信を受け付けるエンドポイントをホストする HTTP サーバーを起動する代わりに WebSocket で Slack に接続し、そのコネクション経由でデータを受信します。ソケットモードを使う前に、アプリの管理画面でソケットモードの機能が有効になっていることを確認しておいてください。 @@ -40,7 +36,7 @@ if __name__ == "__main__": aiohttp のような asyncio をベースとしたアダプターを使う場合、アプリケーション全体が asyncio の async/await プログラミングモデルで実装されている必要があります。`AsyncApp` を動作させるためには `AsyncSocketModeHandler` とその async なミドルウェアやリスナーを利用します。 -`AsyncApp` の使い方についての詳細は、[Async (asyncio) の利用](/concepts/async)や、関連する[サンプルコード例](https://github.com/slackapi/bolt-python/tree/main/examples)を参考にしてください。 +`AsyncApp` の使い方についての詳細は、[Async (asyncio) の利用](/tools/bolt-python/concepts/async)や、関連する[サンプルコード例](https://github.com/slackapi/bolt-python/tree/main/examples)を参考にしてください。 ```python from slack_bolt.app.async_app import AsyncApp diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/token-rotation.md b/docs/japanese/concepts/token-rotation.md similarity index 67% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/token-rotation.md rename to docs/japanese/concepts/token-rotation.md index bc622fe98..25a0c735b 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/token-rotation.md +++ b/docs/japanese/concepts/token-rotation.md @@ -1,13 +1,9 @@ ---- -title: トークンのローテーション -lang: ja-jp -slug: /concepts/token-rotation ---- +# トークンのローテーション Bolt for Python [v1.7.0](https://github.com/slackapi/bolt-python/releases/tag/v1.7.0) から、アクセストークンのさらなるセキュリティ強化のレイヤーであるトークンローテーションの機能に対応しています。トークンローテーションは [OAuth V2 の RFC](https://datatracker.ietf.org/doc/html/rfc6749#section-10.4) で規定されているものです。 既存の Slack アプリではアクセストークンが無期限に存在し続けるのに対して、トークンローテーションを有効にしたアプリではアクセストークンが失効するようになります。リフレッシュトークンを利用して、アクセストークンを長期間にわたって更新し続けることができます。 -[Bolt for Python の組み込みの OAuth 機能](/concepts/authenticating-oauth) を使用していれば、Bolt for Python が自動的にトークンローテーションの処理をハンドリングします。 +[Bolt for Python の組み込みの OAuth 機能](/tools/bolt-python/concepts/authenticating-oauth) を使用していれば、Bolt for Python が自動的にトークンローテーションの処理をハンドリングします。 -トークンローテーションに関する詳細は [API ドキュメント](https://docs.slack.dev/authentication/using-token-rotation)を参照してください。 \ No newline at end of file +トークンローテーションに関する詳細は [API ドキュメント](/authentication/using-token-rotation)を参照してください。 \ No newline at end of file diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/updating-pushing-views.md b/docs/japanese/concepts/updating-pushing-views.md similarity index 60% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/updating-pushing-views.md rename to docs/japanese/concepts/updating-pushing-views.md index 42c89157e..cc32f5b69 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/updating-pushing-views.md +++ b/docs/japanese/concepts/updating-pushing-views.md @@ -1,10 +1,6 @@ ---- -title: モーダルの更新と多重表示 -lang: ja-jp -slug: /concepts/updating-pushing-views ---- +# モーダルの更新と多重表示 -モーダル内では、複数のモーダルをスタックのように重ねることができます。`views_open` という APIを呼び出すと、親となるとなるモーダルビューが追加されます。この最初の呼び出しの後、`views_update` を呼び出すことでそのビューを更新することができます。また、`views_push` を呼び出すと、親のモーダルの上にさらに新しいモーダルビューを重ねることもできます。 +モーダル内では、複数のモーダルをスタックのように重ねることができます。`views_open` という APIを呼び出すと、親となるとなるモーダルビューが追加されます。この最初の呼び出しの後、`views_update` を呼び出すことでそのビューを更新することができます。また、`views_push` を呼び出すと、親のモーダルの上にさらに新しいモーダルビューを重ねることもできます。 **`views_update`** @@ -12,11 +8,11 @@ slug: /concepts/updating-pushing-views **`views_push`** -既存のモーダルの上に新しいモーダルをスタックのように追加する場合は、組み込みのクライアントで `views_push` API を呼び出します。この API 呼び出しでは、有効な `trigger_id` と新しいビューのペイロードを指定します。`views_push` の引数は モーダルの開始 と同じです。モーダルを開いた後、このモーダルのスタックに追加できるモーダルビューは 2 つまでです。 +既存のモーダルの上に新しいモーダルをスタックのように追加する場合は、組み込みのクライアントで `views_push` API を呼び出します。この API 呼び出しでは、有効な `trigger_id` と新しいビューのペイロードを指定します。`views_push` の引数は モーダルの開始 と同じです。モーダルを開いた後、このモーダルのスタックに追加できるモーダルビューは 2 つまでです。 -モーダルの更新と多重表示に関する詳細は、API ドキュメントを参照してください。 +モーダルの更新と多重表示に関する詳細は、API ドキュメントを参照してください。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # モーダルに含まれる、`button_abc` という action_id のボタンの呼び出しをリッスン diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/view-submissions.md b/docs/japanese/concepts/view-submissions.md similarity index 72% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/view-submissions.md rename to docs/japanese/concepts/view-submissions.md index 7ad6637bb..5ae78f173 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/concepts/view-submissions.md +++ b/docs/japanese/concepts/view-submissions.md @@ -1,10 +1,6 @@ ---- -title: モーダルの送信のリスニング -lang: ja-jp -slug: /concepts/view_submissions ---- +# モーダルの送信のリスニング -モーダルのペイロードに `input` ブロックを含める場合、その入力値を受け取るために`view_submission` リクエストをリッスンする必要があります。`view_submission` リクエストのリッスンには、組み込みの`view()` メソッドを利用することができます。`view()` の引数には、`str` 型または `re.Pattern` 型の `callback_id` を指定します。 +モーダルのペイロードに `input` ブロックを含める場合、その入力値を受け取るために`view_submission` リクエストをリッスンする必要があります。`view_submission` リクエストのリッスンには、組み込みの`view()` メソッドを利用することができます。`view()` の引数には、`str` 型または `re.Pattern` 型の `callback_id` を指定します。 `input` ブロックの値にアクセスするには `state` オブジェクトを参照します。`state` 内には `values` というオブジェクトがあり、`block_id` と一意の `action_id` に紐づける形で入力値を保持しています。 @@ -23,9 +19,9 @@ def handle_submission(ack, body): # https://app.slack.com/block-kit-builder/#%7B%22type%22:%22modal%22,%22callback_id%22:%22view_1%22,%22title%22:%7B%22type%22:%22plain_text%22,%22text%22:%22My%20App%22,%22emoji%22:true%7D,%22blocks%22:%5B%5D%7D ack(response_action="update", view=build_new_view(body)) ``` -この例と同様に、モーダルでの送信リクエストに対して、エラーを表示するためのオプションもあります。 +この例と同様に、モーダルでの送信リクエストに対して、エラーを表示するためのオプションもあります。 -モーダルの送信について詳しくは、API ドキュメントを参照してください。 +モーダルの送信について詳しくは、API ドキュメントを参照してください。 --- @@ -33,7 +29,7 @@ def handle_submission(ack, body): `view_closed` リクエストをリッスンするためには `callback_id` を指定して、かつ `notify_on_close` 属性をモーダルのビューに設定する必要があります。以下のコード例をご覧ください。 -よく詳しい情報は、API ドキュメントを参照してください。 +よく詳しい情報は、API ドキュメントを参照してください。 ```python client.views_open( @@ -60,7 +56,7 @@ def handle_view_closed(ack, body, logger): logger.info(body) ``` -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 ```python # view_submission リクエストを処理 diff --git a/docs/japanese/concepts/web-api.md b/docs/japanese/concepts/web-api.md new file mode 100644 index 000000000..7a674b9b2 --- /dev/null +++ b/docs/japanese/concepts/web-api.md @@ -0,0 +1,19 @@ +# Web API の使い方 + +`app.client`、またはミドルウェア・リスナーの引数 `client` として Bolt アプリに提供されている `WebClient` は必要な権限を付与されており、これを利用することで[あらゆる Web API メソッド](/reference/methods)を呼び出すことができます。このクライアントのメソッドを呼び出すと `SlackResponse` という Slack からの応答情報を含むオブジェクトが返されます。 + +Bolt の初期化に使用するトークンは `context` オブジェクトに設定されます。このトークンは、多くの Web API メソッドを呼び出す際に必要となります。 + +指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +```python +@app.message("wake me up") +def say_hello(client, message): + # 2020 年 9 月 30 日午後 11:59:59 を示す Unix エポック秒 + when_september_ends = 1601510399 + channel_id = message["channel"] + client.chat_scheduleMessage( + channel=channel_id, + post_at=when_september_ends, + text="Summer has come and passed" + ) +``` \ No newline at end of file diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/getting-started.md b/docs/japanese/getting-started.md similarity index 80% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/getting-started.md rename to docs/japanese/getting-started.md index b29deaef8..2ec34d193 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/getting-started.md +++ b/docs/japanese/getting-started.md @@ -1,9 +1,3 @@ ---- -title: Bolt 入門ガイド -slug: getting-started -lang: ja-jp ---- - # Bolt 入門ガイド このガイドでは、Bolt for Python を使った Slack アプリの設定と起動の方法について説明します。ここで説明する手順では、まず新しい Slack アプリを作成し、ローカルの開発環境をセットアップし、Slack ワークスペースからのメッセージをリッスンして応答するアプリを開発するという流れになります。 @@ -16,9 +10,7 @@ lang: ja-jp ### アプリを作成する {#create-an-app} 最初にやるべきこと : Bolt での開発を始める前に、[Slack アプリを作成](https://api.slack.com/apps/new)します。 -:::tip - -通常の業務の妨げにならないよう、別の開発用のワークスペースを使用することをおすすめします。[新しいワークスペースは無料で作成できます](https://slack.com/get-started#create) +:::tip[通常の業務の妨げにならないよう、別の開発用のワークスペースを使用することをおすすめします。[新しいワークスペースは無料で作成できます](https://slack.com/get-started#create)] ::: @@ -26,39 +18,37 @@ lang: ja-jp このページでは、アプリの概要や重要な認証情報を確認できます。これらの情報は後ほど参照します。 -![Basic Information ページ](/img/boltpy/basic-information-page.png "Basic Information ページ") +![Basic Information ページ](/img/bolt-python/basic-information-page.png "Basic Information ページ") ひと通り確認して、アプリのアイコンと説明を追加したら、アプリのプロジェクトの構成 🔩 を始めましょう。 --- ### トークンとアプリのインストール {#tokens-and-installing-apps} -Slack アプリでは、[Slack API へのアクセスの管理に OAuth を使用します](https://docs.slack.dev/authentication/installing-with-oauth)。アプリがインストールされると、トークンが発行されます。アプリはそのトークンを使って API メソッドを呼び出すことができます。 +Slack アプリでは、[Slack API へのアクセスの管理に OAuth を使用します](/authentication/installing-with-oauth)。アプリがインストールされると、トークンが発行されます。アプリはそのトークンを使って API メソッドを呼び出すことができます。 Slack アプリで使用できるトークンには、ユーザートークン(`xoxp`)とボットトークン(`xoxb`)、アプリレベルトークン(`xapp`)の 3 種類があります。 -- [ユーザートークン](https://docs.slack.dev/authentication/tokens#user) を使用すると、アプリをインストールまたは認証したユーザーに成り代わって API メソッドを呼び出すことができます。1 つのワークスペースに複数のユーザートークンが存在する可能性があります。 -- [ボットトークン](https://docs.slack.dev/authentication/tokens#bot) はボットユーザーに関連づけられ、1 つのワークスペースでは最初に誰かがそのアプリをインストールした際に一度だけ発行されます。どのユーザーがインストールを実行しても、アプリが使用するボットトークンは同じになります。_ほとんど_のアプリで使用されるのは、ボットトークンです。 -- [アプリレベルトークン](https://docs.slack.dev/authentication/tokens#app-level) は、全ての組織(とその配下のワークスペースでの個々のユーザーによるインストール)を横断して、あなたのアプリを代理するものです。アプリレベルトークンは、アプリの WebSocket コネクションを確立するためによく使われます。 +- [ユーザートークン](/authentication/tokens#user) を使用すると、アプリをインストールまたは認証したユーザーに成り代わって API メソッドを呼び出すことができます。1 つのワークスペースに複数のユーザートークンが存在する可能性があります。 +- [ボットトークン](/authentication/tokens#bot) はボットユーザーに関連づけられ、1 つのワークスペースでは最初に誰かがそのアプリをインストールした際に一度だけ発行されます。どのユーザーがインストールを実行しても、アプリが使用するボットトークンは同じになります。_ほとんど_のアプリで使用されるのは、ボットトークンです。 +- [アプリレベルトークン](/authentication/tokens#app-level) は、全ての組織(とその配下のワークスペースでの個々のユーザーによるインストール)を横断して、あなたのアプリを代理するものです。アプリレベルトークンは、アプリの WebSocket コネクションを確立するためによく使われます。 このガイドではボットトークンとアプリレベルトークンを使用します。 1. 左サイドバーの「**OAuth & Permissions**」をクリックし、「**Bot Token Scopes**」セクションまで下にスクロールします。「**Add an OAuth Scope**」をクリックします。 -2. ここでは [`chat:write`](https://docs.slack.dev/reference/scopes/chat.write) というスコープのみを追加します。このスコープはアプリが参加しているチャンネルにメッセージを投稿することを許可します。 +2. ここでは [`chat:write`](/reference/scopes/chat.write) というスコープのみを追加します。このスコープはアプリが参加しているチャンネルにメッセージを投稿することを許可します。 3. OAuth & Permissions ページの一番上までスクロールし、「**Install App to Workspace**」をクリックします。Slack の OAuth 確認画面 が表示されます。この画面で開発用ワークスペースへのアプリのインストールを承認します。 4. インストールを承認すると **OAuth & Permissions** ページが表示され、**Bot User OAuth Access Token** を確認できるでしょう。 -![OAuth トークン](/img/boltpy/bot-token.png "ボット用 OAuth トークン") +![OAuth トークン](/img/bolt-python/bot-token.png "ボット用 OAuth トークン") 5. 次に「**Basic Informationのページ**」まで戻り、アプリレベルトークンのセクションまで下にスクロールし「**Generate Token and Scopes**」をクリックしてアプリレベルトークンを作成します。このトークンに `connections:write` のスコープを付与し、作成された `xapp` トークンを保存します。これらのトークンは後ほど利用します。 6. 左サイドメニューの「**Socket Mode**」を有効にします。 -:::tip - -トークンはパスワードと同様に取り扱い、[安全な方法で保管してください](https://docs.slack.dev/authentication/best-practices-for-security)。アプリはこのトークンを使って Slack ワークスペースで投稿をしたり、情報の取得をしたりします。 +:::tip[トークンはパスワードと同様に取り扱い、[安全な方法で保管してください](/authentication/best-practices-for-security)。アプリはこのトークンを使って Slack ワークスペースで投稿をしたり、情報の取得をしたりします。] ::: @@ -99,9 +89,9 @@ export SLACK_BOT_TOKEN=xoxb-<ボットトークン> ```shell export SLACK_APP_TOKEN=<アプリレベルトークン> ``` -:::warning +:::warning[🔒 全てのトークンは安全に保管してください。] -🔒 全てのトークンは安全に保管してください。少なくともパブリックなバージョン管理にチェックインするようなことは避けるべきでしょう。また、上にあった例のように環境変数を介してアクセスするようにしてください。詳細な情報は [アプリのセキュリティのベストプラクティス](https://docs.slack.dev/authentication/best-practices-for-security)のドキュメントを参照してください。 +少なくともパブリックなバージョン管理にチェックインするようなことは避けるべきでしょう。また、上にあった例のように環境変数を介してアクセスするようにしてください。詳細な情報は [アプリのセキュリティのベストプラクティス](/authentication/best-practices-for-security)のドキュメントを参照してください。 ::: @@ -139,7 +129,7 @@ python3 app.py ### イベントを設定する {#setting-up-events} アプリはワークスペース内の他のメンバーと同じように振る舞い、メッセージを投稿したり、絵文字リアクションを追加したり、イベントをリッスンして返答したりできます。 -Slack ワークスペースで発生するイベント(メッセージが投稿されたときや、メッセージに対するリアクションがつけられたときなど)をリッスンするには、[Events API を使って特定の種類のイベントをサブスクライブします](https://docs.slack.dev/apis/events-api/)。 +Slack ワークスペースで発生するイベント(メッセージが投稿されたときや、メッセージに対するリアクションがつけられたときなど)をリッスンするには、[Events API を使って特定の種類のイベントをサブスクライブします](/apis/events-api/)。 このチュートリアルの序盤でソケットモードを有効にしました。ソケットモードを使うことで、アプリが公開された HTTP エンドポイントを公開せずに Events API やインタラクティブコンポーネントを利用できるようになります。このことは、開発時やファイヤーウォールの裏からのリクエストを受ける際に便利です。HTTP での方式は、ホスティング環境にデプロイするアプリや Slack App Directory で配布されるアプリの開発・運用に適しています。 @@ -164,11 +154,11 @@ import TabItem from '@theme/TabItem'; 1. アプリ構成ページに戻ります ([アプリ管理ページから](https://api.slack.com/apps) アプリをクリックします)。左側のサイドバーで [**イベント サブスクリプション**] をクリックします。 **イベントを有効にする**というラベルの付いたスイッチを切り替えます。 -2. リクエスト URL を追加します。 Slack は、イベントに対応する HTTP POST リクエストをこの [リクエスト URL](https://docs.slack.dev/apis/events-api/#subscribing) に送信します。 Bolt は、`/slack/events` パスを使用して、すべての受信リクエスト (ショートカット、イベント、対話性ペイロードなど) をリッスンします。アプリ構成内でリクエスト URL を構成する場合は、`/slack/events` を追加します。 「https://あなたのドメイン/slack/events」。 💡 Bolt アプリが実行されている限り、URL は検証されるはずです。 +2. リクエスト URL を追加します。 Slack は、イベントに対応する HTTP POST リクエストをこの [リクエスト URL](/apis/events-api/#subscribing) に送信します。 Bolt は、`/slack/events` パスを使用して、すべての受信リクエスト (ショートカット、イベント、対話性ペイロードなど) をリッスンします。アプリ構成内でリクエスト URL を構成する場合は、`/slack/events` を追加します。 「https://あなたのドメイン/slack/events」。 💡 Bolt アプリが実行されている限り、URL は検証されるはずです。 -:::tip +:::tip -ローカル開発の場合、ngrok などのプロキシ サービスを使用してパブリック URL を作成し、リクエストを開発環境にトンネリングできます。このトンネルの作成方法については、[ngrok のスタート ガイド](https://ngrok.com/docs#getting-started-expose) を参照してください。アプリをホスティングする際には、Slack 開発者がアプリをホストするために使用する最も一般的なホスティング プロバイダーを [API サイト](https://docs.slack.dev/distribution/hosting-slack-apps/) に集めました。 +ローカル開発の場合、ngrok などのプロキシ サービスを使用してパブリック URL を作成し、リクエストを開発環境にトンネリングできます。このトンネルの作成方法については、[ngrok のスタート ガイド](https://ngrok.com/docs#getting-started-expose) を参照してください。アプリをホスティングする際には、Slack 開発者がアプリをホストするために使用する最も一般的なホスティング プロバイダーを [API サイト](/app-management/hosting-slack-apps) に集めました。 ::: @@ -176,10 +166,10 @@ import TabItem from '@theme/TabItem'; 左側のサイドバーから **Event Subscriptions** にアクセスして、機能を有効にしてください。 **Subscribe to Bot Events** 配下で、ボットが受け取れるイベントを追加することができます。4つのメッセージに関するイベントがあります。 -- [`message.channels`](https://docs.slack.dev/reference/events/message.channels) アプリが参加しているパブリックチャンネルのメッセージをリッスン -- [`message.groups`](https://docs.slack.dev/reference/events/message.groups) アプリが参加しているプライベートチャンネルのメッセージをリッスン -- [`message.im`](https://docs.slack.dev/reference/events/message.im) あなたのアプリとユーザーのダイレクトメッセージをリッスン -- [`message.mpim`](https://docs.slack.dev/reference/events/message.mpim) あなたのアプリが追加されているグループ DM をリッスン +- [`message.channels`](/reference/events/message.channels) アプリが参加しているパブリックチャンネルのメッセージをリッスン +- [`message.groups`](/reference/events/message.groups) アプリが参加しているプライベートチャンネルのメッセージをリッスン +- [`message.im`](/reference/events/message.im) あなたのアプリとユーザーのダイレクトメッセージをリッスン +- [`message.mpim`](/reference/events/message.mpim) あなたのアプリが追加されているグループ DM をリッスン ボットが参加するすべての場所のメッセージをリッスンさせるには、これら 4 つのメッセージイベントをすべて選択します。ボットにリッスンさせるメッセージイベントの種類を選択したら、「**Save Changes**」ボタンをクリックします。 @@ -203,7 +193,7 @@ app = App(token=os.environ.get("SLACK_BOT_TOKEN")) # 'こんにちは' を含むメッセージをリッスンします # 指定可能なリスナーのメソッド引数の一覧は以下のモジュールドキュメントを参考にしてください: -# https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html +# https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html @app.message("こんにちは") def message_hello(message, say): # イベントがトリガーされたチャンネルへ say() でメッセージを送信します @@ -229,7 +219,7 @@ app = App( # 'hello' を含むメッセージをリッスンします # 指定可能なリスナーのメソッド引数の一覧は以下のモジュールドキュメントを参考にしてください: -# https://tools.slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html +# https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html @app.message("hello") def message_hello(message, say): # イベントがトリガーされたチャンネルへ say() でメッセージを送信します @@ -359,9 +349,9 @@ if __name__ == "__main__": ボタンを含む `accessory` オブジェクトでは、`action_id` を指定していることがわかります。これは、ボタンを一意に示す識別子として機能します。これを使って、アプリをどのアクションに応答させるかを指定できます。 -:::tip +:::tip[[Block Kit Builder](https://app.slack.com/block-kit-builder) を使用すると、インタラクティブなメッセージのプロトタイプを簡単に作成できます。] -[Block Kit Builder](https://app.slack.com/block-kit-builder) を使用すると、インタラクティブなメッセージのプロトタイプを簡単に作成できます。自分自身やチームメンバーがメッセージのモックアップを作成し、生成される JSON をアプリに直接貼りつけることができます。 +自分自身やチームメンバーがメッセージのモックアップを作成し、生成される JSON をアプリに直接貼りつけることができます。 ::: @@ -468,6 +458,6 @@ if __name__ == "__main__": ここまでで基本的なアプリをセットアップして実行することはできたので、次は自分だけの Bolt アプリを作る方法について調べてみてください。参考になりそうなリソースをいくつかご紹介します。 * 基本的な概念について読んでみてください。Bolt アプリがアクセスできるさまざまメソッドや機能について知ることができます。 -* [`app.event()` メソッド](/concepts/event-listening)でボットがリッスンできるイベントをほかにも試してみましょう。すべてのイベントの一覧は [API サイト](https://docs.slack.dev/reference/events)で確認できます。 -* Bolt では、アプリにアタッチされたクライアントから [Web API メソッドを呼び出す](/concepts/web-api)ことができます。API サイトに [220 以上のメソッド](https://docs.slack.dev/reference/methods)を一覧しています。 -* [API サイト](https://docs.slack.dev/authentication/tokens)でほかのタイプのトークンを確認してみてください。アプリで実行したいアクションによって、異なるトークンが必要になる場合があります。 +* [`app.event()` メソッド](/tools/bolt-python/concepts/event-listening)でボットがリッスンできるイベントをほかにも試してみましょう。すべてのイベントの一覧は [API サイト](/reference/events)で確認できます。 +* Bolt では、アプリにアタッチされたクライアントから [Web API メソッドを呼び出す](/tools/bolt-python/concepts/web-api)ことができます。API サイトに [220 以上のメソッド](/reference/methods)を一覧しています。 +* [API サイト](/authentication/tokens)でほかのタイプのトークンを確認してみてください。アプリで実行したいアクションによって、異なるトークンが必要になる場合があります。 diff --git a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/legacy/steps-from-apps.md b/docs/japanese/legacy/steps-from-apps.md similarity index 71% rename from docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/legacy/steps-from-apps.md rename to docs/japanese/legacy/steps-from-apps.md index 554b2a1f4..a7ef2e04a 100644 --- a/docs/i18n/ja-jp/docusaurus-plugin-content-docs/current/legacy/steps-from-apps.md +++ b/docs/japanese/legacy/steps-from-apps.md @@ -1,10 +1,6 @@ ---- -title: ワークフローステップの概要 -lang: ja-jp -slug: /concepts/steps-from-apps ---- +# ワークフローステップの概要 -(アプリによる)ワークフローステップでは、処理をアプリ側で行うカスタムのワークフローステップを提供することができます。ユーザーは[ワークフロービルダー](https://docs.slack.dev/workflows/workflow-builder)を使ってこれらのステップをワークフローに追加できます。 +(アプリによる)ワークフローステップでは、処理をアプリ側で行うカスタムのワークフローステップを提供することができます。ユーザーは[ワークフロービルダー](/workflows/workflow-builder)を使ってこれらのステップをワークフローに追加できます。 ワークフローステップは、次の 3 つのユーザーイベントで構成されます。 @@ -14,7 +10,7 @@ slug: /concepts/steps-from-apps ワークフローステップを機能させるためには、これら 3 つのイベントすべてに対応する必要があります。 -アプリを使ったワークフローステップに関する詳細は、[API ドキュメント](https://docs.slack.dev/legacy/legacy-steps-from-apps/)を参照してください。 +アプリを使ったワークフローステップに関する詳細は、[API ドキュメント](/legacy/legacy-steps-from-apps/)を参照してください。 ## ステップの定義 @@ -26,9 +22,9 @@ slug: /concepts/steps-from-apps `WorkflowStep` のインスタンスを作成したら、それを`app.step()` メソッドに渡します。これによって、アプリがワークフローステップのイベントをリッスンし、設定オブジェクトで指定されたコールバックを使ってそれに応答できるようになります。 -また、デコレーターとして利用できる `WorkflowStepBuilder` クラスを使ってワークフローステップを定義することもできます。 詳細は、[こちらのドキュメント](https://tools.slack.dev/bolt-python/api-docs/slack_bolt/workflows/step/step.html#slack_bolt.workflows.step.step.WorkflowStepBuilder)のコード例などを参考にしてください。 +また、デコレーターとして利用できる `WorkflowStepBuilder` クラスを使ってワークフローステップを定義することもできます。 詳細は、[こちらのドキュメント](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/step.html#slack_bolt.workflows.step.step.WorkflowStepBuilder)のコード例などを参考にしてください。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 ```python import os @@ -63,15 +59,15 @@ app.step(ws) ## ステップの追加・編集 -作成したワークフローステップがワークフローに追加またはその設定を変更されるタイミングで、[`workflow_step_edit` イベントがアプリに送信されます](https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step_edit-payload)。このイベントがアプリに届くと、`WorkflowStep` で設定した `edit` コールバックが実行されます。 +作成したワークフローステップがワークフローに追加またはその設定を変更されるタイミングで、[`workflow_step_edit` イベントがアプリに送信されます](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step_edit-payload)。このイベントがアプリに届くと、`WorkflowStep` で設定した `edit` コールバックが実行されます。 -ステップの追加と編集のどちらが行われるときも、[ワークフローステップの設定モーダル](https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-configuration-view-object)をビルダーに送信する必要があります。このモーダルは、そのステップ独自の設定を選択するための場所です。通常のモーダルより制限が強く、例えば `title`、`submit`、`close` のプロパティを含めることができません。設定モーダルの `callback_id` は、デフォルトではワークフローステップと同じものになります。 +ステップの追加と編集のどちらが行われるときも、[ワークフローステップの設定モーダル](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-configuration-view-object)をビルダーに送信する必要があります。このモーダルは、そのステップ独自の設定を選択するための場所です。通常のモーダルより制限が強く、例えば `title`、`submit`、`close` のプロパティを含めることができません。設定モーダルの `callback_id` は、デフォルトではワークフローステップと同じものになります。 `edit` コールバック内で `configure()` ユーティリティを使用すると、対応する `blocks` 引数にビューのblocks 部分だけを渡して、ステップの設定モーダルを簡単に表示させることができます。必要な入力内容が揃うまで設定の保存を無効にするには、`True` の値をセットした `submit_disabled` を渡します。 -設定モーダルの開き方に関する詳細は、[こちらのドキュメント](https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-configuration-view-object)を参照してください。 +設定モーダルの開き方に関する詳細は、[こちらのドキュメント](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-configuration-view-object)を参照してください。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 ```python def edit(ack, step, configure): @@ -121,9 +117,9 @@ app.step(ws) - `step_name` : ステップのデフォルトの名前をオーバーライドします。 - `step_image_url` : ステップのデフォルトの画像をオーバーライドします。 -これらのパラメータの構成方法に関する詳細は、[こちらのドキュメント](https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object)を参照してください。 +これらのパラメータの構成方法に関する詳細は、[こちらのドキュメント](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object)を参照してください。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 ```python def save(ack, view, update): @@ -162,13 +158,13 @@ app.step(ws) ## ステップの実行 -エンドユーザーがワークフローステップを実行すると、アプリに [`workflow_step_execute` イベントが送信されます](https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object)。このイベントがアプリに届くと、`WorkflowStep` で設定した `execute` コールバックが実行されます。 +エンドユーザーがワークフローステップを実行すると、アプリに [`workflow_step_execute` イベントが送信されます](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object)。このイベントがアプリに届くと、`WorkflowStep` で設定した `execute` コールバックが実行されます。 `save` コールバックで取り出した `inputs` を使って、サードパーティの API を呼び出す、情報をデータベースに保存する、ユーザーのホームタブを更新するといった処理を実行することができます。また、ワークフローの後続のステップで利用する出力値を `outputs` オブジェクトに設定します。 `execute` コールバック内では、`complete()` を呼び出してステップの実行が成功したことを示すか、`fail()` を呼び出してステップの実行が失敗したことを示す必要があります。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 ```python def execute(step, complete, fail): inputs = step["inputs"] diff --git a/docs/navbarConfig.js b/docs/navbarConfig.js deleted file mode 100644 index b243299f1..000000000 --- a/docs/navbarConfig.js +++ /dev/null @@ -1,97 +0,0 @@ -const navbar = { - style: 'dark', - title: 'Slack Developer Tools', - logo: { - src: 'img/slack-logo-on-white.png', - href: 'https://tools.slack.dev', - }, - items: [ - { - type: 'dropdown', - label: 'Bolt', - position: 'left', - items: [ - { - label: 'Java', - to: 'https://tools.slack.dev/java-slack-sdk/guides/bolt-basics', - target: '_self', - }, - { - label: 'JavaScript', - to: 'https://tools.slack.dev/bolt-js', - target: '_self', - }, - { - label: 'Python', - to: 'https://tools.slack.dev/bolt-python', - target: '_self', - }, - ], - }, - { - type: 'dropdown', - label: 'SDKs', - position: 'left', - items: [ - { - label: 'Java Slack SDK', - to: 'https://tools.slack.dev/java-slack-sdk/', - target: '_self', - }, - { - label: 'Node Slack SDK', - to: 'https://tools.slack.dev/node-slack-sdk/', - target: '_self', - }, - { - label: 'Python Slack SDK', - to: 'https://tools.slack.dev/python-slack-sdk/', - target: '_self', - }, - { - label: 'Deno Slack SDK', - to: 'https://tools.slack.dev/deno-slack-sdk/', - target: '_self', - }, - ], - }, - { - to: 'https://tools.slack.dev/slack-cli', - label: 'Slack CLI', - target: '_self', - }, - { - to: 'https://docs.slack.dev/', - label: 'API Docs', - position: 'right', - target: '_self', - }, - { - label: 'Developer Program', - position: 'right', - to: 'https://api.slack.com/developer-program', - target: '_blank', - rel: "noopener noreferrer" - }, - { - label: 'Your apps', - to: 'https://api.slack.com/apps', - position: 'right', - target: '_blank', - rel: "noopener noreferrer" - }, - { - type: 'localeDropdown', - position: 'right', - }, - { - 'aria-label': 'GitHub Repository', - className: 'navbar-github-link', - href: 'https://github.com/slackapi', - position: 'right', - target: '_self', - }, - ], -}; - -module.exports = navbar; diff --git a/docs/package-lock.json b/docs/package-lock.json deleted file mode 100644 index ec7f3558a..000000000 --- a/docs/package-lock.json +++ /dev/null @@ -1,16738 +0,0 @@ -{ - "name": "website", - "version": "2024.08.01", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "website", - "version": "2024.08.01", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/plugin-client-redirects": "^3.8.1", - "@docusaurus/preset-classic": "3.8.1", - "@mdx-js/react": "^3.1.0", - "clsx": "^2.0.0", - "docusaurus-theme-github-codeblock": "^2.0.2", - "prism-react-renderer": "^2.4.1", - "react": "^19.1.1", - "react-dom": "^19.1.1" - }, - "devDependencies": { - "@docusaurus/module-type-aliases": "3.8.1", - "@docusaurus/types": "3.8.1" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@algolia/autocomplete-core": { - "version": "1.17.9", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.9.tgz", - "integrity": "sha512-O7BxrpLDPJWWHv/DLA9DRFWs+iY1uOJZkqUwjS5HSZAGcl0hIVCQ97LTLewiZmZ402JYUrun+8NqFP+hCknlbQ==", - "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.17.9", - "@algolia/autocomplete-shared": "1.17.9" - } - }, - "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.17.9", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.9.tgz", - "integrity": "sha512-u1fEHkCbWF92DBeB/KHeMacsjsoI0wFhjZtlCq2ddZbAehshbZST6Hs0Avkc0s+4UyBGbMDnSuXHLuvRWK5iDQ==", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.9" - }, - "peerDependencies": { - "search-insights": ">= 1 < 3" - } - }, - "node_modules/@algolia/autocomplete-preset-algolia": { - "version": "1.17.9", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.9.tgz", - "integrity": "sha512-Na1OuceSJeg8j7ZWn5ssMu/Ax3amtOwk76u4h5J4eK2Nx2KB5qt0Z4cOapCsxot9VcEN11ADV5aUSlQF4RhGjQ==", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.9" - }, - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/autocomplete-shared": { - "version": "1.17.9", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.9.tgz", - "integrity": "sha512-iDf05JDQ7I0b7JEA/9IektxN/80a2MZ1ToohfmNS3rfeuQnIKI3IJlIafD0xu4StbtQTghx9T3Maa97ytkXenQ==", - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/client-abtesting": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.30.0.tgz", - "integrity": "sha512-Q3OQXYlTNqVUN/V1qXX8VIzQbLjP3yrRBO9m6NRe1CBALmoGHh9JrYosEGvfior28+DjqqU3Q+nzCSuf/bX0Gw==", - "dependencies": { - "@algolia/client-common": "5.30.0", - "@algolia/requester-browser-xhr": "5.30.0", - "@algolia/requester-fetch": "5.30.0", - "@algolia/requester-node-http": "5.30.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.30.0.tgz", - "integrity": "sha512-/b+SAfHjYjx/ZVeVReCKTTnFAiZWOyvYLrkYpeNMraMT6akYRR8eC1AvFcvR60GLG/jytxcJAp42G8nN5SdcLg==", - "dependencies": { - "@algolia/client-common": "5.30.0", - "@algolia/requester-browser-xhr": "5.30.0", - "@algolia/requester-fetch": "5.30.0", - "@algolia/requester-node-http": "5.30.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-common": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.30.0.tgz", - "integrity": "sha512-tbUgvkp2d20mHPbM0+NPbLg6SzkUh0lADUUjzNCF+HiPkjFRaIW3NGMlESKw5ia4Oz6ZvFzyREquUX6rdkdJcQ==", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-insights": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.30.0.tgz", - "integrity": "sha512-caXuZqJK761m32KoEAEkjkE2WF/zYg1McuGesWXiLSgfxwZZIAf+DljpiSToBUXhoPesvjcLtINyYUzbkwE0iw==", - "dependencies": { - "@algolia/client-common": "5.30.0", - "@algolia/requester-browser-xhr": "5.30.0", - "@algolia/requester-fetch": "5.30.0", - "@algolia/requester-node-http": "5.30.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.30.0.tgz", - "integrity": "sha512-7K6P7TRBHLX1zTmwKDrIeBSgUidmbj6u3UW/AfroLRDGf9oZFytPKU49wg28lz/yulPuHY0nZqiwbyAxq9V17w==", - "dependencies": { - "@algolia/client-common": "5.30.0", - "@algolia/requester-browser-xhr": "5.30.0", - "@algolia/requester-fetch": "5.30.0", - "@algolia/requester-node-http": "5.30.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-query-suggestions": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.30.0.tgz", - "integrity": "sha512-WMjWuBjYxJheRt7Ec5BFr33k3cV0mq2WzmH9aBf5W4TT8kUp34x91VRsYVaWOBRlxIXI8o/WbhleqSngiuqjLA==", - "dependencies": { - "@algolia/client-common": "5.30.0", - "@algolia/requester-browser-xhr": "5.30.0", - "@algolia/requester-fetch": "5.30.0", - "@algolia/requester-node-http": "5.30.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-search": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.30.0.tgz", - "integrity": "sha512-puc1/LREfSqzgmrOFMY5L/aWmhYOlJ0TTpa245C0ZNMKEkdOkcimFbXTXQ8lZhzh+rlyFgR7cQGNtXJ5H0XgZg==", - "dependencies": { - "@algolia/client-common": "5.30.0", - "@algolia/requester-browser-xhr": "5.30.0", - "@algolia/requester-fetch": "5.30.0", - "@algolia/requester-node-http": "5.30.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/events": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz", - "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==" - }, - "node_modules/@algolia/ingestion": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.30.0.tgz", - "integrity": "sha512-NfqiIKVgGKTLr6T9F81oqB39pPiEtILTy0z8ujxPKg2rCvI/qQeDqDWFBmQPElCfUTU6kk67QAgMkQ7T6fE+gg==", - "dependencies": { - "@algolia/client-common": "5.30.0", - "@algolia/requester-browser-xhr": "5.30.0", - "@algolia/requester-fetch": "5.30.0", - "@algolia/requester-node-http": "5.30.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/monitoring": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.30.0.tgz", - "integrity": "sha512-/eeM3aqLKro5KBZw0W30iIA6afkGa+bcpvEM0NDa92m5t3vil4LOmJI9FkgzfmSkF4368z/SZMOTPShYcaVXjA==", - "dependencies": { - "@algolia/client-common": "5.30.0", - "@algolia/requester-browser-xhr": "5.30.0", - "@algolia/requester-fetch": "5.30.0", - "@algolia/requester-node-http": "5.30.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/recommend": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.30.0.tgz", - "integrity": "sha512-iWeAUWqw+xT+2IyUyTqnHCK+cyCKYV5+B6PXKdagc9GJJn6IaPs8vovwoC0Za5vKCje/aXQ24a2Z1pKpc/tdHg==", - "dependencies": { - "@algolia/client-common": "5.30.0", - "@algolia/requester-browser-xhr": "5.30.0", - "@algolia/requester-fetch": "5.30.0", - "@algolia/requester-node-http": "5.30.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.30.0.tgz", - "integrity": "sha512-alo3ly0tdNLjfMSPz9dmNwYUFHx7guaz5dTGlIzVGnOiwLgIoM6NgA+MJLMcH6e1S7OpmE2AxOy78svlhst2tQ==", - "dependencies": { - "@algolia/client-common": "5.30.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-fetch": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.30.0.tgz", - "integrity": "sha512-WOnTYUIY2InllHBy6HHMpGIOo7Or4xhYUx/jkoSK/kPIa1BRoFEHqa8v4pbKHtoG7oLvM2UAsylSnjVpIhGZXg==", - "dependencies": { - "@algolia/client-common": "5.30.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-node-http": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.30.0.tgz", - "integrity": "sha512-uSTUh9fxeHde1c7KhvZKUrivk90sdiDftC+rSKNFKKEU9TiIKAGA7B2oKC+AoMCqMymot1vW9SGbeESQPTZd0w==", - "dependencies": { - "@algolia/client-common": "5.30.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.2.tgz", - "integrity": "sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", - "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.0", - "@babel/generator": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.0", - "@babel/parser": "^7.26.0", - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.26.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.3.tgz", - "integrity": "sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.27.3", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", - "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", - "dependencies": { - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.25.9.tgz", - "integrity": "sha512-C47lC7LIDCnz0h4vai/tpNOI95tCd5ZT3iBt/DBH5lXKHZsyNQv18yf1wIIg2ntiQNgmAvA+DgZ82iW8Qdym8g==", - "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", - "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", - "dependencies": { - "@babel/compat-data": "^7.25.9", - "@babel/helper-validator-option": "^7.25.9", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", - "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/traverse": "^7.25.9", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.9.tgz", - "integrity": "sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "regexpu-core": "^6.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz", - "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==", - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", - "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", - "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", - "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", - "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", - "dependencies": { - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", - "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-wrap-function": "^7.25.9", - "@babel/traverse": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", - "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/traverse": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.9.tgz", - "integrity": "sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==", - "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", - "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", - "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", - "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", - "dependencies": { - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", - "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.10" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.4.tgz", - "integrity": "sha512-BRmLHGwpUqLFR2jzx9orBuX/ABDkj2jLKOXrHDTN2aOKL+jFDDKaRNo9nyYsIl9h/UE/7lMKdDjKQQyxKKDZ7g==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", - "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/traverse": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", - "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", - "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", - "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/plugin-transform-optional-chaining": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", - "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/traverse": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-dynamic-import": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", - "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", - "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", - "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", - "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", - "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", - "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", - "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-remap-async-to-generator": "^7.25.9", - "@babel/traverse": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", - "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", - "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-remap-async-to-generator": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", - "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", - "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", - "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", - "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", - "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9", - "@babel/traverse": "^7.25.9", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", - "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/template": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", - "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", - "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", - "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", - "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", - "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.25.9.tgz", - "integrity": "sha512-KRhdhlVk2nObA5AYa7QMgTMTVJdfHprfpAk4DjZVtllqRg9qarilstTKEhpVjyt+Npi8ThRyiV8176Am3CodPA==", - "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", - "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", - "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", - "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", - "dependencies": { - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/traverse": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", - "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", - "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", - "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", - "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", - "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", - "dependencies": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.9.tgz", - "integrity": "sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==", - "dependencies": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-simple-access": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", - "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", - "dependencies": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", - "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", - "dependencies": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", - "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", - "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.9.tgz", - "integrity": "sha512-ENfftpLZw5EItALAD4WsY/KUWvhUlZndm5GC7G3evUsVeSJB6p0pBeLQUnRnBCBx7zV0RKQjR9kCuwrsIrjWog==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", - "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", - "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", - "dependencies": { - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/plugin-transform-parameters": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", - "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", - "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", - "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", - "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", - "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", - "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", - "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-constant-elements": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz", - "integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.25.9.tgz", - "integrity": "sha512-KJfMlYIUxQB1CJfO3e0+h0ZHWOTLCPP115Awhaz8U0Zpq36Gl/cXlpoyMRnUWlhNUBAzldnCiAZNvCDj7CrKxQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.9.tgz", - "integrity": "sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/plugin-syntax-jsx": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.25.9.tgz", - "integrity": "sha512-9mj6rm7XVYs4mdLIpbZnHOYdpW42uoiBCTVowg7sP1thUOiANgMb4UtpRivR0pp5iL+ocvUv7X4mZgFRpJEzGw==", - "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.25.9.tgz", - "integrity": "sha512-KQ/Takk3T8Qzj5TppkS1be588lkbTp5uj7w6a0LeQaTMSckU/wK0oJ/pih+T690tkgI5jfmg2TqDJvd41Sj1Cg==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", - "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "regenerator-transform": "^0.15.2" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", - "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", - "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.27.4.tgz", - "integrity": "sha512-D68nR5zxU64EUzV8i7T3R5XP0Xhrou/amNnddsRQssx6GrTLdZl1rLxyjtVZBd+v/NVX4AbTPOB5aU8thAZV1A==", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.11.0", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", - "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.3", - "core-js-compat": "^3.40.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", - "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", - "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", - "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", - "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", - "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.25.9.tgz", - "integrity": "sha512-7PbZQZP50tzv2KGGnhh82GSyMB01yKY9scIjf1a+GfZCtInOWqUH5+1EBU4t9fyR5Oykkkc9vFTs4OHrhHXljQ==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/plugin-syntax-typescript": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", - "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", - "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", - "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", - "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/preset-env": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", - "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", - "dependencies": { - "@babel/compat-data": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-validator-option": "^7.25.9", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.26.0", - "@babel/plugin-syntax-import-attributes": "^7.26.0", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.25.9", - "@babel/plugin-transform-async-generator-functions": "^7.25.9", - "@babel/plugin-transform-async-to-generator": "^7.25.9", - "@babel/plugin-transform-block-scoped-functions": "^7.25.9", - "@babel/plugin-transform-block-scoping": "^7.25.9", - "@babel/plugin-transform-class-properties": "^7.25.9", - "@babel/plugin-transform-class-static-block": "^7.26.0", - "@babel/plugin-transform-classes": "^7.25.9", - "@babel/plugin-transform-computed-properties": "^7.25.9", - "@babel/plugin-transform-destructuring": "^7.25.9", - "@babel/plugin-transform-dotall-regex": "^7.25.9", - "@babel/plugin-transform-duplicate-keys": "^7.25.9", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", - "@babel/plugin-transform-dynamic-import": "^7.25.9", - "@babel/plugin-transform-exponentiation-operator": "^7.25.9", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-for-of": "^7.25.9", - "@babel/plugin-transform-function-name": "^7.25.9", - "@babel/plugin-transform-json-strings": "^7.25.9", - "@babel/plugin-transform-literals": "^7.25.9", - "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", - "@babel/plugin-transform-member-expression-literals": "^7.25.9", - "@babel/plugin-transform-modules-amd": "^7.25.9", - "@babel/plugin-transform-modules-commonjs": "^7.25.9", - "@babel/plugin-transform-modules-systemjs": "^7.25.9", - "@babel/plugin-transform-modules-umd": "^7.25.9", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", - "@babel/plugin-transform-new-target": "^7.25.9", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.9", - "@babel/plugin-transform-numeric-separator": "^7.25.9", - "@babel/plugin-transform-object-rest-spread": "^7.25.9", - "@babel/plugin-transform-object-super": "^7.25.9", - "@babel/plugin-transform-optional-catch-binding": "^7.25.9", - "@babel/plugin-transform-optional-chaining": "^7.25.9", - "@babel/plugin-transform-parameters": "^7.25.9", - "@babel/plugin-transform-private-methods": "^7.25.9", - "@babel/plugin-transform-private-property-in-object": "^7.25.9", - "@babel/plugin-transform-property-literals": "^7.25.9", - "@babel/plugin-transform-regenerator": "^7.25.9", - "@babel/plugin-transform-regexp-modifiers": "^7.26.0", - "@babel/plugin-transform-reserved-words": "^7.25.9", - "@babel/plugin-transform-shorthand-properties": "^7.25.9", - "@babel/plugin-transform-spread": "^7.25.9", - "@babel/plugin-transform-sticky-regex": "^7.25.9", - "@babel/plugin-transform-template-literals": "^7.25.9", - "@babel/plugin-transform-typeof-symbol": "^7.25.9", - "@babel/plugin-transform-unicode-escapes": "^7.25.9", - "@babel/plugin-transform-unicode-property-regex": "^7.25.9", - "@babel/plugin-transform-unicode-regex": "^7.25.9", - "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.10.6", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.38.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/preset-react": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.25.9.tgz", - "integrity": "sha512-D3to0uSPiWE7rBrdIICCd0tJSIGpLaaGptna2+w7Pft5xMqLpA1sz99DK5TZ1TjGbdQ/VI1eCSZ06dv3lT4JOw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-validator-option": "^7.25.9", - "@babel/plugin-transform-react-display-name": "^7.25.9", - "@babel/plugin-transform-react-jsx": "^7.25.9", - "@babel/plugin-transform-react-jsx-development": "^7.25.9", - "@babel/plugin-transform-react-pure-annotations": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz", - "integrity": "sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-validator-option": "^7.25.9", - "@babel/plugin-syntax-jsx": "^7.25.9", - "@babel/plugin-transform-modules-commonjs": "^7.25.9", - "@babel/plugin-transform-typescript": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", - "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", - "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime-corejs3": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.27.6.tgz", - "integrity": "sha512-vDVrlmRAY8z9Ul/HxT+8ceAru95LQgkSKiXkSYZvqtbkPSfhZJgpRp45Cldbh1GJ1kxzQkI70AqyrTI58KpaWQ==", - "dependencies": { - "core-js-pure": "^3.30.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", - "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/parser": "^7.27.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz", - "integrity": "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@csstools/cascade-layer-name-parser": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz", - "integrity": "sha512-p1ko5eHgV+MgXFVa4STPKpvPxr6ReS8oS2jzTukjR74i5zJNyWO1ZM1m8YKBXnzDKWfBN1ztLYlHxbVemDD88A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", - "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", - "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/color-helpers": "^5.0.2", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/media-query-list-parser": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", - "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/postcss-cascade-layers": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-5.0.2.tgz", - "integrity": "sha512-nWBE08nhO8uWl6kSAeCx4im7QfVko3zLrtgWZY4/bP87zrSPpSyN/3W3TDqz1jJuH+kbKOHXg5rJnK+ZVYcFFg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/selector-specificity": "^5.0.0", - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-cascade-layers/node_modules/@csstools/selector-specificity": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss-selector-parser": "^7.0.0" - } - }, - "node_modules/@csstools/postcss-cascade-layers/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@csstools/postcss-color-function": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-4.0.10.tgz", - "integrity": "sha512-4dY0NBu7NVIpzxZRgh/Q/0GPSz/jLSw0i/u3LTUor0BkQcz/fNhN10mSWBDsL0p9nDb0Ky1PD6/dcGbhACuFTQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-color-parser": "^3.0.10", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-color-mix-function": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-3.0.10.tgz", - "integrity": "sha512-P0lIbQW9I4ShE7uBgZRib/lMTf9XMjJkFl/d6w4EMNHu2qvQ6zljJGEcBkw/NsBtq/6q3WrmgxSS8kHtPMkK4Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-color-parser": "^3.0.10", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-color-mix-variadic-function-arguments": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-variadic-function-arguments/-/postcss-color-mix-variadic-function-arguments-1.0.0.tgz", - "integrity": "sha512-Z5WhouTyD74dPFPrVE7KydgNS9VvnjB8qcdes9ARpCOItb4jTnm7cHp4FhxCRUoyhabD0WVv43wbkJ4p8hLAlQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-color-parser": "^3.0.10", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-content-alt-text": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@csstools/postcss-content-alt-text/-/postcss-content-alt-text-2.0.6.tgz", - "integrity": "sha512-eRjLbOjblXq+byyaedQRSrAejKGNAFued+LcbzT+LCL78fabxHkxYjBbxkroONxHHYu2qxhFK2dBStTLPG3jpQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-exponential-functions": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-exponential-functions/-/postcss-exponential-functions-2.0.9.tgz", - "integrity": "sha512-abg2W/PI3HXwS/CZshSa79kNWNZHdJPMBXeZNyPQFbbj8sKO3jXxOt/wF7juJVjyDTc6JrvaUZYFcSBZBhaxjw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-font-format-keywords": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-4.0.0.tgz", - "integrity": "sha512-usBzw9aCRDvchpok6C+4TXC57btc4bJtmKQWOHQxOVKen1ZfVqBUuCZ/wuqdX5GHsD0NRSr9XTP+5ID1ZZQBXw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-gamut-mapping": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-2.0.10.tgz", - "integrity": "sha512-QDGqhJlvFnDlaPAfCYPsnwVA6ze+8hhrwevYWlnUeSjkkZfBpcCO42SaUD8jiLlq7niouyLgvup5lh+f1qessg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-color-parser": "^3.0.10", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-gradients-interpolation-method": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-5.0.10.tgz", - "integrity": "sha512-HHPauB2k7Oits02tKFUeVFEU2ox/H3OQVrP3fSOKDxvloOikSal+3dzlyTZmYsb9FlY9p5EUpBtz0//XBmy+aw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-color-parser": "^3.0.10", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-hwb-function": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-4.0.10.tgz", - "integrity": "sha512-nOKKfp14SWcdEQ++S9/4TgRKchooLZL0TUFdun3nI4KPwCjETmhjta1QT4ICQcGVWQTvrsgMM/aLB5We+kMHhQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-color-parser": "^3.0.10", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-ic-unit": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-4.0.2.tgz", - "integrity": "sha512-lrK2jjyZwh7DbxaNnIUjkeDmU8Y6KyzRBk91ZkI5h8nb1ykEfZrtIVArdIjX4DHMIBGpdHrgP0n4qXDr7OHaKA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-initial": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-initial/-/postcss-initial-2.0.1.tgz", - "integrity": "sha512-L1wLVMSAZ4wovznquK0xmC7QSctzO4D0Is590bxpGqhqjboLXYA16dWZpfwImkdOgACdQ9PqXsuRroW6qPlEsg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-is-pseudo-class": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-5.0.3.tgz", - "integrity": "sha512-jS/TY4SpG4gszAtIg7Qnf3AS2pjcUM5SzxpApOrlndMeGhIbaTzWBzzP/IApXoNWEW7OhcjkRT48jnAUIFXhAQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/selector-specificity": "^5.0.0", - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-is-pseudo-class/node_modules/@csstools/selector-specificity": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss-selector-parser": "^7.0.0" - } - }, - "node_modules/@csstools/postcss-is-pseudo-class/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@csstools/postcss-light-dark-function": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-2.0.9.tgz", - "integrity": "sha512-1tCZH5bla0EAkFAI2r0H33CDnIBeLUaJh1p+hvvsylJ4svsv2wOmJjJn+OXwUZLXef37GYbRIVKX+X+g6m+3CQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-logical-float-and-clear": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-float-and-clear/-/postcss-logical-float-and-clear-3.0.0.tgz", - "integrity": "sha512-SEmaHMszwakI2rqKRJgE+8rpotFfne1ZS6bZqBoQIicFyV+xT1UF42eORPxJkVJVrH9C0ctUgwMSn3BLOIZldQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-logical-overflow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overflow/-/postcss-logical-overflow-2.0.0.tgz", - "integrity": "sha512-spzR1MInxPuXKEX2csMamshR4LRaSZ3UXVaRGjeQxl70ySxOhMpP2252RAFsg8QyyBXBzuVOOdx1+bVO5bPIzA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-logical-overscroll-behavior": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overscroll-behavior/-/postcss-logical-overscroll-behavior-2.0.0.tgz", - "integrity": "sha512-e/webMjoGOSYfqLunyzByZj5KKe5oyVg/YSbie99VEaSDE2kimFm0q1f6t/6Jo+VVCQ/jbe2Xy+uX+C4xzWs4w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-logical-resize": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-resize/-/postcss-logical-resize-3.0.0.tgz", - "integrity": "sha512-DFbHQOFW/+I+MY4Ycd/QN6Dg4Hcbb50elIJCfnwkRTCX05G11SwViI5BbBlg9iHRl4ytB7pmY5ieAFk3ws7yyg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-logical-viewport-units": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-3.0.4.tgz", - "integrity": "sha512-q+eHV1haXA4w9xBwZLKjVKAWn3W2CMqmpNpZUk5kRprvSiBEGMgrNH3/sJZ8UA3JgyHaOt3jwT9uFa4wLX4EqQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-media-minmax": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-media-minmax/-/postcss-media-minmax-2.0.9.tgz", - "integrity": "sha512-af9Qw3uS3JhYLnCbqtZ9crTvvkR+0Se+bBqSr7ykAnl9yKhk6895z9rf+2F4dClIDJWxgn0iZZ1PSdkhrbs2ig==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/media-query-list-parser": "^4.0.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-media-queries-aspect-ratio-number-values": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-3.0.5.tgz", - "integrity": "sha512-zhAe31xaaXOY2Px8IYfoVTB3wglbJUVigGphFLj6exb7cjZRH9A6adyE22XfFK3P2PzwRk0VDeTJmaxpluyrDg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/media-query-list-parser": "^4.0.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-nested-calc": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-4.0.0.tgz", - "integrity": "sha512-jMYDdqrQQxE7k9+KjstC3NbsmC063n1FTPLCgCRS2/qHUbHM0mNy9pIn4QIiQGs9I/Bg98vMqw7mJXBxa0N88A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-normalize-display-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.0.tgz", - "integrity": "sha512-HlEoG0IDRoHXzXnkV4in47dzsxdsjdz6+j7MLjaACABX2NfvjFS6XVAnpaDyGesz9gK2SC7MbNwdCHusObKJ9Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-oklab-function": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-4.0.10.tgz", - "integrity": "sha512-ZzZUTDd0fgNdhv8UUjGCtObPD8LYxMH+MJsW9xlZaWTV8Ppr4PtxlHYNMmF4vVWGl0T6f8tyWAKjoI6vePSgAg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-color-parser": "^3.0.10", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-progressive-custom-properties": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-4.1.0.tgz", - "integrity": "sha512-YrkI9dx8U4R8Sz2EJaoeD9fI7s7kmeEBfmO+UURNeL6lQI7VxF6sBE+rSqdCBn4onwqmxFdBU3lTwyYb/lCmxA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-random-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-random-function/-/postcss-random-function-2.0.1.tgz", - "integrity": "sha512-q+FQaNiRBhnoSNo+GzqGOIBKoHQ43lYz0ICrV+UudfWnEF6ksS6DsBIJSISKQT2Bvu3g4k6r7t0zYrk5pDlo8w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-relative-color-syntax": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-3.0.10.tgz", - "integrity": "sha512-8+0kQbQGg9yYG8hv0dtEpOMLwB9M+P7PhacgIzVzJpixxV4Eq9AUQtQw8adMmAJU1RBBmIlpmtmm3XTRd/T00g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-color-parser": "^3.0.10", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-scope-pseudo-class": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-scope-pseudo-class/-/postcss-scope-pseudo-class-4.0.1.tgz", - "integrity": "sha512-IMi9FwtH6LMNuLea1bjVMQAsUhFxJnyLSgOp/cpv5hrzWmrUYU5fm0EguNDIIOHUqzXode8F/1qkC/tEo/qN8Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-scope-pseudo-class/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@csstools/postcss-sign-functions": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-sign-functions/-/postcss-sign-functions-1.1.4.tgz", - "integrity": "sha512-P97h1XqRPcfcJndFdG95Gv/6ZzxUBBISem0IDqPZ7WMvc/wlO+yU0c5D/OCpZ5TJoTt63Ok3knGk64N+o6L2Pg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-stepped-value-functions": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-4.0.9.tgz", - "integrity": "sha512-h9btycWrsex4dNLeQfyU3y3w40LMQooJWFMm/SK9lrKguHDcFl4VMkncKKoXi2z5rM9YGWbUQABI8BT2UydIcA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-text-decoration-shorthand": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-4.0.2.tgz", - "integrity": "sha512-8XvCRrFNseBSAGxeaVTaNijAu+FzUvjwFXtcrynmazGb/9WUdsPCpBX+mHEHShVRq47Gy4peYAoxYs8ltUnmzA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/color-helpers": "^5.0.2", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-trigonometric-functions": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-4.0.9.tgz", - "integrity": "sha512-Hnh5zJUdpNrJqK9v1/E3BbrQhaDTj5YiX7P61TOvUhoDHnUmsNNxcDAgkQ32RrcWx9GVUvfUNPcUkn8R3vIX6A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/postcss-unset-value": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-4.0.0.tgz", - "integrity": "sha512-cBz3tOCI5Fw6NIFEwU3RiwK6mn3nKegjpJuzCndoGq3BZPkUjnsq7uQmIeMNeMbMk7YD2MfKcgCpZwX5jyXqCA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@csstools/utilities": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@csstools/utilities/-/utilities-2.0.0.tgz", - "integrity": "sha512-5VdOr0Z71u+Yp3ozOx8T11N703wIFGVRgOWbOZMKgglPJsWA54MRIoMNVMa7shUToIhx5J8vX4sOZgD2XiihiQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/@discoveryjs/json-ext": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", - "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/@docsearch/css": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.9.0.tgz", - "integrity": "sha512-cQbnVbq0rrBwNAKegIac/t6a8nWoUAn8frnkLFW6YARaRmAQr5/Eoe6Ln2fqkUCZ40KpdrKbpSAmgrkviOxuWA==" - }, - "node_modules/@docsearch/react": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.9.0.tgz", - "integrity": "sha512-mb5FOZYZIkRQ6s/NWnM98k879vu5pscWqTLubLFBO87igYYT4VzVazh4h5o/zCvTIZgEt3PvsCOMOswOUo9yHQ==", - "dependencies": { - "@algolia/autocomplete-core": "1.17.9", - "@algolia/autocomplete-preset-algolia": "1.17.9", - "@docsearch/css": "3.9.0", - "algoliasearch": "^5.14.2" - }, - "peerDependencies": { - "@types/react": ">= 16.8.0 < 20.0.0", - "react": ">= 16.8.0 < 20.0.0", - "react-dom": ">= 16.8.0 < 20.0.0", - "search-insights": ">= 1 < 3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "search-insights": { - "optional": true - } - } - }, - "node_modules/@docusaurus/babel": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.8.1.tgz", - "integrity": "sha512-3brkJrml8vUbn9aeoZUlJfsI/GqyFcDgQJwQkmBtclJgWDEQBKKeagZfOgx0WfUQhagL1sQLNW0iBdxnI863Uw==", - "dependencies": { - "@babel/core": "^7.25.9", - "@babel/generator": "^7.25.9", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-transform-runtime": "^7.25.9", - "@babel/preset-env": "^7.25.9", - "@babel/preset-react": "^7.25.9", - "@babel/preset-typescript": "^7.25.9", - "@babel/runtime": "^7.25.9", - "@babel/runtime-corejs3": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@docusaurus/logger": "3.8.1", - "@docusaurus/utils": "3.8.1", - "babel-plugin-dynamic-import-node": "^2.3.3", - "fs-extra": "^11.1.1", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=18.0" - } - }, - "node_modules/@docusaurus/bundler": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.8.1.tgz", - "integrity": "sha512-/z4V0FRoQ0GuSLToNjOSGsk6m2lQUG4FRn8goOVoZSRsTrU8YR2aJacX5K3RG18EaX9b+52pN4m1sL3MQZVsQA==", - "dependencies": { - "@babel/core": "^7.25.9", - "@docusaurus/babel": "3.8.1", - "@docusaurus/cssnano-preset": "3.8.1", - "@docusaurus/logger": "3.8.1", - "@docusaurus/types": "3.8.1", - "@docusaurus/utils": "3.8.1", - "babel-loader": "^9.2.1", - "clean-css": "^5.3.3", - "copy-webpack-plugin": "^11.0.0", - "css-loader": "^6.11.0", - "css-minimizer-webpack-plugin": "^5.0.1", - "cssnano": "^6.1.2", - "file-loader": "^6.2.0", - "html-minifier-terser": "^7.2.0", - "mini-css-extract-plugin": "^2.9.2", - "null-loader": "^4.0.1", - "postcss": "^8.5.4", - "postcss-loader": "^7.3.4", - "postcss-preset-env": "^10.2.1", - "terser-webpack-plugin": "^5.3.9", - "tslib": "^2.6.0", - "url-loader": "^4.1.1", - "webpack": "^5.95.0", - "webpackbar": "^6.0.1" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "@docusaurus/faster": "*" - }, - "peerDependenciesMeta": { - "@docusaurus/faster": { - "optional": true - } - } - }, - "node_modules/@docusaurus/core": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.8.1.tgz", - "integrity": "sha512-ENB01IyQSqI2FLtOzqSI3qxG2B/jP4gQPahl2C3XReiLebcVh5B5cB9KYFvdoOqOWPyr5gXK4sjgTKv7peXCrA==", - "dependencies": { - "@docusaurus/babel": "3.8.1", - "@docusaurus/bundler": "3.8.1", - "@docusaurus/logger": "3.8.1", - "@docusaurus/mdx-loader": "3.8.1", - "@docusaurus/utils": "3.8.1", - "@docusaurus/utils-common": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "boxen": "^6.2.1", - "chalk": "^4.1.2", - "chokidar": "^3.5.3", - "cli-table3": "^0.6.3", - "combine-promises": "^1.1.0", - "commander": "^5.1.0", - "core-js": "^3.31.1", - "detect-port": "^1.5.1", - "escape-html": "^1.0.3", - "eta": "^2.2.0", - "eval": "^0.1.8", - "execa": "5.1.1", - "fs-extra": "^11.1.1", - "html-tags": "^3.3.1", - "html-webpack-plugin": "^5.6.0", - "leven": "^3.1.0", - "lodash": "^4.17.21", - "open": "^8.4.0", - "p-map": "^4.0.0", - "prompts": "^2.4.2", - "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", - "react-loadable": "npm:@docusaurus/react-loadable@6.0.0", - "react-loadable-ssr-addon-v5-slorber": "^1.0.1", - "react-router": "^5.3.4", - "react-router-config": "^5.1.1", - "react-router-dom": "^5.3.4", - "semver": "^7.5.4", - "serve-handler": "^6.1.6", - "tinypool": "^1.0.2", - "tslib": "^2.6.0", - "update-notifier": "^6.0.2", - "webpack": "^5.95.0", - "webpack-bundle-analyzer": "^4.10.2", - "webpack-dev-server": "^4.15.2", - "webpack-merge": "^6.0.1" - }, - "bin": { - "docusaurus": "bin/docusaurus.mjs" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "@mdx-js/react": "^3.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/cssnano-preset": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.8.1.tgz", - "integrity": "sha512-G7WyR2N6SpyUotqhGznERBK+x84uyhfMQM2MmDLs88bw4Flom6TY46HzkRkSEzaP9j80MbTN8naiL1fR17WQug==", - "dependencies": { - "cssnano-preset-advanced": "^6.1.2", - "postcss": "^8.5.4", - "postcss-sort-media-queries": "^5.2.0", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=18.0" - } - }, - "node_modules/@docusaurus/logger": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.8.1.tgz", - "integrity": "sha512-2wjeGDhKcExEmjX8k1N/MRDiPKXGF2Pg+df/bDDPnnJWHXnVEZxXj80d6jcxp1Gpnksl0hF8t/ZQw9elqj2+ww==", - "dependencies": { - "chalk": "^4.1.2", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=18.0" - } - }, - "node_modules/@docusaurus/mdx-loader": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.8.1.tgz", - "integrity": "sha512-DZRhagSFRcEq1cUtBMo4TKxSNo/W6/s44yhr8X+eoXqCLycFQUylebOMPseHi5tc4fkGJqwqpWJLz6JStU9L4w==", - "dependencies": { - "@docusaurus/logger": "3.8.1", - "@docusaurus/utils": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "@mdx-js/mdx": "^3.0.0", - "@slorber/remark-comment": "^1.0.0", - "escape-html": "^1.0.3", - "estree-util-value-to-estree": "^3.0.1", - "file-loader": "^6.2.0", - "fs-extra": "^11.1.1", - "image-size": "^2.0.2", - "mdast-util-mdx": "^3.0.0", - "mdast-util-to-string": "^4.0.0", - "rehype-raw": "^7.0.0", - "remark-directive": "^3.0.0", - "remark-emoji": "^4.0.0", - "remark-frontmatter": "^5.0.0", - "remark-gfm": "^4.0.0", - "stringify-object": "^3.3.0", - "tslib": "^2.6.0", - "unified": "^11.0.3", - "unist-util-visit": "^5.0.0", - "url-loader": "^4.1.1", - "vfile": "^6.0.1", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/module-type-aliases": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.8.1.tgz", - "integrity": "sha512-6xhvAJiXzsaq3JdosS7wbRt/PwEPWHr9eM4YNYqVlbgG1hSK3uQDXTVvQktasp3VO6BmfYWPozueLWuj4gB+vg==", - "dependencies": { - "@docusaurus/types": "3.8.1", - "@types/history": "^4.7.11", - "@types/react": "*", - "@types/react-router-config": "*", - "@types/react-router-dom": "*", - "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", - "react-loadable": "npm:@docusaurus/react-loadable@6.0.0" - }, - "peerDependencies": { - "react": "*", - "react-dom": "*" - } - }, - "node_modules/@docusaurus/plugin-client-redirects": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.8.1.tgz", - "integrity": "sha512-F+86R7PBn6VNgy/Ux8w3ZRypJGJEzksbejQKlbTC8u6uhBUhfdXWkDp6qdOisIoW0buY5nLqucvZt1zNJzhJhA==", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/logger": "3.8.1", - "@docusaurus/utils": "3.8.1", - "@docusaurus/utils-common": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "eta": "^2.2.0", - "fs-extra": "^11.1.1", - "lodash": "^4.17.21", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-content-blog": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.8.1.tgz", - "integrity": "sha512-vNTpMmlvNP9n3hGEcgPaXyvTljanAKIUkuG9URQ1DeuDup0OR7Ltvoc8yrmH+iMZJbcQGhUJF+WjHLwuk8HSdw==", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/logger": "3.8.1", - "@docusaurus/mdx-loader": "3.8.1", - "@docusaurus/theme-common": "3.8.1", - "@docusaurus/types": "3.8.1", - "@docusaurus/utils": "3.8.1", - "@docusaurus/utils-common": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "cheerio": "1.0.0-rc.12", - "feed": "^4.2.2", - "fs-extra": "^11.1.1", - "lodash": "^4.17.21", - "schema-dts": "^1.1.2", - "srcset": "^4.0.0", - "tslib": "^2.6.0", - "unist-util-visit": "^5.0.0", - "utility-types": "^3.10.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "@docusaurus/plugin-content-docs": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-content-docs": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.1.tgz", - "integrity": "sha512-oByRkSZzeGNQByCMaX+kif5Nl2vmtj2IHQI2fWjCfCootsdKZDPFLonhIp5s3IGJO7PLUfe0POyw0Xh/RrGXJA==", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/logger": "3.8.1", - "@docusaurus/mdx-loader": "3.8.1", - "@docusaurus/module-type-aliases": "3.8.1", - "@docusaurus/theme-common": "3.8.1", - "@docusaurus/types": "3.8.1", - "@docusaurus/utils": "3.8.1", - "@docusaurus/utils-common": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "@types/react-router-config": "^5.0.7", - "combine-promises": "^1.1.0", - "fs-extra": "^11.1.1", - "js-yaml": "^4.1.0", - "lodash": "^4.17.21", - "schema-dts": "^1.1.2", - "tslib": "^2.6.0", - "utility-types": "^3.10.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-content-pages": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.8.1.tgz", - "integrity": "sha512-a+V6MS2cIu37E/m7nDJn3dcxpvXb6TvgdNI22vJX8iUTp8eoMoPa0VArEbWvCxMY/xdC26WzNv4wZ6y0iIni/w==", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/mdx-loader": "3.8.1", - "@docusaurus/types": "3.8.1", - "@docusaurus/utils": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "fs-extra": "^11.1.1", - "tslib": "^2.6.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-css-cascade-layers": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.8.1.tgz", - "integrity": "sha512-VQ47xRxfNKjHS5ItzaVXpxeTm7/wJLFMOPo1BkmoMG4Cuz4nuI+Hs62+RMk1OqVog68Swz66xVPK8g9XTrBKRw==", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/types": "3.8.1", - "@docusaurus/utils": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=18.0" - } - }, - "node_modules/@docusaurus/plugin-debug": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.8.1.tgz", - "integrity": "sha512-nT3lN7TV5bi5hKMB7FK8gCffFTBSsBsAfV84/v293qAmnHOyg1nr9okEw8AiwcO3bl9vije5nsUvP0aRl2lpaw==", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/types": "3.8.1", - "@docusaurus/utils": "3.8.1", - "fs-extra": "^11.1.1", - "react-json-view-lite": "^2.3.0", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-google-analytics": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.8.1.tgz", - "integrity": "sha512-Hrb/PurOJsmwHAsfMDH6oVpahkEGsx7F8CWMjyP/dw1qjqmdS9rcV1nYCGlM8nOtD3Wk/eaThzUB5TSZsGz+7Q==", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/types": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-google-gtag": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.8.1.tgz", - "integrity": "sha512-tKE8j1cEZCh8KZa4aa80zpSTxsC2/ZYqjx6AAfd8uA8VHZVw79+7OTEP2PoWi0uL5/1Is0LF5Vwxd+1fz5HlKg==", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/types": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "@types/gtag.js": "^0.0.12", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-google-tag-manager": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.8.1.tgz", - "integrity": "sha512-iqe3XKITBquZq+6UAXdb1vI0fPY5iIOitVjPQ581R1ZKpHr0qe+V6gVOrrcOHixPDD/BUKdYwkxFjpNiEN+vBw==", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/types": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-sitemap": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.8.1.tgz", - "integrity": "sha512-+9YV/7VLbGTq8qNkjiugIelmfUEVkTyLe6X8bWq7K5qPvGXAjno27QAfFq63mYfFFbJc7z+pudL63acprbqGzw==", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/logger": "3.8.1", - "@docusaurus/types": "3.8.1", - "@docusaurus/utils": "3.8.1", - "@docusaurus/utils-common": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "fs-extra": "^11.1.1", - "sitemap": "^7.1.1", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/plugin-svgr": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.8.1.tgz", - "integrity": "sha512-rW0LWMDsdlsgowVwqiMb/7tANDodpy1wWPwCcamvhY7OECReN3feoFwLjd/U4tKjNY3encj0AJSTxJA+Fpe+Gw==", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/types": "3.8.1", - "@docusaurus/utils": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "@svgr/core": "8.1.0", - "@svgr/webpack": "^8.1.0", - "tslib": "^2.6.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/preset-classic": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.8.1.tgz", - "integrity": "sha512-yJSjYNHXD8POMGc2mKQuj3ApPrN+eG0rO1UPgSx7jySpYU+n4WjBikbrA2ue5ad9A7aouEtMWUoiSRXTH/g7KQ==", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/plugin-content-blog": "3.8.1", - "@docusaurus/plugin-content-docs": "3.8.1", - "@docusaurus/plugin-content-pages": "3.8.1", - "@docusaurus/plugin-css-cascade-layers": "3.8.1", - "@docusaurus/plugin-debug": "3.8.1", - "@docusaurus/plugin-google-analytics": "3.8.1", - "@docusaurus/plugin-google-gtag": "3.8.1", - "@docusaurus/plugin-google-tag-manager": "3.8.1", - "@docusaurus/plugin-sitemap": "3.8.1", - "@docusaurus/plugin-svgr": "3.8.1", - "@docusaurus/theme-classic": "3.8.1", - "@docusaurus/theme-common": "3.8.1", - "@docusaurus/theme-search-algolia": "3.8.1", - "@docusaurus/types": "3.8.1" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/theme-classic": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.8.1.tgz", - "integrity": "sha512-bqDUCNqXeYypMCsE1VcTXSI1QuO4KXfx8Cvl6rYfY0bhhqN6d2WZlRkyLg/p6pm+DzvanqHOyYlqdPyP0iz+iw==", - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/logger": "3.8.1", - "@docusaurus/mdx-loader": "3.8.1", - "@docusaurus/module-type-aliases": "3.8.1", - "@docusaurus/plugin-content-blog": "3.8.1", - "@docusaurus/plugin-content-docs": "3.8.1", - "@docusaurus/plugin-content-pages": "3.8.1", - "@docusaurus/theme-common": "3.8.1", - "@docusaurus/theme-translations": "3.8.1", - "@docusaurus/types": "3.8.1", - "@docusaurus/utils": "3.8.1", - "@docusaurus/utils-common": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "@mdx-js/react": "^3.0.0", - "clsx": "^2.0.0", - "copy-text-to-clipboard": "^3.2.0", - "infima": "0.2.0-alpha.45", - "lodash": "^4.17.21", - "nprogress": "^0.2.0", - "postcss": "^8.5.4", - "prism-react-renderer": "^2.3.0", - "prismjs": "^1.29.0", - "react-router-dom": "^5.3.4", - "rtlcss": "^4.1.0", - "tslib": "^2.6.0", - "utility-types": "^3.10.0" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/theme-common": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.8.1.tgz", - "integrity": "sha512-UswMOyTnPEVRvN5Qzbo+l8k4xrd5fTFu2VPPfD6FcW/6qUtVLmJTQCktbAL3KJ0BVXGm5aJXz/ZrzqFuZERGPw==", - "dependencies": { - "@docusaurus/mdx-loader": "3.8.1", - "@docusaurus/module-type-aliases": "3.8.1", - "@docusaurus/utils": "3.8.1", - "@docusaurus/utils-common": "3.8.1", - "@types/history": "^4.7.11", - "@types/react": "*", - "@types/react-router-config": "*", - "clsx": "^2.0.0", - "parse-numeric-range": "^1.3.0", - "prism-react-renderer": "^2.3.0", - "tslib": "^2.6.0", - "utility-types": "^3.10.0" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "@docusaurus/plugin-content-docs": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/theme-search-algolia": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.8.1.tgz", - "integrity": "sha512-NBFH5rZVQRAQM087aYSRKQ9yGEK9eHd+xOxQjqNpxMiV85OhJDD4ZGz6YJIod26Fbooy54UWVdzNU0TFeUUUzQ==", - "dependencies": { - "@docsearch/react": "^3.9.0", - "@docusaurus/core": "3.8.1", - "@docusaurus/logger": "3.8.1", - "@docusaurus/plugin-content-docs": "3.8.1", - "@docusaurus/theme-common": "3.8.1", - "@docusaurus/theme-translations": "3.8.1", - "@docusaurus/utils": "3.8.1", - "@docusaurus/utils-validation": "3.8.1", - "algoliasearch": "^5.17.1", - "algoliasearch-helper": "^3.22.6", - "clsx": "^2.0.0", - "eta": "^2.2.0", - "fs-extra": "^11.1.1", - "lodash": "^4.17.21", - "tslib": "^2.6.0", - "utility-types": "^3.10.0" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/theme-translations": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.8.1.tgz", - "integrity": "sha512-OTp6eebuMcf2rJt4bqnvuwmm3NVXfzfYejL+u/Y1qwKhZPrjPoKWfk1CbOP5xH5ZOPkiAsx4dHdQBRJszK3z2g==", - "dependencies": { - "fs-extra": "^11.1.1", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=18.0" - } - }, - "node_modules/@docusaurus/types": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.8.1.tgz", - "integrity": "sha512-ZPdW5AB+pBjiVrcLuw3dOS6BFlrG0XkS2lDGsj8TizcnREQg3J8cjsgfDviszOk4CweNfwo1AEELJkYaMUuOPg==", - "dependencies": { - "@mdx-js/mdx": "^3.0.0", - "@types/history": "^4.7.11", - "@types/react": "*", - "commander": "^5.1.0", - "joi": "^17.9.2", - "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", - "utility-types": "^3.10.0", - "webpack": "^5.95.0", - "webpack-merge": "^5.9.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@docusaurus/types/node_modules/webpack-merge": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", - "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", - "license": "MIT", - "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.0" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/@docusaurus/utils": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.8.1.tgz", - "integrity": "sha512-P1ml0nvOmEFdmu0smSXOqTS1sxU5tqvnc0dA4MTKV39kye+bhQnjkIKEE18fNOvxjyB86k8esoCIFM3x4RykOQ==", - "dependencies": { - "@docusaurus/logger": "3.8.1", - "@docusaurus/types": "3.8.1", - "@docusaurus/utils-common": "3.8.1", - "escape-string-regexp": "^4.0.0", - "execa": "5.1.1", - "file-loader": "^6.2.0", - "fs-extra": "^11.1.1", - "github-slugger": "^1.5.0", - "globby": "^11.1.0", - "gray-matter": "^4.0.3", - "jiti": "^1.20.0", - "js-yaml": "^4.1.0", - "lodash": "^4.17.21", - "micromatch": "^4.0.5", - "p-queue": "^6.6.2", - "prompts": "^2.4.2", - "resolve-pathname": "^3.0.0", - "tslib": "^2.6.0", - "url-loader": "^4.1.1", - "utility-types": "^3.10.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=18.0" - } - }, - "node_modules/@docusaurus/utils-common": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.8.1.tgz", - "integrity": "sha512-zTZiDlvpvoJIrQEEd71c154DkcriBecm4z94OzEE9kz7ikS3J+iSlABhFXM45mZ0eN5pVqqr7cs60+ZlYLewtg==", - "dependencies": { - "@docusaurus/types": "3.8.1", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=18.0" - } - }, - "node_modules/@docusaurus/utils-validation": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.8.1.tgz", - "integrity": "sha512-gs5bXIccxzEbyVecvxg6upTwaUbfa0KMmTj7HhHzc016AGyxH2o73k1/aOD0IFrdCsfJNt37MqNI47s2MgRZMA==", - "dependencies": { - "@docusaurus/logger": "3.8.1", - "@docusaurus/utils": "3.8.1", - "@docusaurus/utils-common": "3.8.1", - "fs-extra": "^11.2.0", - "joi": "^17.9.2", - "js-yaml": "^4.1.0", - "lodash": "^4.17.21", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=18.0" - } - }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "license": "MIT" - }, - "node_modules/@mdx-js/mdx": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.0.1.tgz", - "integrity": "sha512-eIQ4QTrOWyL3LWEe/bu6Taqzq2HQvHcyTMaOrI95P2/LmJE7AsfPfgJGuFLPVqBUE1BC1rik3VIhU+s9u72arA==", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdx": "^2.0.0", - "collapse-white-space": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-build-jsx": "^3.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "estree-util-to-js": "^2.0.0", - "estree-walker": "^3.0.0", - "hast-util-to-estree": "^3.0.0", - "hast-util-to-jsx-runtime": "^2.0.0", - "markdown-extensions": "^2.0.0", - "periscopic": "^3.0.0", - "remark-mdx": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.0.0", - "source-map": "^0.7.0", - "unified": "^11.0.0", - "unist-util-position-from-estree": "^2.0.0", - "unist-util-stringify-position": "^4.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/@mdx-js/react": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz", - "integrity": "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==", - "dependencies": { - "@types/mdx": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=16", - "react": ">=16" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@pnpm/config.env-replace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", - "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", - "license": "MIT", - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", - "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", - "license": "MIT", - "dependencies": { - "graceful-fs": "4.2.10" - }, - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "license": "ISC" - }, - "node_modules/@pnpm/npm-conf": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.3.1.tgz", - "integrity": "sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==", - "license": "MIT", - "dependencies": { - "@pnpm/config.env-replace": "^1.1.0", - "@pnpm/network.ca-file": "^1.0.1", - "config-chain": "^1.1.11" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.28", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", - "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==", - "license": "MIT" - }, - "node_modules/@sideway/address": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", - "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" - }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@slorber/remark-comment": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@slorber/remark-comment/-/remark-comment-1.0.0.tgz", - "integrity": "sha512-RCE24n7jsOj1M0UPvIQCHTe7fI0sFL4S2nwKVWwHyVr/wI/H8GosgsJGyhnsZoGFnD/P2hLf1mSbrrgSLN93NA==", - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.1.0", - "micromark-util-symbol": "^1.0.1" - } - }, - "node_modules/@svgr/babel-plugin-add-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", - "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", - "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-svg-dynamic-title": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", - "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-svg-em-dimensions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", - "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-transform-react-native-svg": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", - "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-transform-svg-component": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", - "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-preset": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", - "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", - "dependencies": { - "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", - "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", - "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", - "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", - "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", - "@svgr/babel-plugin-transform-svg-component": "8.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/core": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", - "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", - "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "camelcase": "^6.2.0", - "cosmiconfig": "^8.1.3", - "snake-case": "^3.0.4" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/hast-util-to-babel-ast": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", - "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", - "dependencies": { - "@babel/types": "^7.21.3", - "entities": "^4.4.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/plugin-jsx": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", - "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", - "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "@svgr/hast-util-to-babel-ast": "8.0.0", - "svg-parser": "^2.0.4" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@svgr/core": "*" - } - }, - "node_modules/@svgr/plugin-svgo": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-8.1.0.tgz", - "integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==", - "dependencies": { - "cosmiconfig": "^8.1.3", - "deepmerge": "^4.3.1", - "svgo": "^3.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@svgr/core": "*" - } - }, - "node_modules/@svgr/webpack": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz", - "integrity": "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==", - "dependencies": { - "@babel/core": "^7.21.3", - "@babel/plugin-transform-react-constant-elements": "^7.21.3", - "@babel/preset-env": "^7.20.2", - "@babel/preset-react": "^7.18.6", - "@babel/preset-typescript": "^7.21.0", - "@svgr/core": "8.1.0", - "@svgr/plugin-jsx": "8.1.0", - "@svgr/plugin-svgo": "8.1.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", - "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.1" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/@types/acorn": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz", - "integrity": "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/bonjour": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", - "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", - "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", - "license": "MIT", - "dependencies": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", - "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/express/node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/gtag.js": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.12.tgz", - "integrity": "sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==" - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/history": { - "version": "4.7.11", - "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", - "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==" - }, - "node_modules/@types/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", - "license": "MIT" - }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", - "license": "MIT" - }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "license": "MIT" - }, - "node_modules/@types/http-proxy": { - "version": "1.17.16", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", - "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mdx": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", - "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "license": "MIT" - }, - "node_modules/@types/ms": { - "version": "0.7.34", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", - "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" - }, - "node_modules/@types/node": { - "version": "20.14.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz", - "integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/node-forge": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", - "integrity": "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/prismjs": { - "version": "1.26.4", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.4.tgz", - "integrity": "sha512-rlAnzkW2sZOjbqZ743IHUhFcvzaGbqijwOu8QZnZCjfQzBqFE3s4lOTJEsxikImav9uzz/42I+O7YUs1mWgMlg==" - }, - "node_modules/@types/prop-types": { - "version": "15.7.12", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" - }, - "node_modules/@types/qs": { - "version": "6.9.18", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", - "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.3", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", - "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-router": { - "version": "5.1.20", - "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", - "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", - "dependencies": { - "@types/history": "^4.7.11", - "@types/react": "*" - } - }, - "node_modules/@types/react-router-config": { - "version": "5.0.11", - "resolved": "https://registry.npmjs.org/@types/react-router-config/-/react-router-config-5.0.11.tgz", - "integrity": "sha512-WmSAg7WgqW7m4x8Mt4N6ZyKz0BubSj/2tVUMsAHp+Yd2AMwcSbeFq9WympT19p5heCFmF97R9eD5uUR/t4HEqw==", - "dependencies": { - "@types/history": "^4.7.11", - "@types/react": "*", - "@types/react-router": "^5.1.0" - } - }, - "node_modules/@types/react-router-dom": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", - "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", - "license": "MIT", - "dependencies": { - "@types/history": "^4.7.11", - "@types/react": "*", - "@types/react-router": "*" - } - }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, - "node_modules/@types/sax": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", - "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/@types/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", - "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==" - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==" - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", - "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", - "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.12.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", - "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", - "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", - "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-opt": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1", - "@webassemblyjs/wast-printer": "1.12.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", - "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", - "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", - "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-api-error": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", - "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/address": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", - "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/algoliasearch": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.30.0.tgz", - "integrity": "sha512-ILSdPX4je0n5WUKD34TMe57/eqiXUzCIjAsdtLQYhomqOjTtFUg1s6dE7kUegc4Mc43Xr7IXYlMutU9HPiYfdw==", - "dependencies": { - "@algolia/client-abtesting": "5.30.0", - "@algolia/client-analytics": "5.30.0", - "@algolia/client-common": "5.30.0", - "@algolia/client-insights": "5.30.0", - "@algolia/client-personalization": "5.30.0", - "@algolia/client-query-suggestions": "5.30.0", - "@algolia/client-search": "5.30.0", - "@algolia/ingestion": "1.30.0", - "@algolia/monitoring": "1.30.0", - "@algolia/recommend": "5.30.0", - "@algolia/requester-browser-xhr": "5.30.0", - "@algolia/requester-fetch": "5.30.0", - "@algolia/requester-node-http": "5.30.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/algoliasearch-helper": { - "version": "3.26.0", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.26.0.tgz", - "integrity": "sha512-Rv2x3GXleQ3ygwhkhJubhhYGsICmShLAiqtUuJTUkr9uOCOXyF2E71LVT4XDnVffbknv8XgScP4U0Oxtgm+hIw==", - "dependencies": { - "@algolia/events": "^4.0.1" - }, - "peerDependencies": { - "algoliasearch": ">= 3.1 < 6" - } - }, - "node_modules/ansi-align": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", - "license": "ISC", - "dependencies": { - "string-width": "^4.1.0" - } - }, - "node_modules/ansi-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/ansi-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "engines": [ - "node >= 0.8.0" - ], - "license": "Apache-2.0", - "bin": { - "ansi-html": "bin/ansi-html" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/astring": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/astring/-/astring-1.8.6.tgz", - "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", - "bin": { - "astring": "bin/astring" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/babel-loader": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", - "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", - "dependencies": { - "find-cache-dir": "^4.0.0", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 14.15.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0", - "webpack": ">=5" - } - }, - "node_modules/babel-plugin-dynamic-import-node": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", - "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", - "dependencies": { - "object.assign": "^4.1.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.11", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz", - "integrity": "sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==", - "dependencies": { - "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.2", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.10.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", - "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2", - "core-js-compat": "^3.38.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz", - "integrity": "sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "license": "MIT" - }, - "node_modules/big.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "engines": { - "node": "*" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/bonjour-service": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", - "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" - }, - "node_modules/boxen": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-6.2.1.tgz", - "integrity": "sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw==", - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^6.2.0", - "chalk": "^4.1.2", - "cli-boxes": "^3.0.0", - "string-width": "^5.0.1", - "type-fest": "^2.5.0", - "widest-line": "^4.0.1", - "wrap-ansi": "^8.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", - "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "caniuse-lite": "^1.0.30001718", - "electron-to-chromium": "^1.5.160", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" - }, - "node_modules/bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cacheable-lookup": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", - "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/cacheable-request": { - "version": "10.2.14", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", - "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "^4.0.2", - "get-stream": "^6.0.1", - "http-cache-semantics": "^4.1.1", - "keyv": "^4.5.3", - "mimic-response": "^4.0.0", - "normalize-url": "^8.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "engines": { - "node": ">=6" - } - }, - "node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "license": "MIT", - "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/caniuse-api": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", - "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", - "dependencies": { - "browserslist": "^4.0.0", - "caniuse-lite": "^1.0.0", - "lodash.memoize": "^4.1.2", - "lodash.uniq": "^4.5.0" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001720", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001720.tgz", - "integrity": "sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/cheerio": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", - "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", - "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "htmlparser2": "^8.0.1", - "parse5": "^7.0.0", - "parse5-htmlparser2-tree-adapter": "^7.0.0" - }, - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" - } - }, - "node_modules/cheerio-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "dependencies": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "engines": { - "node": ">=6.0" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/clean-css": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", - "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", - "license": "MIT", - "dependencies": { - "source-map": "~0.6.0" - }, - "engines": { - "node": ">= 10.0" - } - }, - "node_modules/clean-css/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-table3": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", - "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", - "license": "MIT", - "dependencies": { - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "@colors/colors": "1.5.0" - } - }, - "node_modules/cli-table3/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/cli-table3/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/collapse-white-space": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", - "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/colord": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "license": "MIT" - }, - "node_modules/combine-promises": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/combine-promises/-/combine-promises-1.2.0.tgz", - "integrity": "sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ==", - "engines": { - "node": ">=10" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==" - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compressible/node_modules/mime-db": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", - "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" - }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "license": "MIT", - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, - "node_modules/configstore": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-6.0.0.tgz", - "integrity": "sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==", - "license": "BSD-2-Clause", - "dependencies": { - "dot-prop": "^6.0.1", - "graceful-fs": "^4.2.6", - "unique-string": "^3.0.0", - "write-file-atomic": "^3.0.3", - "xdg-basedir": "^5.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/yeoman/configstore?sponsor=1" - } - }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/content-disposition": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", - "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" - }, - "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "license": "MIT" - }, - "node_modules/copy-text-to-clipboard": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.0.tgz", - "integrity": "sha512-RnJFp1XR/LOBDckxTib5Qjr/PMfkatD0MUCQgdpqS8MdKiNUzBjAQBEN6oUy+jW7LI93BBG3DtMB2KOOKpGs2Q==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/copy-webpack-plugin": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", - "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", - "dependencies": { - "fast-glob": "^3.2.11", - "glob-parent": "^6.0.1", - "globby": "^13.1.1", - "normalize-path": "^3.0.0", - "schema-utils": "^4.0.0", - "serialize-javascript": "^6.0.0" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - } - }, - "node_modules/copy-webpack-plugin/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/copy-webpack-plugin/node_modules/globby": { - "version": "13.2.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", - "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", - "dependencies": { - "dir-glob": "^3.0.1", - "fast-glob": "^3.3.0", - "ignore": "^5.2.4", - "merge2": "^1.4.1", - "slash": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/copy-webpack-plugin/node_modules/slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/core-js": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz", - "integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-js-compat": { - "version": "3.42.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz", - "integrity": "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.24.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-js-pure": { - "version": "3.43.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.43.0.tgz", - "integrity": "sha512-i/AgxU2+A+BbJdMxh3v7/vxi2SbFqxiFmg6VsDwYB4jkucrd1BZNA9a9gphC0fYMG5IBSgQcbQnk865VCLe7xA==", - "hasInstallScript": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", - "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/crypto-random-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", - "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", - "license": "MIT", - "dependencies": { - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/crypto-random-string/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/css-blank-pseudo": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-7.0.1.tgz", - "integrity": "sha512-jf+twWGDf6LDoXDUode+nc7ZlrqfaNphrBIBrcmeP3D8yw1uPaix1gCC8LUQUGQ6CycuK2opkbFFWFuq/a94ag==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/css-blank-pseudo/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/css-declaration-sorter": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz", - "integrity": "sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==", - "engines": { - "node": "^14 || ^16 || >=18" - }, - "peerDependencies": { - "postcss": "^8.0.9" - } - }, - "node_modules/css-has-pseudo": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-7.0.2.tgz", - "integrity": "sha512-nzol/h+E0bId46Kn2dQH5VElaknX2Sr0hFuB/1EomdC7j+OISt2ZzK7EHX9DZDY53WbIVAR7FYKSO2XnSf07MQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/selector-specificity": "^5.0.0", - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/css-has-pseudo/node_modules/@csstools/selector-specificity": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss-selector-parser": "^7.0.0" - } - }, - "node_modules/css-has-pseudo/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/css-loader": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", - "integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==", - "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.33", - "postcss-modules-extract-imports": "^3.1.0", - "postcss-modules-local-by-default": "^4.0.5", - "postcss-modules-scope": "^3.2.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/css-minimizer-webpack-plugin": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-5.0.1.tgz", - "integrity": "sha512-3caImjKFQkS+ws1TGcFn0V1HyDJFq1Euy589JlD6/3rV2kj+w7r5G9WDMgSHvpvXHNZ2calVypZWuEDQd9wfLg==", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "cssnano": "^6.0.1", - "jest-worker": "^29.4.3", - "postcss": "^8.4.24", - "schema-utils": "^4.0.1", - "serialize-javascript": "^6.0.1" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@parcel/css": { - "optional": true - }, - "@swc/css": { - "optional": true - }, - "clean-css": { - "optional": true - }, - "csso": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "lightningcss": { - "optional": true - } - } - }, - "node_modules/css-prefers-color-scheme": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-10.0.0.tgz", - "integrity": "sha512-VCtXZAWivRglTZditUfB4StnsWr6YVZ2PRtuxQLKTNRdtAf8tpzaVPE9zXIF3VaSc7O70iK/j1+NXxyQCqdPjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-tree": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", - "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", - "dependencies": { - "mdn-data": "2.0.30", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/cssdb": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.3.1.tgz", - "integrity": "sha512-XnDRQMXucLueX92yDe0LPKupXetWoFOgawr4O4X41l5TltgK2NVbJJVDnnOywDYfW1sTJ28AcXGKOqdRKwCcmQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - } - ] - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssnano": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.1.2.tgz", - "integrity": "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==", - "dependencies": { - "cssnano-preset-default": "^6.1.2", - "lilconfig": "^3.1.1" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/cssnano" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/cssnano-preset-advanced": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-6.1.2.tgz", - "integrity": "sha512-Nhao7eD8ph2DoHolEzQs5CfRpiEP0xa1HBdnFZ82kvqdmbwVBUr2r1QuQ4t1pi+D1ZpqpcO4T+wy/7RxzJ/WPQ==", - "dependencies": { - "autoprefixer": "^10.4.19", - "browserslist": "^4.23.0", - "cssnano-preset-default": "^6.1.2", - "postcss-discard-unused": "^6.0.5", - "postcss-merge-idents": "^6.0.3", - "postcss-reduce-idents": "^6.0.3", - "postcss-zindex": "^6.0.2" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/cssnano-preset-default": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.2.tgz", - "integrity": "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==", - "dependencies": { - "browserslist": "^4.23.0", - "css-declaration-sorter": "^7.2.0", - "cssnano-utils": "^4.0.2", - "postcss-calc": "^9.0.1", - "postcss-colormin": "^6.1.0", - "postcss-convert-values": "^6.1.0", - "postcss-discard-comments": "^6.0.2", - "postcss-discard-duplicates": "^6.0.3", - "postcss-discard-empty": "^6.0.3", - "postcss-discard-overridden": "^6.0.2", - "postcss-merge-longhand": "^6.0.5", - "postcss-merge-rules": "^6.1.1", - "postcss-minify-font-values": "^6.1.0", - "postcss-minify-gradients": "^6.0.3", - "postcss-minify-params": "^6.1.0", - "postcss-minify-selectors": "^6.0.4", - "postcss-normalize-charset": "^6.0.2", - "postcss-normalize-display-values": "^6.0.2", - "postcss-normalize-positions": "^6.0.2", - "postcss-normalize-repeat-style": "^6.0.2", - "postcss-normalize-string": "^6.0.2", - "postcss-normalize-timing-functions": "^6.0.2", - "postcss-normalize-unicode": "^6.1.0", - "postcss-normalize-url": "^6.0.2", - "postcss-normalize-whitespace": "^6.0.2", - "postcss-ordered-values": "^6.0.2", - "postcss-reduce-initial": "^6.1.0", - "postcss-reduce-transforms": "^6.0.2", - "postcss-svgo": "^6.0.3", - "postcss-unique-selectors": "^6.0.4" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/cssnano-utils": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz", - "integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/csso": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", - "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", - "dependencies": { - "css-tree": "~2.2.0" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/csso/node_modules/css-tree": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", - "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", - "dependencies": { - "mdn-data": "2.0.28", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.28", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==" - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" - }, - "node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", - "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/default-gateway": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", - "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", - "license": "BSD-2-Clause", - "dependencies": { - "execa": "^5.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT" - }, - "node_modules/detect-port": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.6.1.tgz", - "integrity": "sha512-CmnVc+Hek2egPx1PeTFVta2W78xy2K/9Rkf6cC4T59S50tVnzKj+tnx5mmx5lwvCkujZ4uRrpRSuV+IVs3f90Q==", - "license": "MIT", - "dependencies": { - "address": "^1.0.1", - "debug": "4" - }, - "bin": { - "detect": "bin/detect-port.js", - "detect-port": "bin/detect-port.js" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "license": "MIT", - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/docusaurus-theme-github-codeblock": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/docusaurus-theme-github-codeblock/-/docusaurus-theme-github-codeblock-2.0.2.tgz", - "integrity": "sha512-H2WoQPWOLjGZO6KS58Gsd+eUVjTFJemkReiSSu9chqokyLc/3Ih3+zPRYfuEZ/HsDvSMIarf7CNcp+Vt+/G+ig==", - "dependencies": { - "@docusaurus/types": "^3.0.0" - } - }, - "node_modules/dom-converter": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", - "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", - "license": "MIT", - "dependencies": { - "utila": "~0.4" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/dot-prop": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", - "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", - "license": "MIT", - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dot-prop/node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "license": "MIT" - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.161", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.161.tgz", - "integrity": "sha512-hwtetwfKNZo/UlwHIVBlKZVdy7o8bIZxxKs0Mv/ROPiQQQmDgdm5a+KvKtBsxM8ZjFzTaCeLoodZ8jiBE3o9rA==", - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/emojilib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", - "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", - "license": "MIT" - }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "engines": { - "node": ">= 4" - } - }, - "node_modules/emoticon": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/emoticon/-/emoticon-4.1.0.tgz", - "integrity": "sha512-VWZfnxqwNcc51hIy/sbOdEem6D+cVtpPzEEtVAFdaas30+1dgkyaOQ4sQ6Bp0tOMqWO1v+HQfYaoodOkdhK6SQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", - "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", - "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-goat": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", - "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esrecurse/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-util-attach-comments": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", - "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", - "dependencies": { - "@types/estree": "^1.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-build-jsx": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", - "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "estree-walker": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-to-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", - "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "astring": "^1.8.0", - "source-map": "^0.7.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-util-value-to-estree": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/estree-util-value-to-estree/-/estree-util-value-to-estree-3.3.3.tgz", - "integrity": "sha512-Db+m1WSD4+mUO7UgMeKkAwdbfNWwIxLt48XF2oFU9emPfXkIu+k5/nlOj313v7wqtAPo0f9REhUvznFrPkG8CQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/remcohaszing" - } - }, - "node_modules/estree-util-visit": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", - "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eta": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/eta/-/eta-2.2.0.tgz", - "integrity": "sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==", - "engines": { - "node": ">=6.0.0" - }, - "funding": { - "url": "https://github.com/eta-dev/eta?sponsor=1" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eval": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eval/-/eval-0.1.8.tgz", - "integrity": "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==", - "dependencies": { - "@types/node": "*", - "require-like": ">= 0.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" - }, - "node_modules/express/node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, - "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fault": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", - "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", - "license": "MIT", - "dependencies": { - "format": "^0.2.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/feed": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz", - "integrity": "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==", - "dependencies": { - "xml-js": "^1.6.11" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/file-loader": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", - "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", - "dependencies": { - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" - } - }, - "node_modules/file-loader/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/file-loader/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/file-loader/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "node_modules/file-loader/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/find-cache-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", - "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", - "dependencies": { - "common-path-prefix": "^3.0.0", - "pkg-dir": "^7.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-up": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", - "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", - "dependencies": { - "locate-path": "^7.1.0", - "path-exists": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "bin": { - "flat": "cli.js" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data-encoder": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", - "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", - "license": "MIT", - "engines": { - "node": ">= 14.17" - } - }, - "node_modules/format": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", - "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/fs-monkey": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", - "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==", - "license": "Unlicense" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-own-enumerable-property-symbols": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", - "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", - "license": "ISC" - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/github-slugger": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.5.0.tgz", - "integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==" - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" - }, - "node_modules/global-dirs": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", - "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", - "license": "MIT", - "dependencies": { - "ini": "2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/global-dirs/node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "engines": { - "node": ">=4" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/got": { - "version": "12.6.1", - "resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz", - "integrity": "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==", - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^5.2.0", - "@szmarczak/http-timer": "^5.0.1", - "cacheable-lookup": "^7.0.0", - "cacheable-request": "^10.2.8", - "decompress-response": "^6.0.0", - "form-data-encoder": "^2.1.2", - "get-stream": "^6.0.1", - "http2-wrapper": "^2.1.10", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^3.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/got/node_modules/@sindresorhus/is": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", - "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/gray-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/gzip-size": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", - "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", - "license": "MIT", - "dependencies": { - "duplexer": "^0.1.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-yarn": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-3.0.0.tgz", - "integrity": "sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hast-util-from-parse5": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", - "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "hastscript": "^9.0.0", - "property-information": "^7.0.0", - "vfile": "^6.0.0", - "vfile-location": "^5.0.0", - "web-namespaces": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-parse5/node_modules/property-information": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz", - "integrity": "sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-raw": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", - "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "hast-util-from-parse5": "^8.0.0", - "hast-util-to-parse5": "^8.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "parse5": "^7.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-estree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.0.tgz", - "integrity": "sha512-lfX5g6hqVh9kjS/B9E2gSkvHH4SZNiQFiqWS0x9fENzEl+8W12RqdRxX6d/Cwxi30tPQs3bIO+aolQJNp1bIyw==", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-attach-comments": "^3.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^6.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-object": "^0.4.0", - "unist-util-position": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.0.tgz", - "integrity": "sha512-H/y0+IWPdsLLS738P8tDnrQ8Z+dj12zQQ6WC11TIM21C8WFVoIxcqWXf2H3hiTVZjF1AWqoimGwrTWecWrnmRQ==", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^6.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-object": "^1.0.0", - "unist-util-position": "^5.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-jsx-runtime/node_modules/inline-style-parser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.3.tgz", - "integrity": "sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g==" - }, - "node_modules/hast-util-to-jsx-runtime/node_modules/style-to-object": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.6.tgz", - "integrity": "sha512-khxq+Qm3xEyZfKd/y9L3oIWQimxuc4STrQKtQn8aSDRHb8mFgpukgX1hdzfrMEW6JCjyJ8p89x+IUMVnCBI1PA==", - "dependencies": { - "inline-style-parser": "0.2.3" - } - }, - "node_modules/hast-util-to-parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", - "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "property-information": "^6.0.0", - "space-separated-tokens": "^2.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript/node_modules/property-information": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz", - "integrity": "sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/history": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", - "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", - "dependencies": { - "@babel/runtime": "^7.1.2", - "loose-envify": "^1.2.0", - "resolve-pathname": "^3.0.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0", - "value-equal": "^1.0.1" - } - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" - } - }, - "node_modules/hpack.js/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/html-entities": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", - "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ], - "license": "MIT" - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "license": "MIT" - }, - "node_modules/html-minifier-terser": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", - "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", - "dependencies": { - "camel-case": "^4.1.2", - "clean-css": "~5.3.2", - "commander": "^10.0.0", - "entities": "^4.4.0", - "param-case": "^3.0.4", - "relateurl": "^0.2.7", - "terser": "^5.15.1" - }, - "bin": { - "html-minifier-terser": "cli.js" - }, - "engines": { - "node": "^14.13.1 || >=16.0.0" - } - }, - "node_modules/html-minifier-terser/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "engines": { - "node": ">=14" - } - }, - "node_modules/html-tags": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", - "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/html-webpack-plugin": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz", - "integrity": "sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==", - "license": "MIT", - "dependencies": { - "@types/html-minifier-terser": "^6.0.0", - "html-minifier-terser": "^6.0.2", - "lodash": "^4.17.21", - "pretty-error": "^4.0.0", - "tapable": "^2.0.0" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/html-webpack-plugin" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.20.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/html-webpack-plugin/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/html-webpack-plugin/node_modules/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", - "license": "MIT", - "dependencies": { - "camel-case": "^4.1.2", - "clean-css": "^5.2.2", - "commander": "^8.3.0", - "he": "^1.2.0", - "param-case": "^3.0.4", - "relateurl": "^0.2.7", - "terser": "^5.10.0" - }, - "bin": { - "html-minifier-terser": "cli.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "license": "BSD-2-Clause" - }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.9.tgz", - "integrity": "sha512-n1XsPy3rXVxlqxVioEWdC+0+M+SQw0DpJynwtOPo1X+ZlvdzTLtDBIJJlDQTnwZIFJrZSzSGmIOUdP8tu+SgLw==", - "license": "MIT" - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", - "license": "MIT", - "dependencies": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/express": "^4.17.13" - }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } - } - }, - "node_modules/http-proxy-middleware/node_modules/is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/http2-wrapper": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", - "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", - "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.2.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/icss-utils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", - "engines": { - "node": ">= 4" - } - }, - "node_modules/image-size": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", - "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", - "license": "MIT", - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=16.x" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-lazy": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", - "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/infima": { - "version": "0.2.0-alpha.45", - "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.45.tgz", - "integrity": "sha512-uyH0zfr1erU1OohLk0fT4Rrb94AOhguWNOcD9uGrSpRvNB+6gZXUoJX5J0NtvzBO10YZ9PgvA4NFgt+fYg8ojw==", - "engines": { - "node": ">=12" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/inline-style-parser": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", - "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==" - }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/ipaddr.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "license": "MIT", - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, - "node_modules/is-core-module": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.14.0.tgz", - "integrity": "sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-installed-globally": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", - "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", - "license": "MIT", - "dependencies": { - "global-dirs": "^3.0.0", - "is-path-inside": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-npm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz", - "integrity": "sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-reference": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", - "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/is-regexp": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", - "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "license": "MIT" - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-yarn-global": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.4.1.tgz", - "integrity": "sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/jiti": { - "version": "1.21.6", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", - "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", - "dependencies": { - "@hapi/hoek": "^9.3.0", - "@hapi/topo": "^5.1.0", - "@sideway/address": "^4.1.5", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "engines": { - "node": ">=6" - } - }, - "node_modules/latest-version": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz", - "integrity": "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==", - "license": "MIT", - "dependencies": { - "package-json": "^8.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/launch-editor": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz", - "integrity": "sha512-D7dBRJo/qcGX9xlvt/6wUYzQxjh5G1RvZPgPv8vi4KRU99DVQL/oW7tnVOCCTm2HGeo3C5HvGE5Yrh6UBoZ0vA==", - "license": "MIT", - "dependencies": { - "picocolors": "^1.0.0", - "shell-quote": "^1.8.1" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" - }, - "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "engines": { - "node": ">=6.11.5" - } - }, - "node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, - "node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" - }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" - }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/lowercase-keys": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", - "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/markdown-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", - "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/markdown-table": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", - "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdast-util-directive": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", - "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.1.tgz", - "integrity": "sha512-aJEUyzZ6TzlsX2s5B4Of7lN7EQtAxvtradMMglCQDyaTFgse6CmtmdJ15ElnVRlCg1vpNyVtbem0PWzlNieZsA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-from-markdown/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/mdast-util-frontmatter": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", - "integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "escape-string-regexp": "^5.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-extension-frontmatter": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-frontmatter/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", - "license": "MIT", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/mdast-util-gfm-autolink-literal/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", - "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-expression": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.0.tgz", - "integrity": "sha512-fGCu8eWdKUKNu5mohVGkhBXCXGnOTLuFqOvGMvdikr+J1w7lDJgxThOKpwRWzzbyXAU2hhSwsmssOY4yTokluw==", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-jsx": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.1.2.tgz", - "integrity": "sha512-eKMQDeywY2wlHc97k5eD8VC+9ASMjN8ItEZQNGwJ6E0XWKiW/Z0V5/H8pvoXUf+y+Mj0VIgeRRbujBmFn4FTyA==", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-remove-position": "^5.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", - "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.0.tgz", - "integrity": "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdn-data": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==" - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memfs": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", - "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", - "license": "Unlicense", - "dependencies": { - "fs-monkey": "^1.0.4" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/micromark": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.0.tgz", - "integrity": "sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.1.tgz", - "integrity": "sha512-CUQyKr1e///ZODyD1U3xit6zXwy1a8q2a1S1HKtIlmgvurrEpaw/Y9y6KSIbF8P59cn/NjzHyO+Q2fAyYLQrAA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark/node_modules/micromark-factory-space": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", - "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark/node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-extension-directive": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-3.0.2.tgz", - "integrity": "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "parse-entities": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-directive/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-directive/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-directive/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-frontmatter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", - "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", - "license": "MIT", - "dependencies": { - "fault": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-frontmatter/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-frontmatter/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "license": "MIT", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", - "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", - "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-table/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-table/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", - "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-extension-mdx-expression": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.0.tgz", - "integrity": "sha512-sI0nwhUDz97xyzqJAbHQhp5TfaxEvZZZ2JDqUo+7NvyIYG6BZ5CPPqj2ogUoPJlmXHBnyZUzISg9+oUmU6tUjQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-mdx-expression": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdx-expression/node_modules/micromark-factory-space": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", - "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdx-expression/node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdx-expression/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-extension-mdx-jsx": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.0.tgz", - "integrity": "sha512-uvhhss8OGuzR4/N17L1JwvmJIpPhAd8oByMawEKx6NVdBCbesjH4t+vjEp3ZXft9DwvlKSD07fCeI44/N0Vf2w==", - "dependencies": { - "@types/acorn": "^4.0.0", - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "micromark-factory-mdx-expression": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-factory-space": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", - "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-extension-mdx-md": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", - "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdxjs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", - "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", - "dependencies": { - "acorn": "^8.0.0", - "acorn-jsx": "^5.0.0", - "micromark-extension-mdx-expression": "^3.0.0", - "micromark-extension-mdx-jsx": "^3.0.0", - "micromark-extension-mdx-md": "^2.0.0", - "micromark-extension-mdxjs-esm": "^3.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdxjs-esm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", - "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-position-from-estree": "^2.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-mdxjs-esm/node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-mdxjs-esm/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz", - "integrity": "sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-destination/node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-destination/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-factory-label": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.0.tgz", - "integrity": "sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label/node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-factory-mdx-expression": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.1.tgz", - "integrity": "sha512-F0ccWIUHRLRrYp5TC9ZYXmZo+p2AM13ggbsW4T0b5CRKP8KHVRB8t4pwtBgTxtjRmwrK0Irwm7vs2JOZabHZfg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "@types/estree": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-events-to-acorn": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-position-from-estree": "^2.0.0", - "vfile-message": "^4.0.0" - } - }, - "node_modules/micromark-factory-mdx-expression/node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-mdx-expression/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-factory-space": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", - "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-factory-space/node_modules/micromark-util-types": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", - "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-factory-title": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.0.tgz", - "integrity": "sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title/node_modules/micromark-factory-space": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", - "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title/node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.0.tgz", - "integrity": "sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace/node_modules/micromark-factory-space": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", - "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace/node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-character": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", - "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-character/node_modules/micromark-util-types": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", - "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.0.tgz", - "integrity": "sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.0.tgz", - "integrity": "sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character/node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.0.tgz", - "integrity": "sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.1.tgz", - "integrity": "sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.0.tgz", - "integrity": "sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string/node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", - "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-events-to-acorn": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.2.tgz", - "integrity": "sha512-Fk+xmBrOv9QZnEDguL9OI9/NQQp6Hz4FuQ4YmCb/5V7+9eAh1s6AYSvL20kHkD67YIg7EpE54TiSlcsf3vyZgA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "@types/acorn": "^4.0.0", - "@types/estree": "^1.0.0", - "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "estree-util-visit": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "vfile-message": "^4.0.0" - } - }, - "node_modules/micromark-util-events-to-acorn/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.0.tgz", - "integrity": "sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.0.tgz", - "integrity": "sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-normalize-identifier/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.0.tgz", - "integrity": "sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", - "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri/node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.1.tgz", - "integrity": "sha512-jZNtiFl/1aY73yS3UGQkutD0UbhTt68qnRpw2Pifmz5wV9h8gOVsN70v+Lq/f1rKaU/W8pxRe8y8Q9FX1AOe1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-symbol": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", - "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz", - "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark/node_modules/micromark-factory-space": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", - "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark/node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark/node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", - "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", - "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", - "license": "MIT", - "dependencies": { - "mime-db": "~1.33.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-response": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", - "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mini-css-extract-plugin": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz", - "integrity": "sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==", - "dependencies": { - "schema-utils": "^4.0.0", - "tapable": "^2.2.1" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "license": "MIT", - "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - }, - "bin": { - "multicast-dns": "cli.js" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" - }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/node-emoji": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", - "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^4.6.0", - "char-regex": "^1.0.2", - "emojilib": "^2.4.0", - "skin-tone": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-url": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", - "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nprogress": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", - "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==" - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/null-loader": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/null-loader/-/null-loader-4.0.1.tgz", - "integrity": "sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg==", - "dependencies": { - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" - } - }, - "node_modules/null-loader/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/null-loader/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/null-loader/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "node_modules/null-loader/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "license": "MIT" - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/opener": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", - "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", - "license": "(WTFPL OR MIT)", - "bin": { - "opener": "bin/opener-bin.js" - } - }, - "node_modules/p-cancelable": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", - "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dependencies": { - "p-limit": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-queue": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", - "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.4", - "p-timeout": "^3.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-timeout": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", - "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", - "license": "MIT", - "dependencies": { - "p-finally": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/package-json": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz", - "integrity": "sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==", - "license": "MIT", - "dependencies": { - "got": "^12.1.0", - "registry-auth-token": "^5.0.1", - "registry-url": "^6.0.0", - "semver": "^7.3.7" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-entities": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.1.tgz", - "integrity": "sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", - "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-numeric-range": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz", - "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==" - }, - "node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "dependencies": { - "entities": "^4.4.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", - "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", - "dependencies": { - "domhandler": "^5.0.3", - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-is-inside": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", - "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", - "license": "(WTFPL OR MIT)" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "node_modules/path-to-regexp": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", - "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", - "dependencies": { - "isarray": "0.0.1" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/periscopic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", - "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^3.0.0", - "is-reference": "^3.0.0" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkg-dir": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", - "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", - "dependencies": { - "find-up": "^6.3.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-attribute-case-insensitive": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-7.0.1.tgz", - "integrity": "sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-attribute-case-insensitive/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-calc": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-9.0.1.tgz", - "integrity": "sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==", - "dependencies": { - "postcss-selector-parser": "^6.0.11", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.2.2" - } - }, - "node_modules/postcss-clamp": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", - "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=7.6.0" - }, - "peerDependencies": { - "postcss": "^8.4.6" - } - }, - "node_modules/postcss-color-functional-notation": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-7.0.10.tgz", - "integrity": "sha512-k9qX+aXHBiLTRrWoCJuUFI6F1iF6QJQUXNVWJVSbqZgj57jDhBlOvD8gNUGl35tgqDivbGLhZeW3Ongz4feuKA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-color-parser": "^3.0.10", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-color-hex-alpha": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-10.0.0.tgz", - "integrity": "sha512-1kervM2cnlgPs2a8Vt/Qbe5cQ++N7rkYo/2rz2BkqJZIHQwaVuJgQH38REHrAi4uM0b1fqxMkWYmese94iMp3w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-color-rebeccapurple": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-10.0.0.tgz", - "integrity": "sha512-JFta737jSP+hdAIEhk1Vs0q0YF5P8fFcj+09pweS8ktuGuZ8pPlykHsk6mPxZ8awDl4TrcxUqJo9l1IhVr/OjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-colormin": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz", - "integrity": "sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw==", - "dependencies": { - "browserslist": "^4.23.0", - "caniuse-api": "^3.0.0", - "colord": "^2.9.3", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-convert-values": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.1.0.tgz", - "integrity": "sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w==", - "dependencies": { - "browserslist": "^4.23.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-custom-media": { - "version": "11.0.6", - "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-11.0.6.tgz", - "integrity": "sha512-C4lD4b7mUIw+RZhtY7qUbf4eADmb7Ey8BFA2px9jUbwg7pjTZDl4KY4bvlUV+/vXQvzQRfiGEVJyAbtOsCMInw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/cascade-layer-name-parser": "^2.0.5", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/media-query-list-parser": "^4.0.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-custom-properties": { - "version": "14.0.6", - "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-14.0.6.tgz", - "integrity": "sha512-fTYSp3xuk4BUeVhxCSJdIPhDLpJfNakZKoiTDx7yRGCdlZrSJR7mWKVOBS4sBF+5poPQFMj2YdXx1VHItBGihQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/cascade-layer-name-parser": "^2.0.5", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-custom-selectors": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-8.0.5.tgz", - "integrity": "sha512-9PGmckHQswiB2usSO6XMSswO2yFWVoCAuih1yl9FVcwkscLjRKjwsjM3t+NIWpSU2Jx3eOiK2+t4vVTQaoCHHg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/cascade-layer-name-parser": "^2.0.5", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-custom-selectors/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-dir-pseudo-class": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-9.0.1.tgz", - "integrity": "sha512-tRBEK0MHYvcMUrAuYMEOa0zg9APqirBcgzi6P21OhxtJyJADo/SWBwY1CAwEohQ/6HDaa9jCjLRG7K3PVQYHEA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-dir-pseudo-class/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-discard-comments": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.2.tgz", - "integrity": "sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw==", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-discard-duplicates": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.3.tgz", - "integrity": "sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw==", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-discard-empty": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-6.0.3.tgz", - "integrity": "sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ==", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-discard-overridden": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-6.0.2.tgz", - "integrity": "sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ==", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-discard-unused": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-6.0.5.tgz", - "integrity": "sha512-wHalBlRHkaNnNwfC8z+ppX57VhvS+HWgjW508esjdaEYr3Mx7Gnn2xA4R/CKf5+Z9S5qsqC+Uzh4ueENWwCVUA==", - "dependencies": { - "postcss-selector-parser": "^6.0.16" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-double-position-gradients": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-6.0.2.tgz", - "integrity": "sha512-7qTqnL7nfLRyJK/AHSVrrXOuvDDzettC+wGoienURV8v2svNbu6zJC52ruZtHaO6mfcagFmuTGFdzRsJKB3k5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-focus-visible": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-10.0.1.tgz", - "integrity": "sha512-U58wyjS/I1GZgjRok33aE8juW9qQgQUNwTSdxQGuShHzwuYdcklnvK/+qOWX1Q9kr7ysbraQ6ht6r+udansalA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-focus-visible/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-focus-within": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-9.0.1.tgz", - "integrity": "sha512-fzNUyS1yOYa7mOjpci/bR+u+ESvdar6hk8XNK/TRR0fiGTp2QT5N+ducP0n3rfH/m9I7H/EQU6lsa2BrgxkEjw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-focus-within/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-font-variant": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", - "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-gap-properties": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-6.0.0.tgz", - "integrity": "sha512-Om0WPjEwiM9Ru+VhfEDPZJAKWUd0mV1HmNXqp2C29z80aQ2uP9UVhLc7e3aYMIor/S5cVhoPgYQ7RtfeZpYTRw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-image-set-function": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-7.0.0.tgz", - "integrity": "sha512-QL7W7QNlZuzOwBTeXEmbVckNt1FSmhQtbMRvGGqqU4Nf4xk6KUEQhAoWuMzwbSv5jxiRiSZ5Tv7eiDB9U87znA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/utilities": "^2.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-lab-function": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-7.0.10.tgz", - "integrity": "sha512-tqs6TCEv9tC1Riq6fOzHuHcZyhg4k3gIAMB8GGY/zA1ssGdm6puHMVE7t75aOSoFg7UD2wyrFFhbldiCMyyFTQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/css-color-parser": "^3.0.10", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/utilities": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-loader": { - "version": "7.3.4", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.4.tgz", - "integrity": "sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A==", - "dependencies": { - "cosmiconfig": "^8.3.5", - "jiti": "^1.20.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "postcss": "^7.0.0 || ^8.0.1", - "webpack": "^5.0.0" - } - }, - "node_modules/postcss-logical": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-8.1.0.tgz", - "integrity": "sha512-pL1hXFQ2fEXNKiNiAgtfA005T9FBxky5zkX6s4GZM2D8RkVgRqz3f4g1JUoq925zXv495qk8UNldDwh8uGEDoA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-merge-idents": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-6.0.3.tgz", - "integrity": "sha512-1oIoAsODUs6IHQZkLQGO15uGEbK3EAl5wi9SS8hs45VgsxQfMnxvt+L+zIr7ifZFIH14cfAeVe2uCTa+SPRa3g==", - "dependencies": { - "cssnano-utils": "^4.0.2", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-merge-longhand": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.5.tgz", - "integrity": "sha512-5LOiordeTfi64QhICp07nzzuTDjNSO8g5Ksdibt44d+uvIIAE1oZdRn8y/W5ZtYgRH/lnLDlvi9F8btZcVzu3w==", - "dependencies": { - "postcss-value-parser": "^4.2.0", - "stylehacks": "^6.1.1" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-merge-rules": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-6.1.1.tgz", - "integrity": "sha512-KOdWF0gju31AQPZiD+2Ar9Qjowz1LTChSjFFbS+e2sFgc4uHOp3ZvVX4sNeTlk0w2O31ecFGgrFzhO0RSWbWwQ==", - "dependencies": { - "browserslist": "^4.23.0", - "caniuse-api": "^3.0.0", - "cssnano-utils": "^4.0.2", - "postcss-selector-parser": "^6.0.16" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-minify-font-values": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.1.0.tgz", - "integrity": "sha512-gklfI/n+9rTh8nYaSJXlCo3nOKqMNkxuGpTn/Qm0gstL3ywTr9/WRKznE+oy6fvfolH6dF+QM4nCo8yPLdvGJg==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-minify-gradients": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-6.0.3.tgz", - "integrity": "sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q==", - "dependencies": { - "colord": "^2.9.3", - "cssnano-utils": "^4.0.2", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-minify-params": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-6.1.0.tgz", - "integrity": "sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA==", - "dependencies": { - "browserslist": "^4.23.0", - "cssnano-utils": "^4.0.2", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-minify-selectors": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-6.0.4.tgz", - "integrity": "sha512-L8dZSwNLgK7pjTto9PzWRoMbnLq5vsZSTu8+j1P/2GB8qdtGQfn+K1uSvFgYvgh83cbyxT5m43ZZhUMTJDSClQ==", - "dependencies": { - "postcss-selector-parser": "^6.0.16" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-modules-extract-imports": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", - "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-local-by-default": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", - "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", - "dependencies": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.1.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-modules-scope": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", - "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-modules-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", - "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", - "dependencies": { - "icss-utils": "^5.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-nesting": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.2.tgz", - "integrity": "sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/selector-resolve-nested": "^3.1.0", - "@csstools/selector-specificity": "^5.0.0", - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-nesting/node_modules/@csstools/selector-resolve-nested": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.1.0.tgz", - "integrity": "sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss-selector-parser": "^7.0.0" - } - }, - "node_modules/postcss-nesting/node_modules/@csstools/selector-specificity": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss-selector-parser": "^7.0.0" - } - }, - "node_modules/postcss-nesting/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-normalize-charset": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.2.tgz", - "integrity": "sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ==", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-display-values": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.2.tgz", - "integrity": "sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-positions": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-6.0.2.tgz", - "integrity": "sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-repeat-style": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.2.tgz", - "integrity": "sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-string": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-6.0.2.tgz", - "integrity": "sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-timing-functions": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.2.tgz", - "integrity": "sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-unicode": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-6.1.0.tgz", - "integrity": "sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg==", - "dependencies": { - "browserslist": "^4.23.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-url": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-6.0.2.tgz", - "integrity": "sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-normalize-whitespace": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.2.tgz", - "integrity": "sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-opacity-percentage": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-3.0.0.tgz", - "integrity": "sha512-K6HGVzyxUxd/VgZdX04DCtdwWJ4NGLG212US4/LA1TLAbHgmAsTWVR86o+gGIbFtnTkfOpb9sCRBx8K7HO66qQ==", - "funding": [ - { - "type": "kofi", - "url": "https://ko-fi.com/mrcgrtz" - }, - { - "type": "liberapay", - "url": "https://liberapay.com/mrcgrtz" - } - ], - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-ordered-values": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-6.0.2.tgz", - "integrity": "sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q==", - "dependencies": { - "cssnano-utils": "^4.0.2", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-overflow-shorthand": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-6.0.0.tgz", - "integrity": "sha512-BdDl/AbVkDjoTofzDQnwDdm/Ym6oS9KgmO7Gr+LHYjNWJ6ExORe4+3pcLQsLA9gIROMkiGVjjwZNoL/mpXHd5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-page-break": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", - "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", - "peerDependencies": { - "postcss": "^8" - } - }, - "node_modules/postcss-place": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-10.0.0.tgz", - "integrity": "sha512-5EBrMzat2pPAxQNWYavwAfoKfYcTADJ8AXGVPcUZ2UkNloUTWzJQExgrzrDkh3EKzmAx1evfTAzF9I8NGcc+qw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-preset-env": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-10.2.4.tgz", - "integrity": "sha512-q+lXgqmTMdB0Ty+EQ31SuodhdfZetUlwCA/F0zRcd/XdxjzI+Rl2JhZNz5US2n/7t9ePsvuhCnEN4Bmu86zXlA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/postcss-cascade-layers": "^5.0.2", - "@csstools/postcss-color-function": "^4.0.10", - "@csstools/postcss-color-mix-function": "^3.0.10", - "@csstools/postcss-color-mix-variadic-function-arguments": "^1.0.0", - "@csstools/postcss-content-alt-text": "^2.0.6", - "@csstools/postcss-exponential-functions": "^2.0.9", - "@csstools/postcss-font-format-keywords": "^4.0.0", - "@csstools/postcss-gamut-mapping": "^2.0.10", - "@csstools/postcss-gradients-interpolation-method": "^5.0.10", - "@csstools/postcss-hwb-function": "^4.0.10", - "@csstools/postcss-ic-unit": "^4.0.2", - "@csstools/postcss-initial": "^2.0.1", - "@csstools/postcss-is-pseudo-class": "^5.0.3", - "@csstools/postcss-light-dark-function": "^2.0.9", - "@csstools/postcss-logical-float-and-clear": "^3.0.0", - "@csstools/postcss-logical-overflow": "^2.0.0", - "@csstools/postcss-logical-overscroll-behavior": "^2.0.0", - "@csstools/postcss-logical-resize": "^3.0.0", - "@csstools/postcss-logical-viewport-units": "^3.0.4", - "@csstools/postcss-media-minmax": "^2.0.9", - "@csstools/postcss-media-queries-aspect-ratio-number-values": "^3.0.5", - "@csstools/postcss-nested-calc": "^4.0.0", - "@csstools/postcss-normalize-display-values": "^4.0.0", - "@csstools/postcss-oklab-function": "^4.0.10", - "@csstools/postcss-progressive-custom-properties": "^4.1.0", - "@csstools/postcss-random-function": "^2.0.1", - "@csstools/postcss-relative-color-syntax": "^3.0.10", - "@csstools/postcss-scope-pseudo-class": "^4.0.1", - "@csstools/postcss-sign-functions": "^1.1.4", - "@csstools/postcss-stepped-value-functions": "^4.0.9", - "@csstools/postcss-text-decoration-shorthand": "^4.0.2", - "@csstools/postcss-trigonometric-functions": "^4.0.9", - "@csstools/postcss-unset-value": "^4.0.0", - "autoprefixer": "^10.4.21", - "browserslist": "^4.25.0", - "css-blank-pseudo": "^7.0.1", - "css-has-pseudo": "^7.0.2", - "css-prefers-color-scheme": "^10.0.0", - "cssdb": "^8.3.0", - "postcss-attribute-case-insensitive": "^7.0.1", - "postcss-clamp": "^4.1.0", - "postcss-color-functional-notation": "^7.0.10", - "postcss-color-hex-alpha": "^10.0.0", - "postcss-color-rebeccapurple": "^10.0.0", - "postcss-custom-media": "^11.0.6", - "postcss-custom-properties": "^14.0.6", - "postcss-custom-selectors": "^8.0.5", - "postcss-dir-pseudo-class": "^9.0.1", - "postcss-double-position-gradients": "^6.0.2", - "postcss-focus-visible": "^10.0.1", - "postcss-focus-within": "^9.0.1", - "postcss-font-variant": "^5.0.0", - "postcss-gap-properties": "^6.0.0", - "postcss-image-set-function": "^7.0.0", - "postcss-lab-function": "^7.0.10", - "postcss-logical": "^8.1.0", - "postcss-nesting": "^13.0.2", - "postcss-opacity-percentage": "^3.0.0", - "postcss-overflow-shorthand": "^6.0.0", - "postcss-page-break": "^3.0.4", - "postcss-place": "^10.0.0", - "postcss-pseudo-class-any-link": "^10.0.1", - "postcss-replace-overflow-wrap": "^4.0.0", - "postcss-selector-not": "^8.0.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-pseudo-class-any-link": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-10.0.1.tgz", - "integrity": "sha512-3el9rXlBOqTFaMFkWDOkHUTQekFIYnaQY55Rsp8As8QQkpiSgIYEcF/6Ond93oHiDsGb4kad8zjt+NPlOC1H0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-pseudo-class-any-link/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-reduce-idents": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-6.0.3.tgz", - "integrity": "sha512-G3yCqZDpsNPoQgbDUy3T0E6hqOQ5xigUtBQyrmq3tn2GxlyiL0yyl7H+T8ulQR6kOcHJ9t7/9H4/R2tv8tJbMA==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-reduce-initial": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.1.0.tgz", - "integrity": "sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw==", - "dependencies": { - "browserslist": "^4.23.0", - "caniuse-api": "^3.0.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-reduce-transforms": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.2.tgz", - "integrity": "sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA==", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-replace-overflow-wrap": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", - "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", - "peerDependencies": { - "postcss": "^8.0.3" - } - }, - "node_modules/postcss-selector-not": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-8.0.1.tgz", - "integrity": "sha512-kmVy/5PYVb2UOhy0+LqUYAhKj7DUGDpSWa5LZqlkWJaaAV+dxxsOG3+St0yNLu6vsKD7Dmqx+nWQt0iil89+WA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-selector-not/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-sort-media-queries": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/postcss-sort-media-queries/-/postcss-sort-media-queries-5.2.0.tgz", - "integrity": "sha512-AZ5fDMLD8SldlAYlvi8NIqo0+Z8xnXU2ia0jxmuhxAU+Lqt9K+AlmLNJ/zWEnE9x+Zx3qL3+1K20ATgNOr3fAA==", - "dependencies": { - "sort-css-media-queries": "2.2.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.4.23" - } - }, - "node_modules/postcss-svgo": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.3.tgz", - "integrity": "sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==", - "dependencies": { - "postcss-value-parser": "^4.2.0", - "svgo": "^3.2.0" - }, - "engines": { - "node": "^14 || ^16 || >= 18" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-unique-selectors": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-6.0.4.tgz", - "integrity": "sha512-K38OCaIrO8+PzpArzkLKB42dSARtC2tmG6PvD4b1o1Q2E9Os8jzfWFfSy/rixsHwohtsDdFtAWGjFVFUdwYaMg==", - "dependencies": { - "postcss-selector-parser": "^6.0.16" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" - }, - "node_modules/postcss-zindex": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-6.0.2.tgz", - "integrity": "sha512-5BxW9l1evPB/4ZIc+2GobEBoKC+h8gPGCMi+jxsYvd2x0mjq7wazk6DrP71pStqxE9Foxh5TVnonbWpFZzXaYg==", - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/pretty-error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", - "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.20", - "renderkid": "^3.0.0" - } - }, - "node_modules/pretty-time": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/pretty-time/-/pretty-time-1.1.0.tgz", - "integrity": "sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==", - "engines": { - "node": ">=4" - } - }, - "node_modules/prism-react-renderer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", - "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", - "dependencies": { - "@types/prismjs": "^1.26.0", - "clsx": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.0.0" - } - }, - "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "engines": { - "node": ">=6" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/property-information": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", - "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "license": "ISC" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pupa": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz", - "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==", - "license": "MIT", - "dependencies": { - "escape-goat": "^4.0.0" - }, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/range-parser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", - "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", - "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.26.0" - }, - "peerDependencies": { - "react": "^19.1.1" - } - }, - "node_modules/react-fast-compare": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", - "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", - "license": "MIT" - }, - "node_modules/react-helmet-async": { - "name": "@slorber/react-helmet-async", - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@slorber/react-helmet-async/-/react-helmet-async-1.3.0.tgz", - "integrity": "sha512-e9/OK8VhwUSc67diWI8Rb3I0YgI9/SBQtnhe9aEuK6MhZm7ntZZimXgwXnd8W96YTmSOb9M4d8LwhRZyhWr/1A==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "invariant": "^2.2.4", - "prop-types": "^15.7.2", - "react-fast-compare": "^3.2.0", - "shallowequal": "^1.1.0" - }, - "peerDependencies": { - "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "node_modules/react-json-view-lite": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-2.4.1.tgz", - "integrity": "sha512-fwFYknRIBxjbFm0kBDrzgBy1xa5tDg2LyXXBepC5f1b+MY3BUClMCsvanMPn089JbV1Eg3nZcrp0VCuH43aXnA==", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-loadable": { - "name": "@docusaurus/react-loadable", - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", - "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", - "dependencies": { - "@types/react": "*" - }, - "peerDependencies": { - "react": "*" - } - }, - "node_modules/react-loadable-ssr-addon-v5-slorber": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.1.tgz", - "integrity": "sha512-lq3Lyw1lGku8zUEJPDxsNm1AfYHBrO9Y1+olAYwpUJ2IGFBskM0DMKok97A6LWUpHm+o7IvQBOWu9MLenp9Z+A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.3" - }, - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "react-loadable": "*", - "webpack": ">=4.41.1 || 5.x" - } - }, - "node_modules/react-router": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", - "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", - "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "hoist-non-react-statics": "^3.1.0", - "loose-envify": "^1.3.1", - "path-to-regexp": "^1.7.0", - "prop-types": "^15.6.2", - "react-is": "^16.6.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" - }, - "peerDependencies": { - "react": ">=15" - } - }, - "node_modules/react-router-config": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/react-router-config/-/react-router-config-5.1.1.tgz", - "integrity": "sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.1.2" - }, - "peerDependencies": { - "react": ">=15", - "react-router": ">=5" - } - }, - "node_modules/react-router-dom": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", - "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", - "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "loose-envify": "^1.3.1", - "prop-types": "^15.6.2", - "react-router": "5.3.4", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" - }, - "peerDependencies": { - "react": ">=15" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==" - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", - "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, - "node_modules/regenerator-transform": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", - "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", - "dependencies": { - "@babel/runtime": "^7.8.4" - } - }, - "node_modules/regexpu-core": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", - "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.0", - "regjsgen": "^0.8.0", - "regjsparser": "^0.12.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/registry-auth-token": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.0.tgz", - "integrity": "sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==", - "license": "MIT", - "dependencies": { - "@pnpm/npm-conf": "^2.1.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/registry-url": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", - "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", - "license": "MIT", - "dependencies": { - "rc": "1.2.8" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==" - }, - "node_modules/regjsparser": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", - "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", - "dependencies": { - "jsesc": "~3.0.2" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/rehype-raw": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", - "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-raw": "^9.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/relateurl": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/remark-directive": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.1.tgz", - "integrity": "sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-directive": "^3.0.0", - "micromark-extension-directive": "^3.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-emoji": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-emoji/-/remark-emoji-4.0.1.tgz", - "integrity": "sha512-fHdvsTR1dHkWKev9eNyhTo4EFwbUvJ8ka9SgeWkMPYFX4WoI7ViVBms3PjlQYgw5TLvNQso3GUB/b/8t3yo+dg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.2", - "emoticon": "^4.0.1", - "mdast-util-find-and-replace": "^3.0.1", - "node-emoji": "^2.1.0", - "unified": "^11.0.4" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/remark-frontmatter": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz", - "integrity": "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-frontmatter": "^2.0.0", - "micromark-extension-frontmatter": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-gfm": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", - "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-mdx": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.0.1.tgz", - "integrity": "sha512-3Pz3yPQ5Rht2pM5R+0J2MrGoBSrzf+tJG94N+t/ilfdh8YLyyKYtidAYwTveB20BoHAcwIopOUqhcmh2F7hGYA==", - "dependencies": { - "mdast-util-mdx": "^3.0.0", - "micromark-extension-mdxjs": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-rehype": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.0.tgz", - "integrity": "sha512-z3tJrAs2kIs1AqIIy6pzHmAHlF1hWQ+OdY4/hv+Wxe35EhyLKcajL33iUEn3ScxtFox9nUvRufR/Zre8Q08H/g==", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/renderkid": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", - "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", - "license": "MIT", - "dependencies": { - "css-select": "^4.1.3", - "dom-converter": "^0.2.0", - "htmlparser2": "^6.1.0", - "lodash": "^4.17.21", - "strip-ansi": "^6.0.1" - } - }, - "node_modules/renderkid/node_modules/css-select": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", - "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.0.1", - "domhandler": "^4.3.1", - "domutils": "^2.8.0", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/renderkid/node_modules/dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/renderkid/node_modules/domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.2.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/renderkid/node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/renderkid/node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "license": "BSD-2-Clause", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/renderkid/node_modules/htmlparser2": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", - "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.0.0", - "domutils": "^2.5.2", - "entities": "^2.0.0" - } - }, - "node_modules/repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-like": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz", - "integrity": "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==", - "engines": { - "node": "*" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "license": "MIT" - }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "license": "MIT" - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-pathname": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", - "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" - }, - "node_modules/responselike": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", - "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", - "license": "MIT", - "dependencies": { - "lowercase-keys": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rtlcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz", - "integrity": "sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==", - "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0", - "postcss": "^8.4.21", - "strip-json-comments": "^3.1.1" - }, - "bin": { - "rtlcss": "bin/rtlcss.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/sax": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" - }, - "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT" - }, - "node_modules/schema-dts": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.5.tgz", - "integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==" - }, - "node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "peer": true - }, - "node_modules/section-matter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", - "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "dependencies": { - "extend-shallow": "^2.0.1", - "kind-of": "^6.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", - "license": "MIT" - }, - "node_modules/selfsigned": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", - "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", - "license": "MIT", - "dependencies": { - "@types/node-forge": "^1.3.0", - "node-forge": "^1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz", - "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/send/node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/serve-handler": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz", - "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", - "license": "MIT", - "dependencies": { - "bytes": "3.0.0", - "content-disposition": "0.5.2", - "mime-types": "2.1.18", - "minimatch": "3.1.2", - "path-is-inside": "1.0.2", - "path-to-regexp": "3.3.0", - "range-parser": "1.2.0" - } - }, - "node_modules/serve-handler/node_modules/path-to-regexp": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", - "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", - "license": "MIT" - }, - "node_modules/serve-index": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", - "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.6.2", - "mime-types": "~2.1.17", - "parseurl": "~1.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-index/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", - "license": "ISC" - }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/serve-index/node_modules/setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", - "license": "ISC" - }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shallowequal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", - "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" - }, - "node_modules/sitemap": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-7.1.2.tgz", - "integrity": "sha512-ARCqzHJ0p4gWt+j7NlU5eDlIO9+Rkr/JhPFZKKQ1l5GCus7rJH4UdrlVAh0xC/gDS/Qir2UMxqYNHtsKr2rpCw==", - "dependencies": { - "@types/node": "^17.0.5", - "@types/sax": "^1.2.1", - "arg": "^5.0.0", - "sax": "^1.2.4" - }, - "bin": { - "sitemap": "dist/cli.js" - }, - "engines": { - "node": ">=12.0.0", - "npm": ">=5.6.0" - } - }, - "node_modules/sitemap/node_modules/@types/node": { - "version": "17.0.45", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", - "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==" - }, - "node_modules/skin-tone": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", - "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", - "license": "MIT", - "dependencies": { - "unicode-emoji-modifier-base": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "engines": { - "node": ">=8" - } - }, - "node_modules/snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "license": "MIT", - "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" - } - }, - "node_modules/sort-css-media-queries": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/sort-css-media-queries/-/sort-css-media-queries-2.2.0.tgz", - "integrity": "sha512-0xtkGhWCC9MGt/EzgnvbbbKhqWjl1+/rncmhTh5qCpbYguXh6S/qwePfv/JQ8jePXXmqingylxoC49pCkSPIbA==", - "engines": { - "node": ">= 6.3.0" - } - }, - "node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" - }, - "node_modules/srcset": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/srcset/-/srcset-4.0.0.tgz", - "integrity": "sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/stringify-object": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", - "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", - "license": "BSD-2-Clause", - "dependencies": { - "get-own-enumerable-property-symbols": "^3.0.0", - "is-obj": "^1.0.1", - "is-regexp": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/style-to-object": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz", - "integrity": "sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==", - "dependencies": { - "inline-style-parser": "0.1.1" - } - }, - "node_modules/stylehacks": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz", - "integrity": "sha512-gSTTEQ670cJNoaeIp9KX6lZmm8LJ3jPB5yJmX8Zq/wQxOsAFXV3qjWzHas3YYk1qesuVIyYWWUpZ0vSE/dTSGg==", - "dependencies": { - "browserslist": "^4.23.0", - "postcss-selector-parser": "^6.0.16" - }, - "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/svg-parser": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", - "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==" - }, - "node_modules/svgo": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", - "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", - "dependencies": { - "@trysound/sax": "0.2.0", - "commander": "^7.2.0", - "css-select": "^5.1.0", - "css-tree": "^2.3.1", - "css-what": "^6.1.0", - "csso": "^5.0.5", - "picocolors": "^1.0.0" - }, - "bin": { - "svgo": "bin/svgo" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/svgo" - } - }, - "node_modules/svgo/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "engines": { - "node": ">= 10" - } - }, - "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "engines": { - "node": ">=6" - } - }, - "node_modules/terser": { - "version": "5.31.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.1.tgz", - "integrity": "sha512-37upzU1+viGvuFtBo9NPufCb9dwM0+l9hMxYyWfBA+fbwrPqNJAhbZ6W47bBFnZHKHTUBnMvi87434qq+qnxOg==", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", - "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.20", - "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.26.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/terser-webpack-plugin/node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/terser-webpack-plugin/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" - }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "license": "MIT" - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" - }, - "node_modules/tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" - }, - "node_modules/tinypool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.0.tgz", - "integrity": "sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==", - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" - }, - "node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/type-is/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/type-is/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "license": "MIT", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, - "node_modules/typescript": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" - }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-emoji-modifier-base": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", - "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", - "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "engines": { - "node": ">=4" - } - }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unique-string": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", - "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", - "license": "MIT", - "dependencies": { - "crypto-random-string": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", - "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position-from-estree": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", - "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-remove-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", - "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", - "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", - "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/update-notifier": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-6.0.2.tgz", - "integrity": "sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==", - "license": "BSD-2-Clause", - "dependencies": { - "boxen": "^7.0.0", - "chalk": "^5.0.1", - "configstore": "^6.0.0", - "has-yarn": "^3.0.0", - "import-lazy": "^4.0.0", - "is-ci": "^3.0.1", - "is-installed-globally": "^0.4.0", - "is-npm": "^6.0.0", - "is-yarn-global": "^0.4.0", - "latest-version": "^7.0.0", - "pupa": "^3.1.0", - "semver": "^7.3.7", - "semver-diff": "^4.0.0", - "xdg-basedir": "^5.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/yeoman/update-notifier?sponsor=1" - } - }, - "node_modules/update-notifier/node_modules/boxen": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", - "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^7.0.1", - "chalk": "^5.2.0", - "cli-boxes": "^3.0.0", - "string-width": "^5.1.2", - "type-fest": "^2.13.0", - "widest-line": "^4.0.1", - "wrap-ansi": "^8.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/update-notifier/node_modules/camelcase": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", - "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/update-notifier/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/uri-js/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/url-loader": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz", - "integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==", - "dependencies": { - "loader-utils": "^2.0.0", - "mime-types": "^2.1.27", - "schema-utils": "^3.0.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "file-loader": "*", - "webpack": "^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "file-loader": { - "optional": true - } - } - }, - "node_modules/url-loader/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/url-loader/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/url-loader/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "node_modules/url-loader/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/url-loader/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/url-loader/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utila": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", - "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", - "license": "MIT" - }, - "node_modules/utility-types": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", - "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", - "engines": { - "node": ">= 4" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/value-equal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", - "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vfile": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.1.tgz", - "integrity": "sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-location": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", - "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", - "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/watchpack": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", - "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/wbuf": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", - "license": "MIT", - "dependencies": { - "minimalistic-assert": "^1.0.0" - } - }, - "node_modules/web-namespaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", - "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/webpack": { - "version": "5.96.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.96.1.tgz", - "integrity": "sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==", - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", - "@webassemblyjs/ast": "^1.12.1", - "@webassemblyjs/wasm-edit": "^1.12.1", - "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-bundle-analyzer": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", - "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "0.5.7", - "acorn": "^8.0.4", - "acorn-walk": "^8.0.0", - "commander": "^7.2.0", - "debounce": "^1.2.1", - "escape-string-regexp": "^4.0.0", - "gzip-size": "^6.0.0", - "html-escaper": "^2.0.2", - "opener": "^1.5.2", - "picocolors": "^1.0.0", - "sirv": "^2.0.3", - "ws": "^7.3.1" - }, - "bin": { - "webpack-bundle-analyzer": "lib/bin/analyzer.js" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/webpack-dev-middleware": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", - "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", - "license": "MIT", - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^3.4.3", - "mime-types": "^2.1.31", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" - } - }, - "node_modules/webpack-dev-middleware/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-dev-middleware/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-dev-middleware/node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-dev-server": { - "version": "4.15.2", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", - "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", - "license": "MIT", - "dependencies": { - "@types/bonjour": "^3.5.9", - "@types/connect-history-api-fallback": "^1.3.5", - "@types/express": "^4.17.13", - "@types/serve-index": "^1.9.1", - "@types/serve-static": "^1.13.10", - "@types/sockjs": "^0.3.33", - "@types/ws": "^8.5.5", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.0.11", - "chokidar": "^3.5.3", - "colorette": "^2.0.10", - "compression": "^1.7.4", - "connect-history-api-fallback": "^2.0.0", - "default-gateway": "^6.0.3", - "express": "^4.17.3", - "graceful-fs": "^4.2.6", - "html-entities": "^2.3.2", - "http-proxy-middleware": "^2.0.3", - "ipaddr.js": "^2.0.1", - "launch-editor": "^2.6.0", - "open": "^8.0.9", - "p-retry": "^4.5.0", - "rimraf": "^3.0.2", - "schema-utils": "^4.0.0", - "selfsigned": "^2.1.1", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^5.3.4", - "ws": "^8.13.0" - }, - "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.37.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/webpack-merge": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", - "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", - "license": "MIT", - "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "node_modules/webpack/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/webpackbar": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-6.0.1.tgz", - "integrity": "sha512-TnErZpmuKdwWBdMoexjio3KKX6ZtoKHRVvLIU0A47R0VVBDtx3ZyOJDktgYixhoJokZTYTt1Z37OkO9pnGJa9Q==", - "dependencies": { - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "consola": "^3.2.3", - "figures": "^3.2.0", - "markdown-table": "^2.0.0", - "pretty-time": "^1.1.0", - "std-env": "^3.7.0", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=14.21.3" - }, - "peerDependencies": { - "webpack": "3 || 4 || 5" - } - }, - "node_modules/webpackbar/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/webpackbar/node_modules/markdown-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", - "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", - "dependencies": { - "repeat-string": "^1.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/webpackbar/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/webpackbar/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "license": "Apache-2.0", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/widest-line": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", - "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", - "license": "MIT", - "dependencies": { - "string-width": "^5.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wildcard": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", - "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==" - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } - }, - "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xdg-basedir": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", - "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xml-js": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", - "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", - "dependencies": { - "sax": "^1.2.4" - }, - "bin": { - "xml-js": "bin/cli.js" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" - }, - "node_modules/yocto-queue": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", - "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/docs/package.json b/docs/package.json deleted file mode 100644 index b67d8a7a4..000000000 --- a/docs/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "website", - "version": "2024.08.01", - "private": true, - "scripts": { - "docusaurus": "docusaurus", - "start": "docusaurus start", - "build": "docusaurus build", - "swizzle": "docusaurus swizzle", - "deploy": "docusaurus deploy", - "clear": "docusaurus clear", - "serve": "docusaurus serve", - "write-translations": "docusaurus write-translations", - "write-heading-ids": "docusaurus write-heading-ids" - }, - "dependencies": { - "@docusaurus/core": "3.8.1", - "@docusaurus/plugin-client-redirects": "^3.8.1", - "@docusaurus/preset-classic": "3.8.1", - "@mdx-js/react": "^3.1.0", - "clsx": "^2.0.0", - "docusaurus-theme-github-codeblock": "^2.0.2", - "prism-react-renderer": "^2.4.1", - "react": "^19.1.1", - "react-dom": "^19.1.1" - }, - "devDependencies": { - "@docusaurus/module-type-aliases": "3.8.1", - "@docusaurus/types": "3.8.1" - }, - "browserslist": { - "production": [ - ">0.5%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 3 chrome version", - "last 3 firefox version", - "last 5 safari version" - ] - }, - "engines": { - "node": ">=20.0" - } -} diff --git a/docs/static/api-docs/slack_bolt/adapter/aiohttp/index.html b/docs/reference/adapter/aiohttp/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/aiohttp/index.html rename to docs/reference/adapter/aiohttp/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/aiohttp/index.html b/docs/reference/adapter/asgi/aiohttp/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/asgi/aiohttp/index.html rename to docs/reference/adapter/asgi/aiohttp/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/async_handler.html b/docs/reference/adapter/asgi/async_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/asgi/async_handler.html rename to docs/reference/adapter/asgi/async_handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/base_handler.html b/docs/reference/adapter/asgi/base_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/asgi/base_handler.html rename to docs/reference/adapter/asgi/base_handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/builtin/index.html b/docs/reference/adapter/asgi/builtin/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/asgi/builtin/index.html rename to docs/reference/adapter/asgi/builtin/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/http_request.html b/docs/reference/adapter/asgi/http_request.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/asgi/http_request.html rename to docs/reference/adapter/asgi/http_request.html diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/http_response.html b/docs/reference/adapter/asgi/http_response.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/asgi/http_response.html rename to docs/reference/adapter/asgi/http_response.html diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/index.html b/docs/reference/adapter/asgi/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/asgi/index.html rename to docs/reference/adapter/asgi/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/asgi/utils.html b/docs/reference/adapter/asgi/utils.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/asgi/utils.html rename to docs/reference/adapter/asgi/utils.html diff --git a/docs/static/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html b/docs/reference/adapter/aws_lambda/chalice_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/aws_lambda/chalice_handler.html rename to docs/reference/adapter/aws_lambda/chalice_handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html b/docs/reference/adapter/aws_lambda/chalice_lazy_listener_runner.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.html rename to docs/reference/adapter/aws_lambda/chalice_lazy_listener_runner.html diff --git a/docs/static/api-docs/slack_bolt/adapter/aws_lambda/handler.html b/docs/reference/adapter/aws_lambda/handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/aws_lambda/handler.html rename to docs/reference/adapter/aws_lambda/handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/aws_lambda/index.html b/docs/reference/adapter/aws_lambda/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/aws_lambda/index.html rename to docs/reference/adapter/aws_lambda/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/aws_lambda/internals.html b/docs/reference/adapter/aws_lambda/internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/aws_lambda/internals.html rename to docs/reference/adapter/aws_lambda/internals.html diff --git a/docs/static/api-docs/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.html b/docs/reference/adapter/aws_lambda/lambda_s3_oauth_flow.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.html rename to docs/reference/adapter/aws_lambda/lambda_s3_oauth_flow.html diff --git a/docs/static/api-docs/slack_bolt/adapter/aws_lambda/lazy_listener_runner.html b/docs/reference/adapter/aws_lambda/lazy_listener_runner.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/aws_lambda/lazy_listener_runner.html rename to docs/reference/adapter/aws_lambda/lazy_listener_runner.html diff --git a/docs/static/api-docs/slack_bolt/adapter/aws_lambda/local_lambda_client.html b/docs/reference/adapter/aws_lambda/local_lambda_client.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/aws_lambda/local_lambda_client.html rename to docs/reference/adapter/aws_lambda/local_lambda_client.html diff --git a/docs/static/api-docs/slack_bolt/adapter/bottle/handler.html b/docs/reference/adapter/bottle/handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/bottle/handler.html rename to docs/reference/adapter/bottle/handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/bottle/index.html b/docs/reference/adapter/bottle/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/bottle/index.html rename to docs/reference/adapter/bottle/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/cherrypy/handler.html b/docs/reference/adapter/cherrypy/handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/cherrypy/handler.html rename to docs/reference/adapter/cherrypy/handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/cherrypy/index.html b/docs/reference/adapter/cherrypy/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/cherrypy/index.html rename to docs/reference/adapter/cherrypy/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/django/handler.html b/docs/reference/adapter/django/handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/django/handler.html rename to docs/reference/adapter/django/handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/django/index.html b/docs/reference/adapter/django/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/django/index.html rename to docs/reference/adapter/django/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/falcon/async_resource.html b/docs/reference/adapter/falcon/async_resource.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/falcon/async_resource.html rename to docs/reference/adapter/falcon/async_resource.html diff --git a/docs/static/api-docs/slack_bolt/adapter/falcon/index.html b/docs/reference/adapter/falcon/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/falcon/index.html rename to docs/reference/adapter/falcon/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/falcon/resource.html b/docs/reference/adapter/falcon/resource.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/falcon/resource.html rename to docs/reference/adapter/falcon/resource.html diff --git a/docs/static/api-docs/slack_bolt/adapter/fastapi/async_handler.html b/docs/reference/adapter/fastapi/async_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/fastapi/async_handler.html rename to docs/reference/adapter/fastapi/async_handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/fastapi/index.html b/docs/reference/adapter/fastapi/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/fastapi/index.html rename to docs/reference/adapter/fastapi/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/flask/handler.html b/docs/reference/adapter/flask/handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/flask/handler.html rename to docs/reference/adapter/flask/handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/flask/index.html b/docs/reference/adapter/flask/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/flask/index.html rename to docs/reference/adapter/flask/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/google_cloud_functions/handler.html b/docs/reference/adapter/google_cloud_functions/handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/google_cloud_functions/handler.html rename to docs/reference/adapter/google_cloud_functions/handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/google_cloud_functions/index.html b/docs/reference/adapter/google_cloud_functions/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/google_cloud_functions/index.html rename to docs/reference/adapter/google_cloud_functions/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/index.html b/docs/reference/adapter/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/index.html rename to docs/reference/adapter/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/pyramid/handler.html b/docs/reference/adapter/pyramid/handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/pyramid/handler.html rename to docs/reference/adapter/pyramid/handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/pyramid/index.html b/docs/reference/adapter/pyramid/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/pyramid/index.html rename to docs/reference/adapter/pyramid/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/sanic/async_handler.html b/docs/reference/adapter/sanic/async_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/sanic/async_handler.html rename to docs/reference/adapter/sanic/async_handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/sanic/index.html b/docs/reference/adapter/sanic/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/sanic/index.html rename to docs/reference/adapter/sanic/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/socket_mode/aiohttp/index.html b/docs/reference/adapter/socket_mode/aiohttp/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/socket_mode/aiohttp/index.html rename to docs/reference/adapter/socket_mode/aiohttp/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/socket_mode/async_base_handler.html b/docs/reference/adapter/socket_mode/async_base_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/socket_mode/async_base_handler.html rename to docs/reference/adapter/socket_mode/async_base_handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/socket_mode/async_handler.html b/docs/reference/adapter/socket_mode/async_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/socket_mode/async_handler.html rename to docs/reference/adapter/socket_mode/async_handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/socket_mode/async_internals.html b/docs/reference/adapter/socket_mode/async_internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/socket_mode/async_internals.html rename to docs/reference/adapter/socket_mode/async_internals.html diff --git a/docs/static/api-docs/slack_bolt/adapter/socket_mode/base_handler.html b/docs/reference/adapter/socket_mode/base_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/socket_mode/base_handler.html rename to docs/reference/adapter/socket_mode/base_handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/socket_mode/builtin/index.html b/docs/reference/adapter/socket_mode/builtin/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/socket_mode/builtin/index.html rename to docs/reference/adapter/socket_mode/builtin/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/socket_mode/index.html b/docs/reference/adapter/socket_mode/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/socket_mode/index.html rename to docs/reference/adapter/socket_mode/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/socket_mode/internals.html b/docs/reference/adapter/socket_mode/internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/socket_mode/internals.html rename to docs/reference/adapter/socket_mode/internals.html diff --git a/docs/static/api-docs/slack_bolt/adapter/socket_mode/websocket_client/index.html b/docs/reference/adapter/socket_mode/websocket_client/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/socket_mode/websocket_client/index.html rename to docs/reference/adapter/socket_mode/websocket_client/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/socket_mode/websockets/index.html b/docs/reference/adapter/socket_mode/websockets/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/socket_mode/websockets/index.html rename to docs/reference/adapter/socket_mode/websockets/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/starlette/async_handler.html b/docs/reference/adapter/starlette/async_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/starlette/async_handler.html rename to docs/reference/adapter/starlette/async_handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/starlette/handler.html b/docs/reference/adapter/starlette/handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/starlette/handler.html rename to docs/reference/adapter/starlette/handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/starlette/index.html b/docs/reference/adapter/starlette/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/starlette/index.html rename to docs/reference/adapter/starlette/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/tornado/async_handler.html b/docs/reference/adapter/tornado/async_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/tornado/async_handler.html rename to docs/reference/adapter/tornado/async_handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/tornado/handler.html b/docs/reference/adapter/tornado/handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/tornado/handler.html rename to docs/reference/adapter/tornado/handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/tornado/index.html b/docs/reference/adapter/tornado/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/tornado/index.html rename to docs/reference/adapter/tornado/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/wsgi/handler.html b/docs/reference/adapter/wsgi/handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/wsgi/handler.html rename to docs/reference/adapter/wsgi/handler.html diff --git a/docs/static/api-docs/slack_bolt/adapter/wsgi/http_request.html b/docs/reference/adapter/wsgi/http_request.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/wsgi/http_request.html rename to docs/reference/adapter/wsgi/http_request.html diff --git a/docs/static/api-docs/slack_bolt/adapter/wsgi/http_response.html b/docs/reference/adapter/wsgi/http_response.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/wsgi/http_response.html rename to docs/reference/adapter/wsgi/http_response.html diff --git a/docs/static/api-docs/slack_bolt/adapter/wsgi/index.html b/docs/reference/adapter/wsgi/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/wsgi/index.html rename to docs/reference/adapter/wsgi/index.html diff --git a/docs/static/api-docs/slack_bolt/adapter/wsgi/internals.html b/docs/reference/adapter/wsgi/internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/adapter/wsgi/internals.html rename to docs/reference/adapter/wsgi/internals.html diff --git a/docs/static/api-docs/slack_bolt/app/app.html b/docs/reference/app/app.html similarity index 100% rename from docs/static/api-docs/slack_bolt/app/app.html rename to docs/reference/app/app.html diff --git a/docs/static/api-docs/slack_bolt/app/async_app.html b/docs/reference/app/async_app.html similarity index 100% rename from docs/static/api-docs/slack_bolt/app/async_app.html rename to docs/reference/app/async_app.html diff --git a/docs/static/api-docs/slack_bolt/app/async_server.html b/docs/reference/app/async_server.html similarity index 100% rename from docs/static/api-docs/slack_bolt/app/async_server.html rename to docs/reference/app/async_server.html diff --git a/docs/static/api-docs/slack_bolt/app/index.html b/docs/reference/app/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/app/index.html rename to docs/reference/app/index.html diff --git a/docs/static/api-docs/slack_bolt/async_app.html b/docs/reference/async_app.html similarity index 100% rename from docs/static/api-docs/slack_bolt/async_app.html rename to docs/reference/async_app.html diff --git a/docs/static/api-docs/slack_bolt/authorization/async_authorize.html b/docs/reference/authorization/async_authorize.html similarity index 100% rename from docs/static/api-docs/slack_bolt/authorization/async_authorize.html rename to docs/reference/authorization/async_authorize.html diff --git a/docs/static/api-docs/slack_bolt/authorization/async_authorize_args.html b/docs/reference/authorization/async_authorize_args.html similarity index 100% rename from docs/static/api-docs/slack_bolt/authorization/async_authorize_args.html rename to docs/reference/authorization/async_authorize_args.html diff --git a/docs/static/api-docs/slack_bolt/authorization/authorize.html b/docs/reference/authorization/authorize.html similarity index 100% rename from docs/static/api-docs/slack_bolt/authorization/authorize.html rename to docs/reference/authorization/authorize.html diff --git a/docs/static/api-docs/slack_bolt/authorization/authorize_args.html b/docs/reference/authorization/authorize_args.html similarity index 100% rename from docs/static/api-docs/slack_bolt/authorization/authorize_args.html rename to docs/reference/authorization/authorize_args.html diff --git a/docs/static/api-docs/slack_bolt/authorization/authorize_result.html b/docs/reference/authorization/authorize_result.html similarity index 100% rename from docs/static/api-docs/slack_bolt/authorization/authorize_result.html rename to docs/reference/authorization/authorize_result.html diff --git a/docs/static/api-docs/slack_bolt/authorization/index.html b/docs/reference/authorization/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/authorization/index.html rename to docs/reference/authorization/index.html diff --git a/docs/static/api-docs/slack_bolt/context/ack/ack.html b/docs/reference/context/ack/ack.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/ack/ack.html rename to docs/reference/context/ack/ack.html diff --git a/docs/static/api-docs/slack_bolt/context/ack/async_ack.html b/docs/reference/context/ack/async_ack.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/ack/async_ack.html rename to docs/reference/context/ack/async_ack.html diff --git a/docs/static/api-docs/slack_bolt/context/ack/index.html b/docs/reference/context/ack/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/ack/index.html rename to docs/reference/context/ack/index.html diff --git a/docs/static/api-docs/slack_bolt/context/ack/internals.html b/docs/reference/context/ack/internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/ack/internals.html rename to docs/reference/context/ack/internals.html diff --git a/docs/static/api-docs/slack_bolt/context/assistant/assistant_utilities.html b/docs/reference/context/assistant/assistant_utilities.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/assistant/assistant_utilities.html rename to docs/reference/context/assistant/assistant_utilities.html diff --git a/docs/static/api-docs/slack_bolt/context/assistant/async_assistant_utilities.html b/docs/reference/context/assistant/async_assistant_utilities.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/assistant/async_assistant_utilities.html rename to docs/reference/context/assistant/async_assistant_utilities.html diff --git a/docs/static/api-docs/slack_bolt/context/assistant/index.html b/docs/reference/context/assistant/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/assistant/index.html rename to docs/reference/context/assistant/index.html diff --git a/docs/static/api-docs/slack_bolt/context/assistant/internals.html b/docs/reference/context/assistant/internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/assistant/internals.html rename to docs/reference/context/assistant/internals.html diff --git a/docs/static/api-docs/slack_bolt/context/assistant/thread_context/index.html b/docs/reference/context/assistant/thread_context/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/assistant/thread_context/index.html rename to docs/reference/context/assistant/thread_context/index.html diff --git a/docs/static/api-docs/slack_bolt/context/assistant/thread_context_store/async_store.html b/docs/reference/context/assistant/thread_context_store/async_store.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/assistant/thread_context_store/async_store.html rename to docs/reference/context/assistant/thread_context_store/async_store.html diff --git a/docs/static/api-docs/slack_bolt/context/assistant/thread_context_store/default_async_store.html b/docs/reference/context/assistant/thread_context_store/default_async_store.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/assistant/thread_context_store/default_async_store.html rename to docs/reference/context/assistant/thread_context_store/default_async_store.html diff --git a/docs/static/api-docs/slack_bolt/context/assistant/thread_context_store/default_store.html b/docs/reference/context/assistant/thread_context_store/default_store.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/assistant/thread_context_store/default_store.html rename to docs/reference/context/assistant/thread_context_store/default_store.html diff --git a/docs/static/api-docs/slack_bolt/context/assistant/thread_context_store/file/index.html b/docs/reference/context/assistant/thread_context_store/file/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/assistant/thread_context_store/file/index.html rename to docs/reference/context/assistant/thread_context_store/file/index.html diff --git a/docs/static/api-docs/slack_bolt/context/assistant/thread_context_store/index.html b/docs/reference/context/assistant/thread_context_store/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/assistant/thread_context_store/index.html rename to docs/reference/context/assistant/thread_context_store/index.html diff --git a/docs/static/api-docs/slack_bolt/context/assistant/thread_context_store/store.html b/docs/reference/context/assistant/thread_context_store/store.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/assistant/thread_context_store/store.html rename to docs/reference/context/assistant/thread_context_store/store.html diff --git a/docs/static/api-docs/slack_bolt/context/async_context.html b/docs/reference/context/async_context.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/async_context.html rename to docs/reference/context/async_context.html diff --git a/docs/static/api-docs/slack_bolt/context/base_context.html b/docs/reference/context/base_context.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/base_context.html rename to docs/reference/context/base_context.html diff --git a/docs/static/api-docs/slack_bolt/context/complete/async_complete.html b/docs/reference/context/complete/async_complete.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/complete/async_complete.html rename to docs/reference/context/complete/async_complete.html diff --git a/docs/static/api-docs/slack_bolt/context/complete/complete.html b/docs/reference/context/complete/complete.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/complete/complete.html rename to docs/reference/context/complete/complete.html diff --git a/docs/static/api-docs/slack_bolt/context/complete/index.html b/docs/reference/context/complete/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/complete/index.html rename to docs/reference/context/complete/index.html diff --git a/docs/static/api-docs/slack_bolt/context/context.html b/docs/reference/context/context.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/context.html rename to docs/reference/context/context.html diff --git a/docs/static/api-docs/slack_bolt/context/fail/async_fail.html b/docs/reference/context/fail/async_fail.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/fail/async_fail.html rename to docs/reference/context/fail/async_fail.html diff --git a/docs/static/api-docs/slack_bolt/context/fail/fail.html b/docs/reference/context/fail/fail.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/fail/fail.html rename to docs/reference/context/fail/fail.html diff --git a/docs/static/api-docs/slack_bolt/context/fail/index.html b/docs/reference/context/fail/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/fail/index.html rename to docs/reference/context/fail/index.html diff --git a/docs/static/api-docs/slack_bolt/context/get_thread_context/async_get_thread_context.html b/docs/reference/context/get_thread_context/async_get_thread_context.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/get_thread_context/async_get_thread_context.html rename to docs/reference/context/get_thread_context/async_get_thread_context.html diff --git a/docs/static/api-docs/slack_bolt/context/get_thread_context/get_thread_context.html b/docs/reference/context/get_thread_context/get_thread_context.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/get_thread_context/get_thread_context.html rename to docs/reference/context/get_thread_context/get_thread_context.html diff --git a/docs/static/api-docs/slack_bolt/context/get_thread_context/index.html b/docs/reference/context/get_thread_context/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/get_thread_context/index.html rename to docs/reference/context/get_thread_context/index.html diff --git a/docs/static/api-docs/slack_bolt/context/index.html b/docs/reference/context/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/index.html rename to docs/reference/context/index.html diff --git a/docs/static/api-docs/slack_bolt/context/respond/async_respond.html b/docs/reference/context/respond/async_respond.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/respond/async_respond.html rename to docs/reference/context/respond/async_respond.html diff --git a/docs/static/api-docs/slack_bolt/context/respond/index.html b/docs/reference/context/respond/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/respond/index.html rename to docs/reference/context/respond/index.html diff --git a/docs/static/api-docs/slack_bolt/context/respond/internals.html b/docs/reference/context/respond/internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/respond/internals.html rename to docs/reference/context/respond/internals.html diff --git a/docs/static/api-docs/slack_bolt/context/respond/respond.html b/docs/reference/context/respond/respond.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/respond/respond.html rename to docs/reference/context/respond/respond.html diff --git a/docs/static/api-docs/slack_bolt/context/save_thread_context/async_save_thread_context.html b/docs/reference/context/save_thread_context/async_save_thread_context.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/save_thread_context/async_save_thread_context.html rename to docs/reference/context/save_thread_context/async_save_thread_context.html diff --git a/docs/static/api-docs/slack_bolt/context/save_thread_context/index.html b/docs/reference/context/save_thread_context/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/save_thread_context/index.html rename to docs/reference/context/save_thread_context/index.html diff --git a/docs/static/api-docs/slack_bolt/context/save_thread_context/save_thread_context.html b/docs/reference/context/save_thread_context/save_thread_context.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/save_thread_context/save_thread_context.html rename to docs/reference/context/save_thread_context/save_thread_context.html diff --git a/docs/static/api-docs/slack_bolt/context/say/async_say.html b/docs/reference/context/say/async_say.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/say/async_say.html rename to docs/reference/context/say/async_say.html diff --git a/docs/static/api-docs/slack_bolt/context/say/index.html b/docs/reference/context/say/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/say/index.html rename to docs/reference/context/say/index.html diff --git a/docs/static/api-docs/slack_bolt/context/say/internals.html b/docs/reference/context/say/internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/say/internals.html rename to docs/reference/context/say/internals.html diff --git a/docs/static/api-docs/slack_bolt/context/say/say.html b/docs/reference/context/say/say.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/say/say.html rename to docs/reference/context/say/say.html diff --git a/docs/static/api-docs/slack_bolt/context/set_status/async_set_status.html b/docs/reference/context/set_status/async_set_status.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/set_status/async_set_status.html rename to docs/reference/context/set_status/async_set_status.html diff --git a/docs/static/api-docs/slack_bolt/context/set_status/index.html b/docs/reference/context/set_status/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/set_status/index.html rename to docs/reference/context/set_status/index.html diff --git a/docs/static/api-docs/slack_bolt/context/set_status/set_status.html b/docs/reference/context/set_status/set_status.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/set_status/set_status.html rename to docs/reference/context/set_status/set_status.html diff --git a/docs/static/api-docs/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.html b/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.html rename to docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html diff --git a/docs/static/api-docs/slack_bolt/context/set_suggested_prompts/index.html b/docs/reference/context/set_suggested_prompts/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/set_suggested_prompts/index.html rename to docs/reference/context/set_suggested_prompts/index.html diff --git a/docs/static/api-docs/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.html b/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.html rename to docs/reference/context/set_suggested_prompts/set_suggested_prompts.html diff --git a/docs/static/api-docs/slack_bolt/context/set_title/async_set_title.html b/docs/reference/context/set_title/async_set_title.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/set_title/async_set_title.html rename to docs/reference/context/set_title/async_set_title.html diff --git a/docs/static/api-docs/slack_bolt/context/set_title/index.html b/docs/reference/context/set_title/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/set_title/index.html rename to docs/reference/context/set_title/index.html diff --git a/docs/static/api-docs/slack_bolt/context/set_title/set_title.html b/docs/reference/context/set_title/set_title.html similarity index 100% rename from docs/static/api-docs/slack_bolt/context/set_title/set_title.html rename to docs/reference/context/set_title/set_title.html diff --git a/docs/static/api-docs/slack_bolt/error/index.html b/docs/reference/error/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/error/index.html rename to docs/reference/error/index.html diff --git a/docs/static/api-docs/slack_bolt/index.html b/docs/reference/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/index.html rename to docs/reference/index.html diff --git a/docs/static/api-docs/slack_bolt/kwargs_injection/args.html b/docs/reference/kwargs_injection/args.html similarity index 100% rename from docs/static/api-docs/slack_bolt/kwargs_injection/args.html rename to docs/reference/kwargs_injection/args.html diff --git a/docs/static/api-docs/slack_bolt/kwargs_injection/async_args.html b/docs/reference/kwargs_injection/async_args.html similarity index 100% rename from docs/static/api-docs/slack_bolt/kwargs_injection/async_args.html rename to docs/reference/kwargs_injection/async_args.html diff --git a/docs/static/api-docs/slack_bolt/kwargs_injection/async_utils.html b/docs/reference/kwargs_injection/async_utils.html similarity index 100% rename from docs/static/api-docs/slack_bolt/kwargs_injection/async_utils.html rename to docs/reference/kwargs_injection/async_utils.html diff --git a/docs/static/api-docs/slack_bolt/kwargs_injection/index.html b/docs/reference/kwargs_injection/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/kwargs_injection/index.html rename to docs/reference/kwargs_injection/index.html diff --git a/docs/static/api-docs/slack_bolt/kwargs_injection/utils.html b/docs/reference/kwargs_injection/utils.html similarity index 100% rename from docs/static/api-docs/slack_bolt/kwargs_injection/utils.html rename to docs/reference/kwargs_injection/utils.html diff --git a/docs/static/api-docs/slack_bolt/lazy_listener/async_internals.html b/docs/reference/lazy_listener/async_internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/lazy_listener/async_internals.html rename to docs/reference/lazy_listener/async_internals.html diff --git a/docs/static/api-docs/slack_bolt/lazy_listener/async_runner.html b/docs/reference/lazy_listener/async_runner.html similarity index 100% rename from docs/static/api-docs/slack_bolt/lazy_listener/async_runner.html rename to docs/reference/lazy_listener/async_runner.html diff --git a/docs/static/api-docs/slack_bolt/lazy_listener/asyncio_runner.html b/docs/reference/lazy_listener/asyncio_runner.html similarity index 100% rename from docs/static/api-docs/slack_bolt/lazy_listener/asyncio_runner.html rename to docs/reference/lazy_listener/asyncio_runner.html diff --git a/docs/static/api-docs/slack_bolt/lazy_listener/index.html b/docs/reference/lazy_listener/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/lazy_listener/index.html rename to docs/reference/lazy_listener/index.html diff --git a/docs/static/api-docs/slack_bolt/lazy_listener/internals.html b/docs/reference/lazy_listener/internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/lazy_listener/internals.html rename to docs/reference/lazy_listener/internals.html diff --git a/docs/static/api-docs/slack_bolt/lazy_listener/runner.html b/docs/reference/lazy_listener/runner.html similarity index 100% rename from docs/static/api-docs/slack_bolt/lazy_listener/runner.html rename to docs/reference/lazy_listener/runner.html diff --git a/docs/static/api-docs/slack_bolt/lazy_listener/thread_runner.html b/docs/reference/lazy_listener/thread_runner.html similarity index 100% rename from docs/static/api-docs/slack_bolt/lazy_listener/thread_runner.html rename to docs/reference/lazy_listener/thread_runner.html diff --git a/docs/static/api-docs/slack_bolt/listener/async_builtins.html b/docs/reference/listener/async_builtins.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/async_builtins.html rename to docs/reference/listener/async_builtins.html diff --git a/docs/static/api-docs/slack_bolt/listener/async_listener.html b/docs/reference/listener/async_listener.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/async_listener.html rename to docs/reference/listener/async_listener.html diff --git a/docs/static/api-docs/slack_bolt/listener/async_listener_completion_handler.html b/docs/reference/listener/async_listener_completion_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/async_listener_completion_handler.html rename to docs/reference/listener/async_listener_completion_handler.html diff --git a/docs/static/api-docs/slack_bolt/listener/async_listener_error_handler.html b/docs/reference/listener/async_listener_error_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/async_listener_error_handler.html rename to docs/reference/listener/async_listener_error_handler.html diff --git a/docs/static/api-docs/slack_bolt/listener/async_listener_start_handler.html b/docs/reference/listener/async_listener_start_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/async_listener_start_handler.html rename to docs/reference/listener/async_listener_start_handler.html diff --git a/docs/static/api-docs/slack_bolt/listener/asyncio_runner.html b/docs/reference/listener/asyncio_runner.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/asyncio_runner.html rename to docs/reference/listener/asyncio_runner.html diff --git a/docs/static/api-docs/slack_bolt/listener/builtins.html b/docs/reference/listener/builtins.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/builtins.html rename to docs/reference/listener/builtins.html diff --git a/docs/static/api-docs/slack_bolt/listener/custom_listener.html b/docs/reference/listener/custom_listener.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/custom_listener.html rename to docs/reference/listener/custom_listener.html diff --git a/docs/static/api-docs/slack_bolt/listener/index.html b/docs/reference/listener/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/index.html rename to docs/reference/listener/index.html diff --git a/docs/static/api-docs/slack_bolt/listener/listener.html b/docs/reference/listener/listener.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/listener.html rename to docs/reference/listener/listener.html diff --git a/docs/static/api-docs/slack_bolt/listener/listener_completion_handler.html b/docs/reference/listener/listener_completion_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/listener_completion_handler.html rename to docs/reference/listener/listener_completion_handler.html diff --git a/docs/static/api-docs/slack_bolt/listener/listener_error_handler.html b/docs/reference/listener/listener_error_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/listener_error_handler.html rename to docs/reference/listener/listener_error_handler.html diff --git a/docs/static/api-docs/slack_bolt/listener/listener_start_handler.html b/docs/reference/listener/listener_start_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/listener_start_handler.html rename to docs/reference/listener/listener_start_handler.html diff --git a/docs/static/api-docs/slack_bolt/listener/thread_runner.html b/docs/reference/listener/thread_runner.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener/thread_runner.html rename to docs/reference/listener/thread_runner.html diff --git a/docs/static/api-docs/slack_bolt/listener_matcher/async_builtins.html b/docs/reference/listener_matcher/async_builtins.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener_matcher/async_builtins.html rename to docs/reference/listener_matcher/async_builtins.html diff --git a/docs/static/api-docs/slack_bolt/listener_matcher/async_listener_matcher.html b/docs/reference/listener_matcher/async_listener_matcher.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener_matcher/async_listener_matcher.html rename to docs/reference/listener_matcher/async_listener_matcher.html diff --git a/docs/static/api-docs/slack_bolt/listener_matcher/builtins.html b/docs/reference/listener_matcher/builtins.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener_matcher/builtins.html rename to docs/reference/listener_matcher/builtins.html diff --git a/docs/static/api-docs/slack_bolt/listener_matcher/custom_listener_matcher.html b/docs/reference/listener_matcher/custom_listener_matcher.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener_matcher/custom_listener_matcher.html rename to docs/reference/listener_matcher/custom_listener_matcher.html diff --git a/docs/static/api-docs/slack_bolt/listener_matcher/index.html b/docs/reference/listener_matcher/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener_matcher/index.html rename to docs/reference/listener_matcher/index.html diff --git a/docs/static/api-docs/slack_bolt/listener_matcher/listener_matcher.html b/docs/reference/listener_matcher/listener_matcher.html similarity index 100% rename from docs/static/api-docs/slack_bolt/listener_matcher/listener_matcher.html rename to docs/reference/listener_matcher/listener_matcher.html diff --git a/docs/static/api-docs/slack_bolt/logger/index.html b/docs/reference/logger/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/logger/index.html rename to docs/reference/logger/index.html diff --git a/docs/static/api-docs/slack_bolt/logger/messages.html b/docs/reference/logger/messages.html similarity index 100% rename from docs/static/api-docs/slack_bolt/logger/messages.html rename to docs/reference/logger/messages.html diff --git a/docs/static/api-docs/slack_bolt/middleware/assistant/assistant.html b/docs/reference/middleware/assistant/assistant.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/assistant/assistant.html rename to docs/reference/middleware/assistant/assistant.html diff --git a/docs/static/api-docs/slack_bolt/middleware/assistant/async_assistant.html b/docs/reference/middleware/assistant/async_assistant.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/assistant/async_assistant.html rename to docs/reference/middleware/assistant/async_assistant.html diff --git a/docs/static/api-docs/slack_bolt/middleware/assistant/index.html b/docs/reference/middleware/assistant/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/assistant/index.html rename to docs/reference/middleware/assistant/index.html diff --git a/docs/static/api-docs/slack_bolt/middleware/async_builtins.html b/docs/reference/middleware/async_builtins.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/async_builtins.html rename to docs/reference/middleware/async_builtins.html diff --git a/docs/static/api-docs/slack_bolt/middleware/async_custom_middleware.html b/docs/reference/middleware/async_custom_middleware.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/async_custom_middleware.html rename to docs/reference/middleware/async_custom_middleware.html diff --git a/docs/static/api-docs/slack_bolt/middleware/async_middleware.html b/docs/reference/middleware/async_middleware.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/async_middleware.html rename to docs/reference/middleware/async_middleware.html diff --git a/docs/static/api-docs/slack_bolt/middleware/async_middleware_error_handler.html b/docs/reference/middleware/async_middleware_error_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/async_middleware_error_handler.html rename to docs/reference/middleware/async_middleware_error_handler.html diff --git a/docs/static/api-docs/slack_bolt/middleware/attaching_function_token/async_attaching_function_token.html b/docs/reference/middleware/attaching_function_token/async_attaching_function_token.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/attaching_function_token/async_attaching_function_token.html rename to docs/reference/middleware/attaching_function_token/async_attaching_function_token.html diff --git a/docs/static/api-docs/slack_bolt/middleware/attaching_function_token/attaching_function_token.html b/docs/reference/middleware/attaching_function_token/attaching_function_token.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/attaching_function_token/attaching_function_token.html rename to docs/reference/middleware/attaching_function_token/attaching_function_token.html diff --git a/docs/static/api-docs/slack_bolt/middleware/attaching_function_token/index.html b/docs/reference/middleware/attaching_function_token/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/attaching_function_token/index.html rename to docs/reference/middleware/attaching_function_token/index.html diff --git a/docs/static/api-docs/slack_bolt/middleware/authorization/async_authorization.html b/docs/reference/middleware/authorization/async_authorization.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/authorization/async_authorization.html rename to docs/reference/middleware/authorization/async_authorization.html diff --git a/docs/static/api-docs/slack_bolt/middleware/authorization/async_internals.html b/docs/reference/middleware/authorization/async_internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/authorization/async_internals.html rename to docs/reference/middleware/authorization/async_internals.html diff --git a/docs/static/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html b/docs/reference/middleware/authorization/async_multi_teams_authorization.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/authorization/async_multi_teams_authorization.html rename to docs/reference/middleware/authorization/async_multi_teams_authorization.html diff --git a/docs/static/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html b/docs/reference/middleware/authorization/async_single_team_authorization.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/authorization/async_single_team_authorization.html rename to docs/reference/middleware/authorization/async_single_team_authorization.html diff --git a/docs/static/api-docs/slack_bolt/middleware/authorization/authorization.html b/docs/reference/middleware/authorization/authorization.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/authorization/authorization.html rename to docs/reference/middleware/authorization/authorization.html diff --git a/docs/static/api-docs/slack_bolt/middleware/authorization/index.html b/docs/reference/middleware/authorization/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/authorization/index.html rename to docs/reference/middleware/authorization/index.html diff --git a/docs/static/api-docs/slack_bolt/middleware/authorization/internals.html b/docs/reference/middleware/authorization/internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/authorization/internals.html rename to docs/reference/middleware/authorization/internals.html diff --git a/docs/static/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html b/docs/reference/middleware/authorization/multi_teams_authorization.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/authorization/multi_teams_authorization.html rename to docs/reference/middleware/authorization/multi_teams_authorization.html diff --git a/docs/static/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html b/docs/reference/middleware/authorization/single_team_authorization.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/authorization/single_team_authorization.html rename to docs/reference/middleware/authorization/single_team_authorization.html diff --git a/docs/static/api-docs/slack_bolt/middleware/custom_middleware.html b/docs/reference/middleware/custom_middleware.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/custom_middleware.html rename to docs/reference/middleware/custom_middleware.html diff --git a/docs/static/api-docs/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.html b/docs/reference/middleware/ignoring_self_events/async_ignoring_self_events.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.html rename to docs/reference/middleware/ignoring_self_events/async_ignoring_self_events.html diff --git a/docs/static/api-docs/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.html b/docs/reference/middleware/ignoring_self_events/ignoring_self_events.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.html rename to docs/reference/middleware/ignoring_self_events/ignoring_self_events.html diff --git a/docs/static/api-docs/slack_bolt/middleware/ignoring_self_events/index.html b/docs/reference/middleware/ignoring_self_events/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/ignoring_self_events/index.html rename to docs/reference/middleware/ignoring_self_events/index.html diff --git a/docs/static/api-docs/slack_bolt/middleware/index.html b/docs/reference/middleware/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/index.html rename to docs/reference/middleware/index.html diff --git a/docs/static/api-docs/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.html b/docs/reference/middleware/message_listener_matches/async_message_listener_matches.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.html rename to docs/reference/middleware/message_listener_matches/async_message_listener_matches.html diff --git a/docs/static/api-docs/slack_bolt/middleware/message_listener_matches/index.html b/docs/reference/middleware/message_listener_matches/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/message_listener_matches/index.html rename to docs/reference/middleware/message_listener_matches/index.html diff --git a/docs/static/api-docs/slack_bolt/middleware/message_listener_matches/message_listener_matches.html b/docs/reference/middleware/message_listener_matches/message_listener_matches.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/message_listener_matches/message_listener_matches.html rename to docs/reference/middleware/message_listener_matches/message_listener_matches.html diff --git a/docs/static/api-docs/slack_bolt/middleware/middleware.html b/docs/reference/middleware/middleware.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/middleware.html rename to docs/reference/middleware/middleware.html diff --git a/docs/static/api-docs/slack_bolt/middleware/middleware_error_handler.html b/docs/reference/middleware/middleware_error_handler.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/middleware_error_handler.html rename to docs/reference/middleware/middleware_error_handler.html diff --git a/docs/static/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html b/docs/reference/middleware/request_verification/async_request_verification.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/request_verification/async_request_verification.html rename to docs/reference/middleware/request_verification/async_request_verification.html diff --git a/docs/static/api-docs/slack_bolt/middleware/request_verification/index.html b/docs/reference/middleware/request_verification/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/request_verification/index.html rename to docs/reference/middleware/request_verification/index.html diff --git a/docs/static/api-docs/slack_bolt/middleware/request_verification/request_verification.html b/docs/reference/middleware/request_verification/request_verification.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/request_verification/request_verification.html rename to docs/reference/middleware/request_verification/request_verification.html diff --git a/docs/static/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html b/docs/reference/middleware/ssl_check/async_ssl_check.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/ssl_check/async_ssl_check.html rename to docs/reference/middleware/ssl_check/async_ssl_check.html diff --git a/docs/static/api-docs/slack_bolt/middleware/ssl_check/index.html b/docs/reference/middleware/ssl_check/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/ssl_check/index.html rename to docs/reference/middleware/ssl_check/index.html diff --git a/docs/static/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html b/docs/reference/middleware/ssl_check/ssl_check.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/ssl_check/ssl_check.html rename to docs/reference/middleware/ssl_check/ssl_check.html diff --git a/docs/static/api-docs/slack_bolt/middleware/url_verification/async_url_verification.html b/docs/reference/middleware/url_verification/async_url_verification.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/url_verification/async_url_verification.html rename to docs/reference/middleware/url_verification/async_url_verification.html diff --git a/docs/static/api-docs/slack_bolt/middleware/url_verification/index.html b/docs/reference/middleware/url_verification/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/url_verification/index.html rename to docs/reference/middleware/url_verification/index.html diff --git a/docs/static/api-docs/slack_bolt/middleware/url_verification/url_verification.html b/docs/reference/middleware/url_verification/url_verification.html similarity index 100% rename from docs/static/api-docs/slack_bolt/middleware/url_verification/url_verification.html rename to docs/reference/middleware/url_verification/url_verification.html diff --git a/docs/static/api-docs/slack_bolt/oauth/async_callback_options.html b/docs/reference/oauth/async_callback_options.html similarity index 100% rename from docs/static/api-docs/slack_bolt/oauth/async_callback_options.html rename to docs/reference/oauth/async_callback_options.html diff --git a/docs/static/api-docs/slack_bolt/oauth/async_internals.html b/docs/reference/oauth/async_internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/oauth/async_internals.html rename to docs/reference/oauth/async_internals.html diff --git a/docs/static/api-docs/slack_bolt/oauth/async_oauth_flow.html b/docs/reference/oauth/async_oauth_flow.html similarity index 100% rename from docs/static/api-docs/slack_bolt/oauth/async_oauth_flow.html rename to docs/reference/oauth/async_oauth_flow.html diff --git a/docs/static/api-docs/slack_bolt/oauth/async_oauth_settings.html b/docs/reference/oauth/async_oauth_settings.html similarity index 100% rename from docs/static/api-docs/slack_bolt/oauth/async_oauth_settings.html rename to docs/reference/oauth/async_oauth_settings.html diff --git a/docs/static/api-docs/slack_bolt/oauth/callback_options.html b/docs/reference/oauth/callback_options.html similarity index 100% rename from docs/static/api-docs/slack_bolt/oauth/callback_options.html rename to docs/reference/oauth/callback_options.html diff --git a/docs/static/api-docs/slack_bolt/oauth/index.html b/docs/reference/oauth/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/oauth/index.html rename to docs/reference/oauth/index.html diff --git a/docs/static/api-docs/slack_bolt/oauth/internals.html b/docs/reference/oauth/internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/oauth/internals.html rename to docs/reference/oauth/internals.html diff --git a/docs/static/api-docs/slack_bolt/oauth/oauth_flow.html b/docs/reference/oauth/oauth_flow.html similarity index 100% rename from docs/static/api-docs/slack_bolt/oauth/oauth_flow.html rename to docs/reference/oauth/oauth_flow.html diff --git a/docs/static/api-docs/slack_bolt/oauth/oauth_settings.html b/docs/reference/oauth/oauth_settings.html similarity index 100% rename from docs/static/api-docs/slack_bolt/oauth/oauth_settings.html rename to docs/reference/oauth/oauth_settings.html diff --git a/docs/static/api-docs/slack_bolt/request/async_internals.html b/docs/reference/request/async_internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/request/async_internals.html rename to docs/reference/request/async_internals.html diff --git a/docs/static/api-docs/slack_bolt/request/async_request.html b/docs/reference/request/async_request.html similarity index 100% rename from docs/static/api-docs/slack_bolt/request/async_request.html rename to docs/reference/request/async_request.html diff --git a/docs/static/api-docs/slack_bolt/request/index.html b/docs/reference/request/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/request/index.html rename to docs/reference/request/index.html diff --git a/docs/static/api-docs/slack_bolt/request/internals.html b/docs/reference/request/internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/request/internals.html rename to docs/reference/request/internals.html diff --git a/docs/static/api-docs/slack_bolt/request/payload_utils.html b/docs/reference/request/payload_utils.html similarity index 100% rename from docs/static/api-docs/slack_bolt/request/payload_utils.html rename to docs/reference/request/payload_utils.html diff --git a/docs/static/api-docs/slack_bolt/request/request.html b/docs/reference/request/request.html similarity index 100% rename from docs/static/api-docs/slack_bolt/request/request.html rename to docs/reference/request/request.html diff --git a/docs/static/api-docs/slack_bolt/response/index.html b/docs/reference/response/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/response/index.html rename to docs/reference/response/index.html diff --git a/docs/static/api-docs/slack_bolt/response/response.html b/docs/reference/response/response.html similarity index 100% rename from docs/static/api-docs/slack_bolt/response/response.html rename to docs/reference/response/response.html diff --git a/docs/static/api-docs/slack_bolt/util/async_utils.html b/docs/reference/util/async_utils.html similarity index 100% rename from docs/static/api-docs/slack_bolt/util/async_utils.html rename to docs/reference/util/async_utils.html diff --git a/docs/static/api-docs/slack_bolt/util/index.html b/docs/reference/util/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/util/index.html rename to docs/reference/util/index.html diff --git a/docs/static/api-docs/slack_bolt/util/utils.html b/docs/reference/util/utils.html similarity index 100% rename from docs/static/api-docs/slack_bolt/util/utils.html rename to docs/reference/util/utils.html diff --git a/docs/static/api-docs/slack_bolt/version.html b/docs/reference/version.html similarity index 100% rename from docs/static/api-docs/slack_bolt/version.html rename to docs/reference/version.html diff --git a/docs/static/api-docs/slack_bolt/workflows/index.html b/docs/reference/workflows/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/index.html rename to docs/reference/workflows/index.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/async_step.html b/docs/reference/workflows/step/async_step.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/async_step.html rename to docs/reference/workflows/step/async_step.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/async_step_middleware.html b/docs/reference/workflows/step/async_step_middleware.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/async_step_middleware.html rename to docs/reference/workflows/step/async_step_middleware.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/index.html b/docs/reference/workflows/step/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/index.html rename to docs/reference/workflows/step/index.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/internals.html b/docs/reference/workflows/step/internals.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/internals.html rename to docs/reference/workflows/step/internals.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/step.html b/docs/reference/workflows/step/step.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/step.html rename to docs/reference/workflows/step/step.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/step_middleware.html b/docs/reference/workflows/step/step_middleware.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/step_middleware.html rename to docs/reference/workflows/step/step_middleware.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_complete.html b/docs/reference/workflows/step/utilities/async_complete.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/utilities/async_complete.html rename to docs/reference/workflows/step/utilities/async_complete.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_configure.html b/docs/reference/workflows/step/utilities/async_configure.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/utilities/async_configure.html rename to docs/reference/workflows/step/utilities/async_configure.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_fail.html b/docs/reference/workflows/step/utilities/async_fail.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/utilities/async_fail.html rename to docs/reference/workflows/step/utilities/async_fail.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/async_update.html b/docs/reference/workflows/step/utilities/async_update.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/utilities/async_update.html rename to docs/reference/workflows/step/utilities/async_update.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/complete.html b/docs/reference/workflows/step/utilities/complete.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/utilities/complete.html rename to docs/reference/workflows/step/utilities/complete.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/configure.html b/docs/reference/workflows/step/utilities/configure.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/utilities/configure.html rename to docs/reference/workflows/step/utilities/configure.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/fail.html b/docs/reference/workflows/step/utilities/fail.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/utilities/fail.html rename to docs/reference/workflows/step/utilities/fail.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/index.html b/docs/reference/workflows/step/utilities/index.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/utilities/index.html rename to docs/reference/workflows/step/utilities/index.html diff --git a/docs/static/api-docs/slack_bolt/workflows/step/utilities/update.html b/docs/reference/workflows/step/utilities/update.html similarity index 100% rename from docs/static/api-docs/slack_bolt/workflows/step/utilities/update.html rename to docs/reference/workflows/step/utilities/update.html diff --git a/docs/sidebars.js b/docs/sidebars.js deleted file mode 100644 index decb8cccb..000000000 --- a/docs/sidebars.js +++ /dev/null @@ -1,127 +0,0 @@ -/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ -const sidebars = { - sidebarBoltPy: [ - { - type: 'doc', - id: 'index', - label: 'Bolt for Python', - className: 'sidebar-title', - }, - { - type: 'doc', - id: 'getting-started', - }, - { type: 'html', value: '


    ' }, - { - type: 'category', - label: 'Guides', - collapsed: false, - items: [ - "building-an-app", - { - type: "category", - label: "Slack API calls", - items: ["concepts/message-sending", "concepts/web-api"], - }, - { - type: "category", - label: "Events", - items: ["concepts/message-listening", "concepts/event-listening"], - }, - { - type: "category", - label: "App UI & Interactivity", - items: [ - "concepts/acknowledge", - "concepts/shortcuts", - "concepts/commands", - "concepts/actions", - "concepts/opening-modals", - "concepts/updating-pushing-views", - "concepts/view-submissions", - "concepts/select-menu-options", - "concepts/app-home", - ], - }, - "concepts/ai-apps", - { - type: 'category', - label: 'Custom Steps', - items: [ - 'concepts/custom-steps', - 'concepts/custom-steps-dynamic-options', - ] - }, - { - type: "category", - label: "App Configuration", - items: [ - "concepts/socket-mode", - "concepts/errors", - "concepts/logging", - "concepts/async", - ], - }, - { - type: "category", - label: "Middleware & Context", - items: [ - "concepts/global-middleware", - "concepts/listener-middleware", - "concepts/context", - ], - }, - "concepts/lazy-listeners", - { - type: "category", - label: "Adaptors", - items: ["concepts/adapters", "concepts/custom-adapters"], - }, - { - type: "category", - label: "Authorization & Security", - items: [ - "concepts/authenticating-oauth", - "concepts/authorization", - "concepts/token-rotation", - ], - }, - { - type: "category", - label: "Legacy", - items: ["concepts/steps-from-apps"], - }, - ], - }, - { type: "html", value: "
    " }, - { - type: "category", - label: "Tutorials", - items: ["tutorial/ai-chatbot", "tutorial/custom-steps", "tutorial/custom-steps-for-jira", "tutorial/custom-steps-workflow-builder-new", "tutorial/custom-steps-workflow-builder-existing", "tutorial/modals"], - }, - { type: "html", value: "
    " }, - { - type: "link", - label: "Reference", - href: "https://tools.slack.dev/bolt-python/api-docs/slack_bolt/", - }, - { type: "html", value: "
    " }, - { - type: "link", - label: "Release notes", - href: "https://github.com/slackapi/bolt-python/releases", - }, - { - type: "link", - label: "Code on GitHub", - href: "https://github.com/SlackAPI/bolt-python", - }, - { - type: "link", - label: "Contributors Guide", - href: "https://github.com/SlackAPI/bolt-python/blob/main/.github/contributing.md", - }, - ], -}; - -export default sidebars; diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css deleted file mode 100644 index 8a0fa6ca2..000000000 --- a/docs/src/css/custom.css +++ /dev/null @@ -1,583 +0,0 @@ -:root { - --ifm-font-size-base: 15px; - - /* set hex colors here pls */ - --dim: #eef2f6; - - --aubergine: #481a54; - --aubergine-background: #552555; - --aubergine-dark: #2c0134; - - --aubergine-active: #7c3085; - --aubergine-active-70: #7c308570; - --aubergine-active-50: #7c308550; - --aubergine-active-30: #7c308530; - - --horchata: #f4ede4; - - --slack-red: #e3066a; - --slack-red-70: #e3066a70; - --slack-red-50: #e3066a50; - --slack-red-30: #e3066a30; - --slack-red-20: #e3066a20; - - --slack-yellow: #fcc003; - --slack-yellow-70: #fcc00370; - --slack-yellow-50: #fcc00350; - --slack-yellow-30: #fcc00330; - --slack-yellow-20: #fcc00320; - - --slack-green: #41b658; - --slack-green-70: #41b65870; - --slack-green-50: #41b65850; - --slack-green-30: #41b65830; - --slack-green-20: #41b65820; - - --slack-blue: #1ab9ff; - --slack-blue-70: #1ab9ff70; - --slack-blue-50: #1ab9ff70; - --slack-blue-30: #1ab9ff30; - --slack-blue-20: #1ab9ff20; - - /* used for dark-mode links */ - --slack-cloud-blue: #1ab9ff; - /* slack marketing color used for light-mode links */ - --slack-dark-blue: #1264a3; - - /* used for functions */ - --unofficial-orange: #e36606; - --unofficial-orange-70: #e3660670; - --unofficial-orange-50: #e3660650; - --unofficial-orange-30: #e3660630; - - /* turns opacity into flat colors for bubbles on top of things */ - --slack-yellow-70-flat: #fcc00370; - - --slack-yellow-30-on-white: #feecb3; - --slack-green-30-on-white: #c6e9cc; - --slack-red-30-on-white: #f6b4d2; - --slack-blue-30-on-white: #baeaff; - --unofficial-orange-30-on-white: #f6d1b4; - --aubergine-active-30-on-white: #d7c0da; - - --ifm-h5-font-size: 1rem; - /* --ifm-heading-font-family: 'AvantGardeForSalesforce', sans-serif; */ - /* --ifm-font-family-base: 'Salesforce_Sans', sans-serif; */ - --ifm-navbar-height: 83px; - - -} - -.navbar__logo img { - height: 150%; - margin-top: -8px; -} - -.navbar--dark { - --ifm-navbar-background-color: #000 !important; - --ifm-navbar-link-hover-color: var(--slack-blue); -} - -.footer { - --ifm-footer-background-color: #000 !important; - --ifm-footer-link-hover-color: var(--slack-blue); - --ifm-footer-color: white !important; -} - -.theme-admonition div{ - text-transform: none !important; /* Disables uppercase transformation */ - -} - -/* resets striped tables that hurt me eyes */ -table tr:nth-child(even) { - background-color: inherit; -} - -h1 { - font-size: 2.5rem; -} - -/* Reduce title size in blog list */ -.blog-list-page h2[class*="title"] -{ - font-size: 2rem; -} - -/* Reduce title size in blog page */ -.blog-post-page h1[class*="title"] -{ - font-size: 2rem; -} - -/* changing the links to blue for accessibility */ -p a, -.markdown a { - color: var(--slack-cloud-blue); - text-decoration: none; -} - -p a, -.markdown a:hover { - text-decoration: underline; -} - -a:hover { - color: var(--slack-cloud-blue); -} - -.article h1 { - font-size: 1rem !important; /* Adjust the size as needed */ -} - -.card { - box-shadow: none; -} - -/* adjusting for light and dark modes */ -[data-theme="light"] { - --docusaurus-highlighted-code-line-bg: var(--dim); - --ifm-color-primary: var(--aubergine-active); - --ifm-navbar-background-color: black; - --ifm-footer-background-color: black; - --slack-cloud-blue: var(--slack-dark-blue); - --reference-section-color: var(--horchata); -} - -[data-theme="dark"] { - --docusaurus-highlighted-code-line-bg: rgb(0 0 0 / 30%); - --ifm-color-primary: var(--slack-cloud-blue); - --ifm-navbar-background-color: #000 !important; - --ifm-footer-background-color: #000 !important; - --ifm-footer-color: white; -} - -.alert--warning { - --ifm-alert-background-color: var(--slack-yellow-30); - --ifm-alert-border-color: var(--slack-yellow); - --ifm-alert-background-color-highlight: var(--slack-yellow-30); -} - -.alert--info { - --ifm-alert-background-color: var(--slack-blue-30); - --ifm-alert-border-color: var(--slack-blue); - /* --ifm-alert-background-color-highlight: var(--slack-blue-30); */ -} - -.alert--danger { - --ifm-alert-background-color: var(--slack-red-30); - --ifm-alert-border-color: var(--slack-red); -} - -.alert--success { - --ifm-alert-background-color: var(--slack-green-30); - --ifm-alert-border-color: var(--slack-green); -} - -.footer { - /* font-size: 80%; */ - padding-bottom: 0.5rem; -} - -.footer__items a { - color: inherit; -} - -.footer .container { - margin: 0; -} - -.table-of-contents__link { - font-size: .9rem; -} - -/* bolding ToC for contrast */ -.table-of-contents__link--active { - font-weight: bold; -} - -/* removing ToC line */ -.table-of-contents__left-border { - border-left: none !important; -} - - -.dropdown-hr { - margin: 0 -} - -/* increasing name of site in sidebar */ -.sidebar-title { - /* padding-bottom: 0.5rem; - font-size: 1.25em; */ - font-weight: bold; -} - -.theme-doc-sidebar-item-link hr { - margin: 1rem; -} - -.sidebar-sdk-title { - /* margin: 0.5rem 0; */ - padding: 0.5rem; - /* border-radius: 4px; */ - border-bottom: 0.5px solid grey; -} - -/* .theme-doc-sidebar-item-category-level-1 .menu__link { - font-weight: bold; -} */ - -.theme-doc-sidebar-item-category-level-1 .menu__list-item .menu__link { - font-weight: normal; -} - -/* removing sidebar line and adding space to match ToC */ -.theme-doc-sidebar-container { - border-right: none !important; - margin-right: 2rem; -} - -/* announcement bar up top */ -div[class^="announcementBar_"] { - font-size: 20px; - height: 50px; - background: var(--horchata); -} - -/* navbar github link */ -.navbar-github-link { - width: 32px; - height: 32px; - padding: 6px; - margin-right: 6px; - margin-left: 6px; - border-radius: 50%; - transition: background var(--ifm-transition-fast); -} - -.navbar-github-link:hover { - background: var(--ifm-color-gray-800); -} - -.navbar-github-link::before { - content: ""; - height: 100%; - display: block; - background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E") - no-repeat; -} - -/* Delineate tab blocks */ -.tabs-container { - border: 1px solid var(--ifm-color-primary); - border-radius: 4px; - padding: 0.5em; -} - -summary { - background-color: var(--ifm-background-color); - --docusaurus-details-decoration-color: var(--ifm-color-primary); -} - -details { - border: 1px solid var(--ifm-color-primary)!important; - background-color: var(--ifm-background-color)!important; - --docusaurus-details-decoration-color: var(--ifm-color-primary); -} - -details[open] { - border: 1px solid var(--ifm-color-primary); - background-color: var(--ifm-background-color); - --docusaurus-details-decoration-color: var(--ifm-color-primary); - -} - -/* Docs code bubbles */ -[data-theme="light"] { - --contrast-color: black; - --code-link-background: var(--slack-blue-30); - --code-link-text: rgb(21, 50, 59); - - --method-link-background: var(--slack-green-30-on-white); - --method-link-text: rgb(0, 41, 0); - - --scope-link-background: var(--slack-yellow-30-on-white); - --scope-link-text: rgb(63, 46, 0); - - --event-link-background:#fad4e5; - /* --event-link-text: rgb(63, 0, 24); */ - --event-link-text: rgb(0, 0, 0); - - --function-link-background: var(--unofficial-orange-30-on-white); - --function-link-text: rgb(75, 35, 0); - - --command-link-background: var(--aubergine-active-30-on-white); - --command-link-text: rgb(75, 0, 75); -} - -[data-theme="dark"] { - --contrast-color: white; - --code-link-text: white; - --method-link-text: white; - --scope-link-text: white; - --event-link-text: white; - --function-link-text: white; - --command-link-text: white; - - --code-link-background: var(--slack-blue-70); - --method-link-background: var(--slack-green-70); - --scope-link-background: var(--slack-yellow-70); - --event-link-background: var(--slack-red-70); - --command-link-background: var(--aubergine-active); - --function-link-background: var(--unofficial-orange-70); -} - -a code { - background-color: var(--code-link-background); - color: var(--code-link-text); -} - -a[href^="https://docs.slack.dev/reference/methods"] > code -{ - background-color: var(--method-link-background); - color: var(--method-link-text); -} - -a[href^="/reference/methods"] > code -{ - background-color: var(--method-link-background); - color: var(--method-link-text); -} - -a[href^="https://docs.slack.dev/reference/scopes"] > code -{ - background-color: var(--scope-link-background); - color: var(--scope-link-text); -} - -a[href^="/reference/scopes"] > code -{ - background-color: var(--scope-link-background); - color: var(--scope-link-text); -} - -a[href^="https://docs.slack.dev/reference/events"] > code -{ - background-color: var(--event-link-background); - color: var(--event-link-text); -} - -a[href^="/reference/events"] > code -{ - background-color: var(--event-link-background); - color: var(--event-link-text); -} - -a[href^="/deno-slack-sdk/reference/slack-functions/"] > code { - background-color: var(--function-link-background); - color: var(--function-link-text); -} - -a[href^="/deno-slack-sdk/reference/connector-functions/"] > code { - background-color: var(--function-link-background); - color: var(--function-link-text); -} - -a[href^="/slack-cli/reference/commands"] > code { - background-color: var(--command-link-background); - color: var(--command-link-text); -} - -.facts-section { - margin-top: 2rem; - background-color: var(--slack-green-20) !important; -} - - -.facts-section .tabs-container { - border: none; - border-radius: 0px; - padding: 0em; - --ifm-leading: 0rem - -} - -.facts-section .tabs__item { - padding: 0 0.5rem; - color: inherit; -} - -.facts-section .tabs__item--active { - border-bottom-color: inherit -} - -.errors-section { - background-color: var(--slack-red-20) !important; -} - - -.inputs-section { - background-color: var(--slack-blue-20) !important; -} - -.functions-section { - border-radius: 6px; - padding: 1rem; - margin-bottom: 2rem; -} - -.facts-row-list { - display: flex; - flex-wrap: wrap; - column-gap: 0.5rem; - row-gap: 0.5rem; - align-items: baseline; /* Aligns items to the same baseline */ -} - -.facts-row-list-item { - display: inline-block; -} - - -.inline-icon { - height: 1.9em; /* Matches the height of the text */ - width: auto; /* Maintains aspect ratio */ - vertical-align: middle; /* Aligns with the text */ -} - -.functions-section .type { - text-align: right; -} - -.param-required-section { - padding-top: 1rem; - margin-bottom: 1rem; -} - -.reference-container { - display: flex; - flex-direction: column; - width: 100%; - /* border: 1px solid #ddd; */ - border-radius: 8px; - overflow: hidden; -} -.reference-facts-header { - display: flex; - /* background: #f4f4f4; */ - padding: 10px 0; - font-weight: bold; -} -.reference-facts-item { - display: flex; - padding: 10px 0; - border-bottom: 1px solid var(--ifm-color-emphasis-200); -} -.reference-facts-item:last-child { - border-bottom: none; -} - -.reference-name { - flex: 2; - /* padding: 5px;*/ - min-width: 200px; -} - -.reference-description { - flex: 2; /* Makes description take extra space */ - padding: 5px; -} - -.reference-last-column { - flex: 1; - padding: 5px 0; -} - -.reference-subitems-bubble { - display: inline-block; - background: var(--ifm-color-emphasis-200); - color: var(--ifm-color-emphasis-1000); - padding: 2px 6px; - margin: 2px; - border-radius: 4px; - font-size: 12px; -} - -.param-container { - border-top: 1px solid lightgray; - padding-top: 1rem; - padding-bottom: 1rem; -} - -.param-container:last-child { - padding-bottom: 0; -} - -.param-top-row { - display: flex; - align-items: center; - margin-bottom: 1rem; -} - -/* left-align param name */ -.param-top-row .name { - flex: 1; -} - -/* right-align Required and Type */ -.param-top-row .required, -.param-top-row .type { - margin-left: auto; - text-align: right; -} - -/* add space between Required and Type */ -.param-top-row .required { - margin-left: 10px; -} - -.info-row { - display: flex; - /* align-items: center; */ - padding-top: 1rem; - padding-bottom: 1rem; - border-top: 1px solid var(--ifm-color-emphasis-400); -} - -.info-key { - flex: 0 0 10rem; - align-items: center; -} - -/* hides next and previous */ -.pagination-nav__link { - display: none; -} - -/* -html[data-theme="dark"] .button:hover { - background-color: var(--slack-blue-30-on-white); -} - -html[data-theme="light"] .button:hover { - background-color: var(--aubergine-active-30-on-white); -} */ - -.button { - background-color: var(--aubergine); /* Change color on hover */ - border: 0; - color: white; -} - -.button:hover { - background-color: var(--aubergine-active); - border: 0; - color: white; -} - -.footer-spaced { - display: flex; - gap: 20px; - padding-bottom: 1rem -} \ No newline at end of file diff --git a/docs/src/theme/NotFound/Content/index.js b/docs/src/theme/NotFound/Content/index.js deleted file mode 100644 index c122bc039..000000000 --- a/docs/src/theme/NotFound/Content/index.js +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import clsx from 'clsx'; -import Translate from '@docusaurus/Translate'; -import Heading from '@theme/Heading'; -export default function NotFoundContent({className}) { - return ( -
    -
    -
    - - - Oh no! There's nothing here. - - -

    - - If we've led you astray, please let us know. We'll do our best to get things in order. - - -

    -

    - - For now, we suggest heading back to the beginning to get your bearings. May your next journey have clear skies to guide you true. - -

    -
    -
    -
    - ); -} diff --git a/docs/src/theme/NotFound/index.js b/docs/src/theme/NotFound/index.js deleted file mode 100644 index 3b551f9e4..000000000 --- a/docs/src/theme/NotFound/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import {translate} from '@docusaurus/Translate'; -import {PageMetadata} from '@docusaurus/theme-common'; -import Layout from '@theme/Layout'; -import NotFoundContent from '@theme/NotFound/Content'; -export default function Index() { - const title = translate({ - id: 'theme.NotFound.title', - message: 'Page Not Found', - }); - return ( - <> - - - - - - ); -} diff --git a/docs/static/.nojekyll b/docs/static/.nojekyll deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/static/img/bolt-logo.svg b/docs/static/img/bolt-logo.svg deleted file mode 100644 index 5077600d5..000000000 --- a/docs/static/img/bolt-logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/static/img/bolt-py-logo.svg b/docs/static/img/bolt-py-logo.svg deleted file mode 100644 index 1dcab5261..000000000 --- a/docs/static/img/bolt-py-logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/static/img/boltpy/bolt-favicon.png b/docs/static/img/boltpy/bolt-favicon.png deleted file mode 100644 index bfe5456c172c5e76fa9b13b1e8ab0a793b6e95cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3376 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 diff --git a/docs/static/img/boltpy/ngrok.gif b/docs/static/img/boltpy/ngrok.gif deleted file mode 100644 index c7c94d51a303aedcbee5302988b5b6629940e8b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49094 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 diff --git a/docs/static/img/boltpy/signing-secret.png b/docs/static/img/boltpy/signing-secret.png deleted file mode 100644 index d32afa03e670f36d023355d91c76f0f475a690f8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 289939 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Μ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

    =$cTRzP6g9AUM*{+>;Kt{TOp-U+J|Vgil81LN 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 z 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

    Bolt Bolt logo for Python

    +

    Bolt Bolt logo for Python

    From c0ea30f8699ad3fe87f0b199c94dadb46420bba7 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 22 Aug 2025 16:11:49 -0400 Subject: [PATCH 785/865] feat: surface ack_timeout on the handler (#1351) --- slack_bolt/app/app.py | 14 +++++---- slack_bolt/app/async_app.py | 13 +++++---- slack_bolt/listener/async_listener.py | 8 +++--- slack_bolt/listener/asyncio_runner.py | 2 +- slack_bolt/listener/custom_listener.py | 6 ++-- slack_bolt/listener/listener.py | 2 +- slack_bolt/listener/thread_runner.py | 2 +- slack_bolt/logger/messages.py | 6 ++++ tests/scenario_tests/test_function.py | 31 ++++++++++++++++++-- tests/scenario_tests_async/test_function.py | 32 +++++++++++++++++++-- 10 files changed, 92 insertions(+), 24 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 86909ed18..60f20ea9e 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -59,6 +59,7 @@ info_default_oauth_settings_loaded, error_installation_store_required_for_builtin_listeners, warning_unhandled_by_global_middleware, + warning_ack_timeout_has_no_effect, ) from slack_bolt.middleware import ( Middleware, @@ -912,6 +913,7 @@ def function( matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, auto_acknowledge: bool = True, + ack_timeout: int = 3, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. @@ -940,15 +942,17 @@ def reverse_string(ack: Ack, inputs: dict, complete: Complete, fail: Fail): Only when all the middleware call `next()` method, the listener function can be invoked. """ + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) + matchers = list(matchers) if matchers else [] middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger) - return self._register_listener( - functions, primary_matcher, matchers, middleware, auto_acknowledge, acknowledgement_timeout=5 - ) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__ @@ -1424,7 +1428,7 @@ def _register_listener( matchers: Optional[Sequence[Callable[..., bool]]], middleware: Optional[Sequence[Union[Callable, Middleware]]], auto_acknowledgement: bool = False, - acknowledgement_timeout: int = 3, + ack_timeout: int = 3, ) -> Optional[Callable[..., Optional[BoltResponse]]]: value_to_return = None if not isinstance(functions, list): @@ -1455,7 +1459,7 @@ def _register_listener( matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, - acknowledgement_timeout=acknowledgement_timeout, + ack_timeout=ack_timeout, base_logger=self._base_logger, ) ) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 294fb8b0c..906359fcc 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -68,6 +68,7 @@ info_default_oauth_settings_loaded, error_installation_store_required_for_builtin_listeners, warning_unhandled_by_global_middleware, + warning_ack_timeout_has_no_effect, ) from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -940,6 +941,7 @@ def function( matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, auto_acknowledge: bool = True, + ack_timeout: int = 3, ) -> Callable[..., Optional[Callable[..., Awaitable[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. @@ -967,6 +969,9 @@ async def reverse_string(ack: AsyncAck, inputs: dict, complete: AsyncComplete, f middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) matchers = list(matchers) if matchers else [] middleware = list(middleware) if middleware else [] @@ -976,9 +981,7 @@ def __call__(*args, **kwargs): primary_matcher = builtin_matchers.function_executed( callback_id=callback_id, base_logger=self._base_logger, asyncio=True ) - return self._register_listener( - functions, primary_matcher, matchers, middleware, auto_acknowledge, acknowledgement_timeout=5 - ) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__ @@ -1458,7 +1461,7 @@ def _register_listener( matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]], middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]], auto_acknowledgement: bool = False, - acknowledgement_timeout: int = 3, + ack_timeout: int = 3, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: value_to_return = None if not isinstance(functions, list): @@ -1494,7 +1497,7 @@ def _register_listener( matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, - acknowledgement_timeout=acknowledgement_timeout, + ack_timeout=ack_timeout, base_logger=self._base_logger, ) ) diff --git a/slack_bolt/listener/async_listener.py b/slack_bolt/listener/async_listener.py index ca069b097..0810b91a7 100644 --- a/slack_bolt/listener/async_listener.py +++ b/slack_bolt/listener/async_listener.py @@ -15,7 +15,7 @@ class AsyncListener(metaclass=ABCMeta): ack_function: Callable[..., Awaitable[BoltResponse]] lazy_functions: Sequence[Callable[..., Awaitable[None]]] auto_acknowledgement: bool - acknowledgement_timeout: int + ack_timeout: int async def async_matches( self, @@ -88,7 +88,7 @@ class AsyncCustomListener(AsyncListener): matchers: Sequence[AsyncListenerMatcher] middleware: Sequence[AsyncMiddleware] auto_acknowledgement: bool - acknowledgement_timeout: int + ack_timeout: int arg_names: MutableSequence[str] logger: Logger @@ -101,7 +101,7 @@ def __init__( matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False, - acknowledgement_timeout: int = 3, + ack_timeout: int = 3, base_logger: Optional[Logger] = None, ): self.app_name = app_name @@ -110,7 +110,7 @@ def __init__( self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement - self.acknowledgement_timeout = acknowledgement_timeout + self.ack_timeout = ack_timeout self.arg_names = get_arg_names_of_callable(ack_function) self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) diff --git a/slack_bolt/listener/asyncio_runner.py b/slack_bolt/listener/asyncio_runner.py index 98d3bf4f8..81f9e6106 100644 --- a/slack_bolt/listener/asyncio_runner.py +++ b/slack_bolt/listener/asyncio_runner.py @@ -149,7 +149,7 @@ async def run_ack_function_asynchronously( self._start_lazy_function(lazy_func, request) # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= listener.acknowledgement_timeout: + while ack.response is None and time.time() - starting_time <= listener.ack_timeout: await asyncio.sleep(0.01) if response is None and ack.response is None: diff --git a/slack_bolt/listener/custom_listener.py b/slack_bolt/listener/custom_listener.py index e2977effa..2b73018db 100644 --- a/slack_bolt/listener/custom_listener.py +++ b/slack_bolt/listener/custom_listener.py @@ -18,7 +18,7 @@ class CustomListener(Listener): matchers: Sequence[ListenerMatcher] middleware: Sequence[Middleware] auto_acknowledgement: bool - acknowledgement_timeout: int = 3 + ack_timeout: int = 3 arg_names: MutableSequence[str] logger: Logger @@ -31,7 +31,7 @@ def __init__( matchers: Sequence[ListenerMatcher], middleware: Sequence[Middleware], auto_acknowledgement: bool = False, - acknowledgement_timeout: int = 3, + ack_timeout: int = 3, base_logger: Optional[Logger] = None, ): self.app_name = app_name @@ -40,7 +40,7 @@ def __init__( self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement - self.acknowledgement_timeout = acknowledgement_timeout + self.ack_timeout = ack_timeout self.arg_names = get_arg_names_of_callable(ack_function) self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) diff --git a/slack_bolt/listener/listener.py b/slack_bolt/listener/listener.py index 51dadae56..7685f3c7b 100644 --- a/slack_bolt/listener/listener.py +++ b/slack_bolt/listener/listener.py @@ -13,7 +13,7 @@ class Listener(metaclass=ABCMeta): ack_function: Callable[..., BoltResponse] lazy_functions: Sequence[Callable[..., None]] auto_acknowledgement: bool - acknowledgement_timeout: int = 3 + ack_timeout: int = 3 def matches( self, diff --git a/slack_bolt/listener/thread_runner.py b/slack_bolt/listener/thread_runner.py index 61e8d6129..378ca1bfa 100644 --- a/slack_bolt/listener/thread_runner.py +++ b/slack_bolt/listener/thread_runner.py @@ -160,7 +160,7 @@ def run_ack_function_asynchronously(): self._start_lazy_function(lazy_func, request) # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= listener.acknowledgement_timeout: + while ack.response is None and time.time() - starting_time <= listener.ack_timeout: time.sleep(0.01) if response is None and ack.response is None: diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 3ec1fef8a..cffdc445f 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -1,3 +1,4 @@ +from re import Pattern import time from typing import Union, Dict, Any, Optional @@ -331,6 +332,11 @@ def warning_skip_uncommon_arg_name(arg_name: str) -> str: ) +def warning_ack_timeout_has_no_effect(identifier: Union[str, Pattern], ack_timeout: int) -> str: + handler_example = f'@app.function("{identifier}")' if isinstance(identifier, str) else f"@app.function({identifier})" + return f"On {handler_example}, as `auto_acknowledge` is `True`, " f"`ack_timeout={ack_timeout}` you gave will be unused" + + # ------------------------------- # Info # ------------------------------- diff --git a/tests/scenario_tests/test_function.py b/tests/scenario_tests/test_function.py index 41290de8f..0a2152892 100644 --- a/tests/scenario_tests/test_function.py +++ b/tests/scenario_tests/test_function.py @@ -1,4 +1,5 @@ import json +import re import time import pytest from unittest.mock import Mock @@ -149,11 +150,12 @@ def test_auto_acknowledge_false_without_acknowledging(self, caplog, monkeypatch) assert f"WARNING {just_no_ack.__name__} didn't call ack()" in caplog.text def test_function_handler_timeout(self, monkeypatch): + timeout = 5 app = App( client=self.web_client, signing_secret=self.signing_secret, ) - app.function("reverse", auto_acknowledge=False)(just_no_ack) + app.function("reverse", auto_acknowledge=False, ack_timeout=timeout)(just_no_ack) request = self.build_request_from_body(function_body) sleep_mock = Mock() @@ -168,9 +170,34 @@ def test_function_handler_timeout(self, monkeypatch): assert response.status == 404 assert_auth_test_count(self, 1) assert ( - sleep_mock.call_count == 5 + sleep_mock.call_count == timeout ), f"Expected handler to time out after calling time.sleep 5 times, but it was called {sleep_mock.call_count} times" + def test_warning_when_timeout_improperly_set(self, caplog): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(just_no_ack) + assert "WARNING" not in caplog.text + + timeout_argument_name = "ack_timeout" + kwargs = {timeout_argument_name: 5} + + callback_id = "reverse1" + app.function(callback_id, **kwargs)(just_no_ack) + assert ( + f'WARNING On @app.function("{callback_id}"), as `auto_acknowledge` is `True`, `{timeout_argument_name}={kwargs[timeout_argument_name]}` you gave will be unused' + in caplog.text + ) + + callback_id = re.compile(r"hello \w+") + app.function(callback_id, **kwargs)(just_no_ack) + assert ( + f"WARNING On @app.function({callback_id}), as `auto_acknowledge` is `True`, `{timeout_argument_name}={kwargs[timeout_argument_name]}` you gave will be unused" + in caplog.text + ) + function_body = { "token": "verification_token", diff --git a/tests/scenario_tests_async/test_function.py b/tests/scenario_tests_async/test_function.py index fc1299e55..3f8b7a722 100644 --- a/tests/scenario_tests_async/test_function.py +++ b/tests/scenario_tests_async/test_function.py @@ -1,5 +1,6 @@ import asyncio import json +import re import time import pytest @@ -160,11 +161,12 @@ async def test_auto_acknowledge_false_without_acknowledging(self, caplog, monkey @pytest.mark.asyncio async def test_function_handler_timeout(self, monkeypatch): + timeout = 5 app = AsyncApp( client=self.web_client, signing_secret=self.signing_secret, ) - app.function("reverse", auto_acknowledge=False)(just_no_ack) + app.function("reverse", auto_acknowledge=False, ack_timeout=timeout)(just_no_ack) request = self.build_request_from_body(function_body) sleep_mock = MagicMock(side_effect=fake_sleep) @@ -179,9 +181,35 @@ async def test_function_handler_timeout(self, monkeypatch): assert response.status == 404 await assert_auth_test_count_async(self, 1) assert ( - sleep_mock.call_count == 5 + sleep_mock.call_count == timeout ), f"Expected handler to time out after calling time.sleep 5 times, but it was called {sleep_mock.call_count} times" + @pytest.mark.asyncio + async def test_warning_when_timeout_improperly_set(self, caplog): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(just_no_ack) + assert "WARNING" not in caplog.text + + timeout_argument_name = "ack_timeout" + kwargs = {timeout_argument_name: 5} + + callback_id = "reverse1" + app.function(callback_id, **kwargs)(just_no_ack) + assert ( + f'WARNING On @app.function("{callback_id}"), as `auto_acknowledge` is `True`, `{timeout_argument_name}={kwargs[timeout_argument_name]}` you gave will be unused' + in caplog.text + ) + + callback_id = re.compile(r"hello \w+") + app.function(callback_id, **kwargs)(just_no_ack) + assert ( + f"WARNING On @app.function({callback_id}), as `auto_acknowledge` is `True`, `{timeout_argument_name}={kwargs[timeout_argument_name]}` you gave will be unused" + in caplog.text + ) + function_body = { "token": "verification_token", From 67b873ded975b3ec35f07f954f0cbc1a3dceba43 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 27 Aug 2025 14:49:47 -0400 Subject: [PATCH 786/865] version 1.24.0 (#1354) --- .github/maintainers_guide.md | 2 + docs/reference/adapter/aiohttp/index.html | 4 +- .../reference/adapter/asgi/aiohttp/index.html | 4 +- .../reference/adapter/asgi/async_handler.html | 4 +- docs/reference/adapter/asgi/base_handler.html | 4 +- .../reference/adapter/asgi/builtin/index.html | 4 +- docs/reference/adapter/asgi/http_request.html | 4 +- .../reference/adapter/asgi/http_response.html | 4 +- docs/reference/adapter/asgi/index.html | 4 +- docs/reference/adapter/asgi/utils.html | 4 +- .../adapter/aws_lambda/chalice_handler.html | 4 +- .../chalice_lazy_listener_runner.html | 4 +- .../reference/adapter/aws_lambda/handler.html | 4 +- docs/reference/adapter/aws_lambda/index.html | 4 +- .../adapter/aws_lambda/internals.html | 4 +- .../aws_lambda/lambda_s3_oauth_flow.html | 4 +- .../aws_lambda/lazy_listener_runner.html | 4 +- .../aws_lambda/local_lambda_client.html | 4 +- docs/reference/adapter/bottle/handler.html | 4 +- docs/reference/adapter/bottle/index.html | 4 +- docs/reference/adapter/cherrypy/handler.html | 4 +- docs/reference/adapter/cherrypy/index.html | 4 +- docs/reference/adapter/django/handler.html | 4 +- docs/reference/adapter/django/index.html | 4 +- .../adapter/falcon/async_resource.html | 8 +- docs/reference/adapter/falcon/index.html | 10 +- docs/reference/adapter/falcon/resource.html | 10 +- .../adapter/fastapi/async_handler.html | 4 +- docs/reference/adapter/fastapi/index.html | 4 +- docs/reference/adapter/flask/handler.html | 4 +- docs/reference/adapter/flask/index.html | 4 +- .../google_cloud_functions/handler.html | 4 +- .../adapter/google_cloud_functions/index.html | 4 +- docs/reference/adapter/index.html | 4 +- docs/reference/adapter/pyramid/handler.html | 4 +- docs/reference/adapter/pyramid/index.html | 4 +- .../adapter/sanic/async_handler.html | 4 +- docs/reference/adapter/sanic/index.html | 4 +- .../adapter/socket_mode/aiohttp/index.html | 4 +- .../socket_mode/async_base_handler.html | 4 +- .../adapter/socket_mode/async_handler.html | 4 +- .../adapter/socket_mode/async_internals.html | 4 +- .../adapter/socket_mode/base_handler.html | 4 +- .../adapter/socket_mode/builtin/index.html | 4 +- docs/reference/adapter/socket_mode/index.html | 4 +- .../adapter/socket_mode/internals.html | 4 +- .../socket_mode/websocket_client/index.html | 4 +- .../adapter/socket_mode/websockets/index.html | 4 +- .../adapter/starlette/async_handler.html | 4 +- docs/reference/adapter/starlette/handler.html | 4 +- docs/reference/adapter/starlette/index.html | 4 +- .../adapter/tornado/async_handler.html | 4 +- docs/reference/adapter/tornado/handler.html | 4 +- docs/reference/adapter/tornado/index.html | 4 +- docs/reference/adapter/wsgi/handler.html | 4 +- docs/reference/adapter/wsgi/http_request.html | 4 +- .../reference/adapter/wsgi/http_response.html | 4 +- docs/reference/adapter/wsgi/index.html | 4 +- docs/reference/adapter/wsgi/internals.html | 4 +- docs/reference/app/app.html | 139 +++++++++-------- docs/reference/app/async_app.html | 137 +++++++++-------- docs/reference/app/async_server.html | 4 +- docs/reference/app/index.html | 139 +++++++++-------- docs/reference/async_app.html | 143 +++++++++-------- .../authorization/async_authorize.html | 4 +- .../authorization/async_authorize_args.html | 4 +- docs/reference/authorization/authorize.html | 4 +- .../authorization/authorize_args.html | 4 +- .../authorization/authorize_result.html | 4 +- docs/reference/authorization/index.html | 4 +- docs/reference/context/ack/ack.html | 4 +- docs/reference/context/ack/async_ack.html | 4 +- docs/reference/context/ack/index.html | 4 +- docs/reference/context/ack/internals.html | 4 +- .../assistant/assistant_utilities.html | 4 +- .../assistant/async_assistant_utilities.html | 4 +- docs/reference/context/assistant/index.html | 4 +- .../context/assistant/internals.html | 4 +- .../assistant/thread_context/index.html | 4 +- .../thread_context_store/async_store.html | 4 +- .../default_async_store.html | 4 +- .../thread_context_store/default_store.html | 4 +- .../thread_context_store/file/index.html | 4 +- .../assistant/thread_context_store/index.html | 4 +- .../assistant/thread_context_store/store.html | 4 +- docs/reference/context/async_context.html | 4 +- docs/reference/context/base_context.html | 4 +- .../context/complete/async_complete.html | 4 +- docs/reference/context/complete/complete.html | 4 +- docs/reference/context/complete/index.html | 4 +- docs/reference/context/context.html | 4 +- docs/reference/context/fail/async_fail.html | 4 +- docs/reference/context/fail/fail.html | 4 +- docs/reference/context/fail/index.html | 4 +- .../async_get_thread_context.html | 4 +- .../get_thread_context.html | 4 +- .../context/get_thread_context/index.html | 4 +- docs/reference/context/index.html | 4 +- .../context/respond/async_respond.html | 4 +- docs/reference/context/respond/index.html | 4 +- docs/reference/context/respond/internals.html | 4 +- docs/reference/context/respond/respond.html | 4 +- .../async_save_thread_context.html | 4 +- .../context/save_thread_context/index.html | 4 +- .../save_thread_context.html | 4 +- docs/reference/context/say/async_say.html | 4 +- docs/reference/context/say/index.html | 4 +- docs/reference/context/say/internals.html | 4 +- docs/reference/context/say/say.html | 4 +- .../context/set_status/async_set_status.html | 4 +- docs/reference/context/set_status/index.html | 4 +- .../context/set_status/set_status.html | 4 +- .../async_set_suggested_prompts.html | 4 +- .../context/set_suggested_prompts/index.html | 4 +- .../set_suggested_prompts.html | 4 +- .../context/set_title/async_set_title.html | 4 +- docs/reference/context/set_title/index.html | 4 +- .../context/set_title/set_title.html | 4 +- docs/reference/error/index.html | 4 +- docs/reference/index.html | 145 ++++++++++-------- docs/reference/kwargs_injection/args.html | 4 +- .../kwargs_injection/async_args.html | 4 +- .../kwargs_injection/async_utils.html | 4 +- docs/reference/kwargs_injection/index.html | 4 +- docs/reference/kwargs_injection/utils.html | 4 +- .../lazy_listener/async_internals.html | 4 +- .../reference/lazy_listener/async_runner.html | 4 +- .../lazy_listener/asyncio_runner.html | 4 +- docs/reference/lazy_listener/index.html | 4 +- docs/reference/lazy_listener/internals.html | 4 +- docs/reference/lazy_listener/runner.html | 4 +- .../lazy_listener/thread_runner.html | 4 +- docs/reference/listener/async_builtins.html | 4 +- docs/reference/listener/async_listener.html | 26 +++- .../async_listener_completion_handler.html | 4 +- .../async_listener_error_handler.html | 4 +- .../async_listener_start_handler.html | 4 +- docs/reference/listener/asyncio_runner.html | 8 +- docs/reference/listener/builtins.html | 4 +- docs/reference/listener/custom_listener.html | 10 +- docs/reference/listener/index.html | 16 +- docs/reference/listener/listener.html | 10 +- .../listener/listener_completion_handler.html | 4 +- .../listener/listener_error_handler.html | 4 +- .../listener/listener_start_handler.html | 4 +- docs/reference/listener/thread_runner.html | 12 +- .../listener_matcher/async_builtins.html | 4 +- .../async_listener_matcher.html | 4 +- docs/reference/listener_matcher/builtins.html | 6 +- .../custom_listener_matcher.html | 4 +- docs/reference/listener_matcher/index.html | 4 +- .../listener_matcher/listener_matcher.html | 4 +- docs/reference/logger/index.html | 4 +- docs/reference/logger/messages.html | 19 ++- .../middleware/assistant/assistant.html | 4 +- .../middleware/assistant/async_assistant.html | 4 +- .../reference/middleware/assistant/index.html | 4 +- docs/reference/middleware/async_builtins.html | 16 +- .../middleware/async_custom_middleware.html | 4 +- .../middleware/async_middleware.html | 4 +- .../async_middleware_error_handler.html | 4 +- .../async_attaching_function_token.html | 4 +- .../attaching_function_token.html | 4 +- .../attaching_function_token/index.html | 4 +- .../authorization/async_authorization.html | 4 +- .../authorization/async_internals.html | 4 +- .../async_multi_teams_authorization.html | 4 +- .../async_single_team_authorization.html | 4 +- .../authorization/authorization.html | 4 +- .../middleware/authorization/index.html | 4 +- .../middleware/authorization/internals.html | 4 +- .../multi_teams_authorization.html | 4 +- .../single_team_authorization.html | 4 +- .../middleware/custom_middleware.html | 4 +- .../async_ignoring_self_events.html | 4 +- .../ignoring_self_events.html | 4 +- .../ignoring_self_events/index.html | 4 +- docs/reference/middleware/index.html | 20 +-- .../async_message_listener_matches.html | 4 +- .../message_listener_matches/index.html | 4 +- .../message_listener_matches.html | 4 +- docs/reference/middleware/middleware.html | 4 +- .../middleware/middleware_error_handler.html | 4 +- .../async_request_verification.html | 10 +- .../request_verification/index.html | 8 +- .../request_verification.html | 8 +- .../middleware/ssl_check/async_ssl_check.html | 8 +- .../reference/middleware/ssl_check/index.html | 12 +- .../middleware/ssl_check/ssl_check.html | 12 +- .../async_url_verification.html | 6 +- .../middleware/url_verification/index.html | 8 +- .../url_verification/url_verification.html | 8 +- .../oauth/async_callback_options.html | 4 +- docs/reference/oauth/async_internals.html | 4 +- docs/reference/oauth/async_oauth_flow.html | 4 +- .../reference/oauth/async_oauth_settings.html | 4 +- docs/reference/oauth/callback_options.html | 4 +- docs/reference/oauth/index.html | 4 +- docs/reference/oauth/internals.html | 4 +- docs/reference/oauth/oauth_flow.html | 4 +- docs/reference/oauth/oauth_settings.html | 4 +- docs/reference/request/async_internals.html | 4 +- docs/reference/request/async_request.html | 4 +- docs/reference/request/index.html | 6 +- docs/reference/request/internals.html | 4 +- docs/reference/request/payload_utils.html | 4 +- docs/reference/request/request.html | 4 +- docs/reference/response/index.html | 6 +- docs/reference/response/response.html | 4 +- docs/reference/util/async_utils.html | 4 +- docs/reference/util/index.html | 4 +- docs/reference/util/utils.html | 4 +- docs/reference/version.html | 4 +- docs/reference/workflows/index.html | 6 +- docs/reference/workflows/step/async_step.html | 44 +++--- .../workflows/step/async_step_middleware.html | 4 +- docs/reference/workflows/step/index.html | 28 ++-- docs/reference/workflows/step/internals.html | 4 +- docs/reference/workflows/step/step.html | 44 +++--- .../workflows/step/step_middleware.html | 4 +- .../step/utilities/async_complete.html | 8 +- .../step/utilities/async_configure.html | 8 +- .../workflows/step/utilities/async_fail.html | 8 +- .../step/utilities/async_update.html | 8 +- .../workflows/step/utilities/complete.html | 8 +- .../workflows/step/utilities/configure.html | 8 +- .../workflows/step/utilities/fail.html | 8 +- .../workflows/step/utilities/index.html | 4 +- .../workflows/step/utilities/update.html | 8 +- scripts/generate_api_docs.sh | 6 +- slack_bolt/version.py | 2 +- 231 files changed, 1026 insertions(+), 884 deletions(-) diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index 85b4e13be..69026d602 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -157,10 +157,12 @@ password: {your password} - Commit with a message including the new version number. For example `1.2.3` & Push the commit to a branch and create a PR to sanity check. - `git checkout -b v1.2.3` - `git commit -a -m 'version 1.2.3'` + - `git push -u origin HEAD` - Open a PR and merge after receiving at least one approval from other maintainers. 2. Distribute the release - Use the latest stable Python runtime + - `git checkout main && git pull` - `python --version` - `python -m venv .venv` - `./scripts/deploy_to_pypi_org.sh` diff --git a/docs/reference/adapter/aiohttp/index.html b/docs/reference/adapter/aiohttp/index.html index 879b7a023..7d7ceedbe 100644 --- a/docs/reference/adapter/aiohttp/index.html +++ b/docs/reference/adapter/aiohttp/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aiohttp API documentation @@ -122,7 +122,7 @@

    Functions

    diff --git a/docs/reference/adapter/asgi/aiohttp/index.html b/docs/reference/adapter/asgi/aiohttp/index.html index d598dc6cb..a6aa7c92d 100644 --- a/docs/reference/adapter/asgi/aiohttp/index.html +++ b/docs/reference/adapter/asgi/aiohttp/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.asgi.aiohttp API documentation @@ -158,7 +158,7 @@

    diff --git a/docs/reference/adapter/asgi/async_handler.html b/docs/reference/adapter/asgi/async_handler.html index 9ecdb6fd3..23433ffce 100644 --- a/docs/reference/adapter/asgi/async_handler.html +++ b/docs/reference/adapter/asgi/async_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.asgi.async_handler API documentation @@ -158,7 +158,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/asgi/base_handler.html b/docs/reference/adapter/asgi/base_handler.html index 2e194b12b..b8a6da68f 100644 --- a/docs/reference/adapter/asgi/base_handler.html +++ b/docs/reference/adapter/asgi/base_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.asgi.base_handler API documentation @@ -204,7 +204,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/asgi/builtin/index.html b/docs/reference/adapter/asgi/builtin/index.html index 4a0d0c777..9147380c5 100644 --- a/docs/reference/adapter/asgi/builtin/index.html +++ b/docs/reference/adapter/asgi/builtin/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.asgi.builtin API documentation @@ -159,7 +159,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/asgi/http_request.html b/docs/reference/adapter/asgi/http_request.html index 38ab574da..062ac7ca2 100644 --- a/docs/reference/adapter/asgi/http_request.html +++ b/docs/reference/adapter/asgi/http_request.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.asgi.http_request API documentation @@ -250,7 +250,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/asgi/http_response.html b/docs/reference/adapter/asgi/http_response.html index 93c929224..86e368f6e 100644 --- a/docs/reference/adapter/asgi/http_response.html +++ b/docs/reference/adapter/asgi/http_response.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.asgi.http_response API documentation @@ -252,7 +252,7 @@

    diff --git a/docs/reference/adapter/asgi/index.html b/docs/reference/adapter/asgi/index.html index 295ead704..0f2abec74 100644 --- a/docs/reference/adapter/asgi/index.html +++ b/docs/reference/adapter/asgi/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.asgi API documentation @@ -201,7 +201,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/asgi/utils.html b/docs/reference/adapter/asgi/utils.html index 031deda6c..8eb2a24f1 100644 --- a/docs/reference/adapter/asgi/utils.html +++ b/docs/reference/adapter/asgi/utils.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.asgi.utils API documentation @@ -60,7 +60,7 @@

    Module slack_bolt.adapter.asgi.utils

    diff --git a/docs/reference/adapter/aws_lambda/chalice_handler.html b/docs/reference/adapter/aws_lambda/chalice_handler.html index e8bd162ec..28c75ea6a 100644 --- a/docs/reference/adapter/aws_lambda/chalice_handler.html +++ b/docs/reference/adapter/aws_lambda/chalice_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.chalice_handler API documentation @@ -278,7 +278,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/aws_lambda/chalice_lazy_listener_runner.html b/docs/reference/adapter/aws_lambda/chalice_lazy_listener_runner.html index 8bd6428c1..f27e09c93 100644 --- a/docs/reference/adapter/aws_lambda/chalice_lazy_listener_runner.html +++ b/docs/reference/adapter/aws_lambda/chalice_lazy_listener_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner API documentation @@ -124,7 +124,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/aws_lambda/handler.html b/docs/reference/adapter/aws_lambda/handler.html index 48fd279c2..08e4ac9b7 100644 --- a/docs/reference/adapter/aws_lambda/handler.html +++ b/docs/reference/adapter/aws_lambda/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.handler API documentation @@ -281,7 +281,7 @@

    diff --git a/docs/reference/adapter/aws_lambda/index.html b/docs/reference/adapter/aws_lambda/index.html index 023c9b6eb..0aae2c31a 100644 --- a/docs/reference/adapter/aws_lambda/index.html +++ b/docs/reference/adapter/aws_lambda/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda API documentation @@ -249,7 +249,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/aws_lambda/internals.html b/docs/reference/adapter/aws_lambda/internals.html index 94fa2be46..bbbe281b0 100644 --- a/docs/reference/adapter/aws_lambda/internals.html +++ b/docs/reference/adapter/aws_lambda/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.internals API documentation @@ -60,7 +60,7 @@

    Module slack_bolt.adapter.aws_lambda.internals diff --git a/docs/reference/adapter/aws_lambda/lambda_s3_oauth_flow.html b/docs/reference/adapter/aws_lambda/lambda_s3_oauth_flow.html index 067ad846d..11845c902 100644 --- a/docs/reference/adapter/aws_lambda/lambda_s3_oauth_flow.html +++ b/docs/reference/adapter/aws_lambda/lambda_s3_oauth_flow.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow API documentation @@ -204,7 +204,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/aws_lambda/lazy_listener_runner.html b/docs/reference/adapter/aws_lambda/lazy_listener_runner.html index 4fbebcd4e..df53f5f22 100644 --- a/docs/reference/adapter/aws_lambda/lazy_listener_runner.html +++ b/docs/reference/adapter/aws_lambda/lazy_listener_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.lazy_listener_runner API documentation @@ -116,7 +116,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/aws_lambda/local_lambda_client.html b/docs/reference/adapter/aws_lambda/local_lambda_client.html index 1441fb2b7..45ee0510b 100644 --- a/docs/reference/adapter/aws_lambda/local_lambda_client.html +++ b/docs/reference/adapter/aws_lambda/local_lambda_client.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.aws_lambda.local_lambda_client API documentation @@ -134,7 +134,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/bottle/handler.html b/docs/reference/adapter/bottle/handler.html index e14b2da44..fe6f8ae1a 100644 --- a/docs/reference/adapter/bottle/handler.html +++ b/docs/reference/adapter/bottle/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.bottle.handler API documentation @@ -186,7 +186,7 @@

    diff --git a/docs/reference/adapter/bottle/index.html b/docs/reference/adapter/bottle/index.html index 0ceecb7f7..f240d52bc 100644 --- a/docs/reference/adapter/bottle/index.html +++ b/docs/reference/adapter/bottle/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.bottle API documentation @@ -153,7 +153,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/cherrypy/handler.html b/docs/reference/adapter/cherrypy/handler.html index 735786296..d41f00148 100644 --- a/docs/reference/adapter/cherrypy/handler.html +++ b/docs/reference/adapter/cherrypy/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.cherrypy.handler API documentation @@ -228,7 +228,7 @@

    diff --git a/docs/reference/adapter/cherrypy/index.html b/docs/reference/adapter/cherrypy/index.html index a1c121e05..5a322fd7a 100644 --- a/docs/reference/adapter/cherrypy/index.html +++ b/docs/reference/adapter/cherrypy/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.cherrypy API documentation @@ -157,7 +157,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/django/handler.html b/docs/reference/adapter/django/handler.html index a2de8c99d..4fe9e359a 100644 --- a/docs/reference/adapter/django/handler.html +++ b/docs/reference/adapter/django/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.django.handler API documentation @@ -382,7 +382,7 @@

    diff --git a/docs/reference/adapter/django/index.html b/docs/reference/adapter/django/index.html index ed9f43658..dfb6af63f 100644 --- a/docs/reference/adapter/django/index.html +++ b/docs/reference/adapter/django/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.django API documentation @@ -194,7 +194,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/falcon/async_resource.html b/docs/reference/adapter/falcon/async_resource.html index 07d432390..f43ab11ef 100644 --- a/docs/reference/adapter/falcon/async_resource.html +++ b/docs/reference/adapter/falcon/async_resource.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.falcon.async_resource API documentation @@ -87,7 +87,7 @@

    Classes

    resp.status = "404" # Falcon 4.x w/ mypy fails to correctly infer the str type here - resp.body = "The page is not found..." # type: ignore[assignment] + resp.body = "The page is not found..." async def on_post(self, req: Request, resp: Response): bolt_req = await self._to_bolt_request(req) @@ -151,7 +151,7 @@

    Methods

    resp.status = "404" # Falcon 4.x w/ mypy fails to correctly infer the str type here - resp.body = "The page is not found..." # type: ignore[assignment]
    + resp.body = "The page is not found..."
    @@ -200,7 +200,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/falcon/index.html b/docs/reference/adapter/falcon/index.html index 50874f7b8..82a2a57e2 100644 --- a/docs/reference/adapter/falcon/index.html +++ b/docs/reference/adapter/falcon/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.falcon API documentation @@ -93,7 +93,7 @@

    Classes

    resp.status = "404" # Falcon 4.x w/ mypy fails to correctly infer the str type here - resp.body = "The page is not found..." # type: ignore[assignment] + resp.body = "The page is not found..." def on_post(self, req: Request, resp: Response): bolt_req = self._to_bolt_request(req) @@ -110,7 +110,7 @@

    Classes

    def _write_response(self, bolt_resp: BoltResponse, resp: Response): if falcon_version.__version__.startswith("2."): # Falcon 4.x w/ mypy fails to correctly infer the str type here - resp.body = bolt_resp.body # type: ignore[assignment] + resp.body = bolt_resp.body else: resp.text = bolt_resp.body @@ -161,7 +161,7 @@

    Methods

    resp.status = "404" # Falcon 4.x w/ mypy fails to correctly infer the str type here - resp.body = "The page is not found..." # type: ignore[assignment]
    + resp.body = "The page is not found..."
    @@ -216,7 +216,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/falcon/resource.html b/docs/reference/adapter/falcon/resource.html index b999b290c..73860adc1 100644 --- a/docs/reference/adapter/falcon/resource.html +++ b/docs/reference/adapter/falcon/resource.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.falcon.resource API documentation @@ -82,7 +82,7 @@

    Classes

    resp.status = "404" # Falcon 4.x w/ mypy fails to correctly infer the str type here - resp.body = "The page is not found..." # type: ignore[assignment] + resp.body = "The page is not found..." def on_post(self, req: Request, resp: Response): bolt_req = self._to_bolt_request(req) @@ -99,7 +99,7 @@

    Classes

    def _write_response(self, bolt_resp: BoltResponse, resp: Response): if falcon_version.__version__.startswith("2."): # Falcon 4.x w/ mypy fails to correctly infer the str type here - resp.body = bolt_resp.body # type: ignore[assignment] + resp.body = bolt_resp.body else: resp.text = bolt_resp.body @@ -150,7 +150,7 @@

    Methods

    resp.status = "404" # Falcon 4.x w/ mypy fails to correctly infer the str type here - resp.body = "The page is not found..." # type: ignore[assignment]
    + resp.body = "The page is not found..."
    @@ -199,7 +199,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/fastapi/async_handler.html b/docs/reference/adapter/fastapi/async_handler.html index 0056ce305..6f6205e51 100644 --- a/docs/reference/adapter/fastapi/async_handler.html +++ b/docs/reference/adapter/fastapi/async_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.fastapi.async_handler API documentation @@ -149,7 +149,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/fastapi/index.html b/docs/reference/adapter/fastapi/index.html index 49b91a814..6ffb52f35 100644 --- a/docs/reference/adapter/fastapi/index.html +++ b/docs/reference/adapter/fastapi/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.fastapi API documentation @@ -153,7 +153,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/flask/handler.html b/docs/reference/adapter/flask/handler.html index 1b3952603..489b80a90 100644 --- a/docs/reference/adapter/flask/handler.html +++ b/docs/reference/adapter/flask/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.flask.handler API documentation @@ -179,7 +179,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/flask/index.html b/docs/reference/adapter/flask/index.html index 7d4b60292..ee765fa1e 100644 --- a/docs/reference/adapter/flask/index.html +++ b/docs/reference/adapter/flask/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.flask API documentation @@ -145,7 +145,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/google_cloud_functions/handler.html b/docs/reference/adapter/google_cloud_functions/handler.html index 0b48a26a4..1d9b0da7f 100644 --- a/docs/reference/adapter/google_cloud_functions/handler.html +++ b/docs/reference/adapter/google_cloud_functions/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.google_cloud_functions.handler API documentation @@ -170,7 +170,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/google_cloud_functions/index.html b/docs/reference/adapter/google_cloud_functions/index.html index 3ad305b8d..790d210be 100644 --- a/docs/reference/adapter/google_cloud_functions/index.html +++ b/docs/reference/adapter/google_cloud_functions/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.google_cloud_functions API documentation @@ -147,7 +147,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/index.html b/docs/reference/adapter/index.html index 78ef4a2f7..646c0ac81 100644 --- a/docs/reference/adapter/index.html +++ b/docs/reference/adapter/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter API documentation @@ -148,7 +148,7 @@

    Sub-modules

    diff --git a/docs/reference/adapter/pyramid/handler.html b/docs/reference/adapter/pyramid/handler.html index 2f26bbc38..4a4a68849 100644 --- a/docs/reference/adapter/pyramid/handler.html +++ b/docs/reference/adapter/pyramid/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.pyramid.handler API documentation @@ -195,7 +195,7 @@

    diff --git a/docs/reference/adapter/pyramid/index.html b/docs/reference/adapter/pyramid/index.html index 30d3685e8..7f0903cb6 100644 --- a/docs/reference/adapter/pyramid/index.html +++ b/docs/reference/adapter/pyramid/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.pyramid API documentation @@ -151,7 +151,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/sanic/async_handler.html b/docs/reference/adapter/sanic/async_handler.html index 37945775c..adabe53be 100644 --- a/docs/reference/adapter/sanic/async_handler.html +++ b/docs/reference/adapter/sanic/async_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.sanic.async_handler API documentation @@ -210,7 +210,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/sanic/index.html b/docs/reference/adapter/sanic/index.html index 1ae23450c..558bb321c 100644 --- a/docs/reference/adapter/sanic/index.html +++ b/docs/reference/adapter/sanic/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.sanic API documentation @@ -153,7 +153,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/socket_mode/aiohttp/index.html b/docs/reference/adapter/socket_mode/aiohttp/index.html index f9d240874..cc91a3d06 100644 --- a/docs/reference/adapter/socket_mode/aiohttp/index.html +++ b/docs/reference/adapter/socket_mode/aiohttp/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.aiohttp API documentation @@ -239,7 +239,7 @@

    diff --git a/docs/reference/adapter/socket_mode/async_base_handler.html b/docs/reference/adapter/socket_mode/async_base_handler.html index 31b681b3c..b00420c11 100644 --- a/docs/reference/adapter/socket_mode/async_base_handler.html +++ b/docs/reference/adapter/socket_mode/async_base_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.async_base_handler API documentation @@ -240,7 +240,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/socket_mode/async_handler.html b/docs/reference/adapter/socket_mode/async_handler.html index 5093f1281..447ecf0ea 100644 --- a/docs/reference/adapter/socket_mode/async_handler.html +++ b/docs/reference/adapter/socket_mode/async_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.async_handler API documentation @@ -142,7 +142,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/socket_mode/async_internals.html b/docs/reference/adapter/socket_mode/async_internals.html index 5b7769483..d2e300efa 100644 --- a/docs/reference/adapter/socket_mode/async_internals.html +++ b/docs/reference/adapter/socket_mode/async_internals.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.async_internals API documentation @@ -121,7 +121,7 @@

    Functions

    diff --git a/docs/reference/adapter/socket_mode/base_handler.html b/docs/reference/adapter/socket_mode/base_handler.html index b57156928..450f9ac0e 100644 --- a/docs/reference/adapter/socket_mode/base_handler.html +++ b/docs/reference/adapter/socket_mode/base_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.base_handler API documentation @@ -252,7 +252,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/socket_mode/builtin/index.html b/docs/reference/adapter/socket_mode/builtin/index.html index ab1837ae3..fc66eb203 100644 --- a/docs/reference/adapter/socket_mode/builtin/index.html +++ b/docs/reference/adapter/socket_mode/builtin/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.builtin API documentation @@ -200,7 +200,7 @@

    diff --git a/docs/reference/adapter/socket_mode/index.html b/docs/reference/adapter/socket_mode/index.html index cb26a212a..511ef4840 100644 --- a/docs/reference/adapter/socket_mode/index.html +++ b/docs/reference/adapter/socket_mode/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode API documentation @@ -260,7 +260,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/socket_mode/internals.html b/docs/reference/adapter/socket_mode/internals.html index 7c1a7a81f..55d96b054 100644 --- a/docs/reference/adapter/socket_mode/internals.html +++ b/docs/reference/adapter/socket_mode/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.internals API documentation @@ -119,7 +119,7 @@

    Functions

    diff --git a/docs/reference/adapter/socket_mode/websocket_client/index.html b/docs/reference/adapter/socket_mode/websocket_client/index.html index d6a4b50b4..e837ef19b 100644 --- a/docs/reference/adapter/socket_mode/websocket_client/index.html +++ b/docs/reference/adapter/socket_mode/websocket_client/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.websocket_client API documentation @@ -190,7 +190,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/socket_mode/websockets/index.html b/docs/reference/adapter/socket_mode/websockets/index.html index 2b7e9f493..7f96f0021 100644 --- a/docs/reference/adapter/socket_mode/websockets/index.html +++ b/docs/reference/adapter/socket_mode/websockets/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.socket_mode.websockets API documentation @@ -239,7 +239,7 @@

    diff --git a/docs/reference/adapter/starlette/async_handler.html b/docs/reference/adapter/starlette/async_handler.html index e8e596f2a..91345eba3 100644 --- a/docs/reference/adapter/starlette/async_handler.html +++ b/docs/reference/adapter/starlette/async_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.starlette.async_handler API documentation @@ -213,7 +213,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/starlette/handler.html b/docs/reference/adapter/starlette/handler.html index 5171240fd..5c74b71da 100644 --- a/docs/reference/adapter/starlette/handler.html +++ b/docs/reference/adapter/starlette/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.starlette.handler API documentation @@ -205,7 +205,7 @@

    diff --git a/docs/reference/adapter/starlette/index.html b/docs/reference/adapter/starlette/index.html index 3af382537..bdf5bf42a 100644 --- a/docs/reference/adapter/starlette/index.html +++ b/docs/reference/adapter/starlette/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.starlette API documentation @@ -158,7 +158,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/tornado/async_handler.html b/docs/reference/adapter/tornado/async_handler.html index b7d813420..c274429de 100644 --- a/docs/reference/adapter/tornado/async_handler.html +++ b/docs/reference/adapter/tornado/async_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.tornado.async_handler API documentation @@ -242,7 +242,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/tornado/handler.html b/docs/reference/adapter/tornado/handler.html index 1149311a9..a69adb987 100644 --- a/docs/reference/adapter/tornado/handler.html +++ b/docs/reference/adapter/tornado/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.tornado.handler API documentation @@ -273,7 +273,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/tornado/index.html b/docs/reference/adapter/tornado/index.html index dac1b6b78..a5bec4ffb 100644 --- a/docs/reference/adapter/tornado/index.html +++ b/docs/reference/adapter/tornado/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.tornado API documentation @@ -234,7 +234,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/wsgi/handler.html b/docs/reference/adapter/wsgi/handler.html index 8369cb636..204499a05 100644 --- a/docs/reference/adapter/wsgi/handler.html +++ b/docs/reference/adapter/wsgi/handler.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.wsgi.handler API documentation @@ -230,7 +230,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/wsgi/http_request.html b/docs/reference/adapter/wsgi/http_request.html index c4495e440..fa845dd93 100644 --- a/docs/reference/adapter/wsgi/http_request.html +++ b/docs/reference/adapter/wsgi/http_request.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.wsgi.http_request API documentation @@ -373,7 +373,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/wsgi/http_response.html b/docs/reference/adapter/wsgi/http_response.html index 3ddc2350c..da7dc33f0 100644 --- a/docs/reference/adapter/wsgi/http_response.html +++ b/docs/reference/adapter/wsgi/http_response.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.wsgi.http_response API documentation @@ -191,7 +191,7 @@

    diff --git a/docs/reference/adapter/wsgi/index.html b/docs/reference/adapter/wsgi/index.html index 49ab0d930..c3cfafea1 100644 --- a/docs/reference/adapter/wsgi/index.html +++ b/docs/reference/adapter/wsgi/index.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.wsgi API documentation @@ -257,7 +257,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/adapter/wsgi/internals.html b/docs/reference/adapter/wsgi/internals.html index addbae583..7fdfa267f 100644 --- a/docs/reference/adapter/wsgi/internals.html +++ b/docs/reference/adapter/wsgi/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.adapter.wsgi.internals API documentation @@ -60,7 +60,7 @@

    Module slack_bolt.adapter.wsgi.internals

    diff --git a/docs/reference/app/app.html b/docs/reference/app/app.html index 02fc5b036..d1224dd5d 100644 --- a/docs/reference/app/app.html +++ b/docs/reference/app/app.html @@ -3,7 +3,7 @@ - + slack_bolt.app.app API documentation @@ -675,7 +675,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new step from app listener. @@ -693,7 +693,7 @@

    Classes

    # Pass Step to set up listeners app.step(ws) - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. + Refer to https://api.slack.com/workflows/steps for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -710,7 +710,7 @@

    Classes

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps" + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" ), category=DeprecationWarning, ) @@ -787,7 +787,7 @@

    Classes

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. + Refer to https://api.slack.com/apis/connections/events-api for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -825,7 +825,7 @@

    Classes

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://docs.slack.dev/reference/events/message for details of `message` events. + Refer to https://api.slack.com/events/message for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -871,6 +871,7 @@

    Classes

    matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, auto_acknowledge: bool = True, + ack_timeout: int = 3, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. @@ -899,13 +900,17 @@

    Classes

    Only when all the middleware call `next()` method, the listener function can be invoked. """ + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) + matchers = list(matchers) if matchers else [] middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger) - return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__ @@ -931,7 +936,7 @@

    Classes

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands. + Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -978,7 +983,7 @@

    Classes

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts. + Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1046,9 +1051,9 @@

    Classes

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for actions in `blocks`. - * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for actions in `attachments`. - * Refer to https://docs.slack.dev/legacy/legacy-dialogs for actions in dialogs. + * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. + * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. + * Refer to https://api.slack.com/dialogs for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1074,7 +1079,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details. + Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ def __call__(*args, **kwargs): @@ -1091,7 +1096,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. - Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.""" + Refer to https://api.slack.com/legacy/message-buttons for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1107,7 +1112,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1123,7 +1128,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1164,7 +1169,7 @@

    Classes

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. + Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1190,7 +1195,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1206,7 +1211,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1247,7 +1252,8 @@

    Classes

    Refer to the following documents for details: - * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select + * https://api.slack.com/reference/block-kit/block-elements#external_select + * https://api.slack.com/reference/block-kit/block-elements#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1287,7 +1293,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1380,6 +1386,7 @@

    Classes

    matchers: Optional[Sequence[Callable[..., bool]]], middleware: Optional[Sequence[Union[Callable, Middleware]]], auto_acknowledgement: bool = False, + ack_timeout: int = 3, ) -> Optional[Callable[..., Optional[BoltResponse]]]: value_to_return = None if not isinstance(functions, list): @@ -1406,10 +1413,11 @@

    Classes

    CustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, + lazy_functions=functions, # type:ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + ack_timeout=ack_timeout, base_logger=self._base_logger, ) ) @@ -1629,9 +1637,9 @@

    Methods

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for actions in `blocks`. - * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for actions in `attachments`. - * Refer to https://docs.slack.dev/legacy/legacy-dialogs for actions in dialogs. + * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. + * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. + * Refer to https://api.slack.com/dialogs for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1660,9 +1668,9 @@

    Methods

    app.action("approve_button")(update_message)

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -1705,7 +1713,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. - Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.""" + Refer to https://api.slack.com/legacy/message-buttons for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1715,7 +1723,7 @@

    Args

    return __call__

    Registers a new interactive_message action listener. -Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.

    +Refer to https://api.slack.com/legacy/message-buttons for details.

    def block_action(self,
    constraints: str | Pattern | Dict[str, str | Pattern],
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1732,7 +1740,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details. + Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ def __call__(*args, **kwargs): @@ -1743,7 +1751,7 @@

    Args

    return __call__

    Registers a new block_actions action listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.

    def block_suggestion(self,
    action_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1797,7 +1805,7 @@

    Args

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands. + Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1828,7 +1836,7 @@

    Args

    # Pass a function to this method app.command("/echo")(repeat_text)
    -

    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands.

    +

    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -1891,7 +1899,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1901,7 +1909,7 @@

    Args

    return __call__

    Registers a new dialog_cancellation listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def dialog_submission(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1918,7 +1926,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1928,7 +1936,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def dialog_suggestion(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1945,7 +1953,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1955,7 +1963,7 @@

    Args

    return __call__

    Registers a new dialog_suggestion listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def dispatch(self,
    req: BoltRequest) ‑> BoltResponse
    @@ -2181,7 +2189,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. + Refer to https://api.slack.com/apis/connections/events-api for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2213,7 +2221,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction)
    -

    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

    +

    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2229,7 +2237,7 @@

    Args

    -def function(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None,
    auto_acknowledge: bool = True) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    +def function(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None,
    auto_acknowledge: bool = True,
    ack_timeout: int = 3) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -2242,6 +2250,7 @@

    Args

    matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, auto_acknowledge: bool = True, + ack_timeout: int = 3, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. @@ -2270,13 +2279,17 @@

    Args

    Only when all the middleware call `next()` method, the listener function can be invoked. """ + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) + matchers = list(matchers) if matchers else [] middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger) - return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__
    @@ -2360,7 +2373,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://docs.slack.dev/reference/events/message for details of `message` events. + Refer to https://api.slack.com/events/message for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2411,7 +2424,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) -

    Refer to https://docs.slack.dev/reference/events/message for details of message events.

    +

    Refer to https://api.slack.com/events/message for details of message events.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2554,7 +2567,8 @@

    Args

    Refer to the following documents for details: - * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select + * https://api.slack.com/reference/block-kit/block-elements#external_select + * https://api.slack.com/reference/block-kit/block-elements#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2594,7 +2608,8 @@

    Args

    Refer to the following documents for details:

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2640,7 +2655,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts. + Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2677,7 +2692,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) -

    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts.

    +

    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2763,7 +2778,7 @@

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new step from app listener. @@ -2781,7 +2796,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. + Refer to https://api.slack.com/workflows/steps for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2798,7 +2813,7 @@

    Args

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps" + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" ), category=DeprecationWarning, ) @@ -2820,7 +2835,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Registers a new step from app listener.

    Unlike others, this method doesn't behave as a decorator. If you want to register a step from app by a decorator, use WorkflowStepBuilder's methods.

    @@ -2835,7 +2850,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) -

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

    +

    Refer to https://api.slack.com/workflows/steps for details of steps from apps.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    For further information about WorkflowStep specific function arguments such as configure, update, complete, and fail, @@ -2906,7 +2921,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. + Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2947,7 +2962,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) -

    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

    +

    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2976,7 +2991,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2986,7 +3001,7 @@

    Args

    return __call__

    Registers a new view_closed listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.

    def view_submission(self,
    constraints: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -3003,7 +3018,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3013,7 +3028,7 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.

    @@ -3264,7 +3279,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/app/async_app.html b/docs/reference/app/async_app.html index 66af8f038..b6634710a 100644 --- a/docs/reference/app/async_app.html +++ b/docs/reference/app/async_app.html @@ -3,7 +3,7 @@ - + slack_bolt.app.async_app API documentation @@ -687,7 +687,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new step from app listener. @@ -705,7 +705,7 @@

    Classes

    # Pass Step to set up listeners app.step(ws) - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. + Refer to https://api.slack.com/workflows/steps for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. For further information about AsyncWorkflowStep specific function arguments @@ -721,7 +721,7 @@

    Classes

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps" + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" ), category=DeprecationWarning, ) @@ -803,7 +803,7 @@

    Classes

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. + Refer to https://api.slack.com/apis/connections/events-api for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -841,7 +841,7 @@

    Classes

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://docs.slack.dev/reference/events/message for details of `message` events. + Refer to https://api.slack.com/events/message for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -890,6 +890,7 @@

    Classes

    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, auto_acknowledge: bool = True, + ack_timeout: int = 3, ) -> Callable[..., Optional[Callable[..., Awaitable[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. @@ -917,6 +918,9 @@

    Classes

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) matchers = list(matchers) if matchers else [] middleware = list(middleware) if middleware else [] @@ -926,7 +930,7 @@

    Classes

    primary_matcher = builtin_matchers.function_executed( callback_id=callback_id, base_logger=self._base_logger, asyncio=True ) - return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__ @@ -952,7 +956,7 @@

    Classes

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands. + Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -999,7 +1003,7 @@

    Classes

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts. + Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1067,9 +1071,9 @@

    Classes

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for actions in `blocks`. - * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for actions in `attachments`. - * Refer to https://docs.slack.dev/legacy/legacy-dialogs for actions in dialogs. + * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. + * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. + * Refer to https://api.slack.com/dialogs for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1095,7 +1099,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_actions` action listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details. + Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ def __call__(*args, **kwargs): @@ -1112,7 +1116,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `interactive_message` action listener. - Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.""" + Refer to https://api.slack.com/legacy/message-buttons for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1128,7 +1132,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1144,7 +1148,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1185,7 +1189,7 @@

    Classes

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. + Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1211,7 +1215,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1227,7 +1231,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_closed` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1268,7 +1272,8 @@

    Classes

    Refer to the following documents for details: - * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select + * https://api.slack.com/reference/block-kit/block-elements#external_select + * https://api.slack.com/reference/block-kit/block-elements#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1308,7 +1313,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1405,6 +1410,7 @@

    Classes

    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]], middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]], auto_acknowledgement: bool = False, + ack_timeout: int = 3, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: value_to_return = None if not isinstance(functions, list): @@ -1436,10 +1442,11 @@

    Classes

    AsyncCustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, + lazy_functions=functions, # type:ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + ack_timeout=ack_timeout, base_logger=self._base_logger, ) ) @@ -1662,9 +1669,9 @@

    Methods

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for actions in `blocks`. - * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for actions in `attachments`. - * Refer to https://docs.slack.dev/legacy/legacy-dialogs for actions in dialogs. + * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. + * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. + * Refer to https://api.slack.com/dialogs for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1693,9 +1700,9 @@

    Methods

    app.action("approve_button")(update_message)

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -1866,7 +1873,7 @@

    Returns

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `interactive_message` action listener. - Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.""" + Refer to https://api.slack.com/legacy/message-buttons for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1876,7 +1883,7 @@

    Returns

    return __call__

    Registers a new interactive_message action listener. -Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.

    +Refer to https://api.slack.com/legacy/message-buttons for details.

    def block_action(self,
    constraints: str | Pattern | Dict[str, str | Pattern],
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -1893,7 +1900,7 @@

    Returns

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_actions` action listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details. + Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ def __call__(*args, **kwargs): @@ -1904,7 +1911,7 @@

    Returns

    return __call__

    Registers a new block_actions action listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.

    def block_suggestion(self,
    action_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -1958,7 +1965,7 @@

    Returns

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands. + Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1989,7 +1996,7 @@

    Returns

    # Pass a function to this method app.command("/echo")(repeat_text)
    -

    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands.

    +

    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2052,7 +2059,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2062,7 +2069,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def dialog_submission(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -2079,7 +2086,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2089,7 +2096,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def dialog_suggestion(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -2106,7 +2113,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2116,7 +2123,7 @@

    Args

    return __call__

    Registers a new dialog_suggestion listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def enable_token_revocation_listeners(self) ‑> None @@ -2222,7 +2229,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. + Refer to https://api.slack.com/apis/connections/events-api for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2254,7 +2261,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction)
    -

    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

    +

    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2270,7 +2277,7 @@

    Args

    -def function(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None,
    auto_acknowledge: bool = True) ‑> Callable[..., Callable[..., Awaitable[BoltResponse]] | None]
    +def function(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None,
    auto_acknowledge: bool = True,
    ack_timeout: int = 3) ‑> Callable[..., Callable[..., Awaitable[BoltResponse]] | None]
    @@ -2283,6 +2290,7 @@

    Args

    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, auto_acknowledge: bool = True, + ack_timeout: int = 3, ) -> Callable[..., Optional[Callable[..., Awaitable[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. @@ -2310,6 +2318,9 @@

    Args

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) matchers = list(matchers) if matchers else [] middleware = list(middleware) if middleware else [] @@ -2319,7 +2330,7 @@

    Args

    primary_matcher = builtin_matchers.function_executed( callback_id=callback_id, base_logger=self._base_logger, asyncio=True ) - return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__
    @@ -2403,7 +2414,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://docs.slack.dev/reference/events/message for details of `message` events. + Refer to https://api.slack.com/events/message for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2457,7 +2468,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) -

    Refer to https://docs.slack.dev/reference/events/message for details of message events.

    +

    Refer to https://api.slack.com/events/message for details of message events.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2597,7 +2608,8 @@

    Args

    Refer to the following documents for details: - * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select + * https://api.slack.com/reference/block-kit/block-elements#external_select + * https://api.slack.com/reference/block-kit/block-elements#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2637,7 +2649,8 @@

    Args

    Refer to the following documents for details:

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2726,7 +2739,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts. + Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2763,7 +2776,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) -

    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts.

    +

    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2826,7 +2839,7 @@

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new step from app listener. @@ -2844,7 +2857,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. + Refer to https://api.slack.com/workflows/steps for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. For further information about AsyncWorkflowStep specific function arguments @@ -2860,7 +2873,7 @@

    Args

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps" + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" ), category=DeprecationWarning, ) @@ -2882,7 +2895,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Registers a new step from app listener.

    Unlike others, this method doesn't behave as a decorator. If you want to register a step from app by a decorator, use AsyncWorkflowStepBuilder's methods.

    @@ -2897,7 +2910,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) -

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

    +

    Refer to https://api.slack.com/workflows/steps for details of steps from apps.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document. For further information about AsyncWorkflowStep specific function arguments such as configure, update, complete, and fail, @@ -2965,7 +2978,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. + Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -3006,7 +3019,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) -

    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

    +

    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -3035,7 +3048,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_closed` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3045,7 +3058,7 @@

    Args

    return __call__

    Registers a new view_closed listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.

    def view_submission(self,
    constraints: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -3062,7 +3075,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3072,7 +3085,7 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.

    def web_app(self, path: str = '/slack/events', port: int = 3000) ‑> aiohttp.web_app.Application @@ -3192,7 +3205,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/app/async_server.html b/docs/reference/app/async_server.html index 25d28fed1..b95b8a2b3 100644 --- a/docs/reference/app/async_server.html +++ b/docs/reference/app/async_server.html @@ -3,7 +3,7 @@ - + slack_bolt.app.async_server API documentation @@ -270,7 +270,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/app/index.html b/docs/reference/app/index.html index f1a7e9655..857fb22c8 100644 --- a/docs/reference/app/index.html +++ b/docs/reference/app/index.html @@ -3,7 +3,7 @@ - + slack_bolt.app API documentation @@ -694,7 +694,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new step from app listener. @@ -712,7 +712,7 @@

    Classes

    # Pass Step to set up listeners app.step(ws) - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. + Refer to https://api.slack.com/workflows/steps for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -729,7 +729,7 @@

    Classes

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps" + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" ), category=DeprecationWarning, ) @@ -806,7 +806,7 @@

    Classes

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. + Refer to https://api.slack.com/apis/connections/events-api for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -844,7 +844,7 @@

    Classes

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://docs.slack.dev/reference/events/message for details of `message` events. + Refer to https://api.slack.com/events/message for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -890,6 +890,7 @@

    Classes

    matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, auto_acknowledge: bool = True, + ack_timeout: int = 3, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. @@ -918,13 +919,17 @@

    Classes

    Only when all the middleware call `next()` method, the listener function can be invoked. """ + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) + matchers = list(matchers) if matchers else [] middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger) - return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__ @@ -950,7 +955,7 @@

    Classes

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands. + Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -997,7 +1002,7 @@

    Classes

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts. + Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1065,9 +1070,9 @@

    Classes

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for actions in `blocks`. - * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for actions in `attachments`. - * Refer to https://docs.slack.dev/legacy/legacy-dialogs for actions in dialogs. + * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. + * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. + * Refer to https://api.slack.com/dialogs for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1093,7 +1098,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details. + Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ def __call__(*args, **kwargs): @@ -1110,7 +1115,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. - Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.""" + Refer to https://api.slack.com/legacy/message-buttons for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1126,7 +1131,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1142,7 +1147,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1183,7 +1188,7 @@

    Classes

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. + Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1209,7 +1214,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1225,7 +1230,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1266,7 +1271,8 @@

    Classes

    Refer to the following documents for details: - * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select + * https://api.slack.com/reference/block-kit/block-elements#external_select + * https://api.slack.com/reference/block-kit/block-elements#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1306,7 +1312,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1399,6 +1405,7 @@

    Classes

    matchers: Optional[Sequence[Callable[..., bool]]], middleware: Optional[Sequence[Union[Callable, Middleware]]], auto_acknowledgement: bool = False, + ack_timeout: int = 3, ) -> Optional[Callable[..., Optional[BoltResponse]]]: value_to_return = None if not isinstance(functions, list): @@ -1425,10 +1432,11 @@

    Classes

    CustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, + lazy_functions=functions, # type:ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + ack_timeout=ack_timeout, base_logger=self._base_logger, ) ) @@ -1648,9 +1656,9 @@

    Methods

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for actions in `blocks`. - * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for actions in `attachments`. - * Refer to https://docs.slack.dev/legacy/legacy-dialogs for actions in dialogs. + * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. + * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. + * Refer to https://api.slack.com/dialogs for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1679,9 +1687,9 @@

    Methods

    app.action("approve_button")(update_message)

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -1724,7 +1732,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. - Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.""" + Refer to https://api.slack.com/legacy/message-buttons for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1734,7 +1742,7 @@

    Args

    return __call__

    Registers a new interactive_message action listener. -Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.

    +Refer to https://api.slack.com/legacy/message-buttons for details.

    def block_action(self,
    constraints: str | Pattern | Dict[str, str | Pattern],
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1751,7 +1759,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details. + Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ def __call__(*args, **kwargs): @@ -1762,7 +1770,7 @@

    Args

    return __call__

    Registers a new block_actions action listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.

    def block_suggestion(self,
    action_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1816,7 +1824,7 @@

    Args

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands. + Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1847,7 +1855,7 @@

    Args

    # Pass a function to this method app.command("/echo")(repeat_text)
    -

    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands.

    +

    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -1910,7 +1918,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1920,7 +1928,7 @@

    Args

    return __call__

    Registers a new dialog_cancellation listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def dialog_submission(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1937,7 +1945,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1947,7 +1955,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def dialog_suggestion(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1964,7 +1972,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1974,7 +1982,7 @@

    Args

    return __call__

    Registers a new dialog_suggestion listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def dispatch(self,
    req: BoltRequest) ‑> BoltResponse
    @@ -2200,7 +2208,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. + Refer to https://api.slack.com/apis/connections/events-api for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2232,7 +2240,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction)
    -

    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

    +

    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2248,7 +2256,7 @@

    Args

    -def function(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None,
    auto_acknowledge: bool = True) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    +def function(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None,
    auto_acknowledge: bool = True,
    ack_timeout: int = 3) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -2261,6 +2269,7 @@

    Args

    matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, auto_acknowledge: bool = True, + ack_timeout: int = 3, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. @@ -2289,13 +2298,17 @@

    Args

    Only when all the middleware call `next()` method, the listener function can be invoked. """ + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) + matchers = list(matchers) if matchers else [] middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger) - return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__
    @@ -2379,7 +2392,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://docs.slack.dev/reference/events/message for details of `message` events. + Refer to https://api.slack.com/events/message for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2430,7 +2443,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) -

    Refer to https://docs.slack.dev/reference/events/message for details of message events.

    +

    Refer to https://api.slack.com/events/message for details of message events.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2573,7 +2586,8 @@

    Args

    Refer to the following documents for details: - * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select + * https://api.slack.com/reference/block-kit/block-elements#external_select + * https://api.slack.com/reference/block-kit/block-elements#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2613,7 +2627,8 @@

    Args

    Refer to the following documents for details:

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2659,7 +2674,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts. + Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2696,7 +2711,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) -

    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts.

    +

    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2782,7 +2797,7 @@

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new step from app listener. @@ -2800,7 +2815,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. + Refer to https://api.slack.com/workflows/steps for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2817,7 +2832,7 @@

    Args

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps" + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" ), category=DeprecationWarning, ) @@ -2839,7 +2854,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Registers a new step from app listener.

    Unlike others, this method doesn't behave as a decorator. If you want to register a step from app by a decorator, use WorkflowStepBuilder's methods.

    @@ -2854,7 +2869,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) -

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

    +

    Refer to https://api.slack.com/workflows/steps for details of steps from apps.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    For further information about WorkflowStep specific function arguments such as configure, update, complete, and fail, @@ -2925,7 +2940,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. + Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2966,7 +2981,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) -

    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

    +

    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2995,7 +3010,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3005,7 +3020,7 @@

    Args

    return __call__

    Registers a new view_closed listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.

    def view_submission(self,
    constraints: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -3022,7 +3037,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3032,7 +3047,7 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.

    @@ -3104,7 +3119,7 @@

    App diff --git a/docs/reference/async_app.html b/docs/reference/async_app.html index c067aeb5e..07c1f3627 100644 --- a/docs/reference/async_app.html +++ b/docs/reference/async_app.html @@ -3,7 +3,7 @@ - + slack_bolt.async_app API documentation @@ -778,7 +778,7 @@

    Class variables

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new step from app listener. @@ -796,7 +796,7 @@

    Class variables

    # Pass Step to set up listeners app.step(ws) - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. + Refer to https://api.slack.com/workflows/steps for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. For further information about AsyncWorkflowStep specific function arguments @@ -812,7 +812,7 @@

    Class variables

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps" + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" ), category=DeprecationWarning, ) @@ -894,7 +894,7 @@

    Class variables

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. + Refer to https://api.slack.com/apis/connections/events-api for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -932,7 +932,7 @@

    Class variables

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://docs.slack.dev/reference/events/message for details of `message` events. + Refer to https://api.slack.com/events/message for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -981,6 +981,7 @@

    Class variables

    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, auto_acknowledge: bool = True, + ack_timeout: int = 3, ) -> Callable[..., Optional[Callable[..., Awaitable[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. @@ -1008,6 +1009,9 @@

    Class variables

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) matchers = list(matchers) if matchers else [] middleware = list(middleware) if middleware else [] @@ -1017,7 +1021,7 @@

    Class variables

    primary_matcher = builtin_matchers.function_executed( callback_id=callback_id, base_logger=self._base_logger, asyncio=True ) - return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__ @@ -1043,7 +1047,7 @@

    Class variables

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands. + Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1090,7 +1094,7 @@

    Class variables

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts. + Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1158,9 +1162,9 @@

    Class variables

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for actions in `blocks`. - * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for actions in `attachments`. - * Refer to https://docs.slack.dev/legacy/legacy-dialogs for actions in dialogs. + * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. + * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. + * Refer to https://api.slack.com/dialogs for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1186,7 +1190,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_actions` action listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details. + Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ def __call__(*args, **kwargs): @@ -1203,7 +1207,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `interactive_message` action listener. - Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.""" + Refer to https://api.slack.com/legacy/message-buttons for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1219,7 +1223,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1235,7 +1239,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1276,7 +1280,7 @@

    Class variables

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. + Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1302,7 +1306,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1318,7 +1322,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_closed` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1359,7 +1363,8 @@

    Class variables

    Refer to the following documents for details: - * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select + * https://api.slack.com/reference/block-kit/block-elements#external_select + * https://api.slack.com/reference/block-kit/block-elements#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1399,7 +1404,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1496,6 +1501,7 @@

    Class variables

    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]], middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]], auto_acknowledgement: bool = False, + ack_timeout: int = 3, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: value_to_return = None if not isinstance(functions, list): @@ -1527,10 +1533,11 @@

    Class variables

    AsyncCustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, + lazy_functions=functions, # type:ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + ack_timeout=ack_timeout, base_logger=self._base_logger, ) ) @@ -1753,9 +1760,9 @@

    Methods

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for actions in `blocks`. - * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for actions in `attachments`. - * Refer to https://docs.slack.dev/legacy/legacy-dialogs for actions in dialogs. + * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. + * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. + * Refer to https://api.slack.com/dialogs for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1784,9 +1791,9 @@

    Methods

    app.action("approve_button")(update_message)

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -1957,7 +1964,7 @@

    Returns

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `interactive_message` action listener. - Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.""" + Refer to https://api.slack.com/legacy/message-buttons for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1967,7 +1974,7 @@

    Returns

    return __call__

    Registers a new interactive_message action listener. -Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.

    +Refer to https://api.slack.com/legacy/message-buttons for details.

    def block_action(self,
    constraints: str | Pattern | Dict[str, str | Pattern],
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -1984,7 +1991,7 @@

    Returns

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_actions` action listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details. + Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ def __call__(*args, **kwargs): @@ -1995,7 +2002,7 @@

    Returns

    return __call__

    Registers a new block_actions action listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.

    def block_suggestion(self,
    action_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -2049,7 +2056,7 @@

    Returns

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands. + Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2080,7 +2087,7 @@

    Returns

    # Pass a function to this method app.command("/echo")(repeat_text)
    -

    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands.

    +

    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2143,7 +2150,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2153,7 +2160,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def dialog_submission(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -2170,7 +2177,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2180,7 +2187,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def dialog_suggestion(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -2197,7 +2204,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2207,7 +2214,7 @@

    Args

    return __call__

    Registers a new dialog_suggestion listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def enable_token_revocation_listeners(self) ‑> None @@ -2313,7 +2320,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. + Refer to https://api.slack.com/apis/connections/events-api for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2345,7 +2352,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction)
    -

    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

    +

    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2361,7 +2368,7 @@

    Args

    -def function(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None,
    auto_acknowledge: bool = True) ‑> Callable[..., Callable[..., Awaitable[BoltResponse]] | None]
    +def function(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None,
    auto_acknowledge: bool = True,
    ack_timeout: int = 3) ‑> Callable[..., Callable[..., Awaitable[BoltResponse]] | None]
    @@ -2374,6 +2381,7 @@

    Args

    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, auto_acknowledge: bool = True, + ack_timeout: int = 3, ) -> Callable[..., Optional[Callable[..., Awaitable[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. @@ -2401,6 +2409,9 @@

    Args

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) matchers = list(matchers) if matchers else [] middleware = list(middleware) if middleware else [] @@ -2410,7 +2421,7 @@

    Args

    primary_matcher = builtin_matchers.function_executed( callback_id=callback_id, base_logger=self._base_logger, asyncio=True ) - return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__
    @@ -2494,7 +2505,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://docs.slack.dev/reference/events/message for details of `message` events. + Refer to https://api.slack.com/events/message for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2548,7 +2559,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) -

    Refer to https://docs.slack.dev/reference/events/message for details of message events.

    +

    Refer to https://api.slack.com/events/message for details of message events.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2688,7 +2699,8 @@

    Args

    Refer to the following documents for details: - * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select + * https://api.slack.com/reference/block-kit/block-elements#external_select + * https://api.slack.com/reference/block-kit/block-elements#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2728,7 +2740,8 @@

    Args

    Refer to the following documents for details:

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2817,7 +2830,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts. + Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2854,7 +2867,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) -

    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts.

    +

    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2917,7 +2930,7 @@

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new step from app listener. @@ -2935,7 +2948,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. + Refer to https://api.slack.com/workflows/steps for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. For further information about AsyncWorkflowStep specific function arguments @@ -2951,7 +2964,7 @@

    Args

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps" + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" ), category=DeprecationWarning, ) @@ -2973,7 +2986,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Registers a new step from app listener.

    Unlike others, this method doesn't behave as a decorator. If you want to register a step from app by a decorator, use AsyncWorkflowStepBuilder's methods.

    @@ -2988,7 +3001,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) -

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

    +

    Refer to https://api.slack.com/workflows/steps for details of steps from apps.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document. For further information about AsyncWorkflowStep specific function arguments such as configure, update, complete, and fail, @@ -3056,7 +3069,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. + Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -3097,7 +3110,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) -

    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

    +

    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -3126,7 +3139,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_closed` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3136,7 +3149,7 @@

    Args

    return __call__

    Registers a new view_closed listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.

    def view_submission(self,
    constraints: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -3153,7 +3166,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3163,7 +3176,7 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.

    def web_app(self, path: str = '/slack/events', port: int = 3000) ‑> aiohttp.web_app.Application @@ -4779,6 +4792,7 @@

    Class variables

    ack_function: Callable[..., Awaitable[BoltResponse]] lazy_functions: Sequence[Callable[..., Awaitable[None]]] auto_acknowledgement: bool + ack_timeout: int async def async_matches( self, @@ -4844,6 +4858,10 @@

    Class variables

    The type of the None singleton.

    +
    var ack_timeout : int
    +
    +

    The type of the None singleton.

    +
    var auto_acknowledgement : bool

    The type of the None singleton.

    @@ -5497,6 +5515,7 @@

    AsyncListener

    • ack_function
    • +
    • ack_timeout
    • async_matches
    • auto_acknowledgement
    • lazy_functions
    • @@ -5561,7 +5580,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/authorization/async_authorize.html b/docs/reference/authorization/async_authorize.html index 0a0640780..b4dfa2682 100644 --- a/docs/reference/authorization/async_authorize.html +++ b/docs/reference/authorization/async_authorize.html @@ -3,7 +3,7 @@ - + slack_bolt.authorization.async_authorize API documentation @@ -518,7 +518,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/authorization/async_authorize_args.html b/docs/reference/authorization/async_authorize_args.html index 642b35f93..5de20f757 100644 --- a/docs/reference/authorization/async_authorize_args.html +++ b/docs/reference/authorization/async_authorize_args.html @@ -3,7 +3,7 @@ - + slack_bolt.authorization.async_authorize_args API documentation @@ -158,7 +158,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/authorization/authorize.html b/docs/reference/authorization/authorize.html index 255c87196..33b50be02 100644 --- a/docs/reference/authorization/authorize.html +++ b/docs/reference/authorization/authorize.html @@ -3,7 +3,7 @@ - + slack_bolt.authorization.authorize API documentation @@ -516,7 +516,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/authorization/authorize_args.html b/docs/reference/authorization/authorize_args.html index 660ac17eb..78423fc40 100644 --- a/docs/reference/authorization/authorize_args.html +++ b/docs/reference/authorization/authorize_args.html @@ -3,7 +3,7 @@ - + slack_bolt.authorization.authorize_args API documentation @@ -158,7 +158,7 @@

      diff --git a/docs/reference/authorization/authorize_result.html b/docs/reference/authorization/authorize_result.html index 3bddc0a35..6eac3724d 100644 --- a/docs/reference/authorization/authorize_result.html +++ b/docs/reference/authorization/authorize_result.html @@ -3,7 +3,7 @@ - + slack_bolt.authorization.authorize_result API documentation @@ -292,7 +292,7 @@

      diff --git a/docs/reference/authorization/index.html b/docs/reference/authorization/index.html index eaa267d29..64ca14f0e 100644 --- a/docs/reference/authorization/index.html +++ b/docs/reference/authorization/index.html @@ -3,7 +3,7 @@ - + slack_bolt.authorization API documentation @@ -328,7 +328,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/ack/ack.html b/docs/reference/context/ack/ack.html index e1b71bcb9..a8b808d86 100644 --- a/docs/reference/context/ack/ack.html +++ b/docs/reference/context/ack/ack.html @@ -3,7 +3,7 @@ - + slack_bolt.context.ack.ack API documentation @@ -127,7 +127,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/ack/async_ack.html b/docs/reference/context/ack/async_ack.html index 0f2989e9f..f744d5693 100644 --- a/docs/reference/context/ack/async_ack.html +++ b/docs/reference/context/ack/async_ack.html @@ -3,7 +3,7 @@ - + slack_bolt.context.ack.async_ack API documentation @@ -127,7 +127,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/ack/index.html b/docs/reference/context/ack/index.html index 230da5d99..89f0600e8 100644 --- a/docs/reference/context/ack/index.html +++ b/docs/reference/context/ack/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.ack API documentation @@ -149,7 +149,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/ack/internals.html b/docs/reference/context/ack/internals.html index 2fa3d8028..f7f776241 100644 --- a/docs/reference/context/ack/internals.html +++ b/docs/reference/context/ack/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.context.ack.internals API documentation @@ -60,7 +60,7 @@

      Module slack_bolt.context.ack.internals

      diff --git a/docs/reference/context/assistant/assistant_utilities.html b/docs/reference/context/assistant/assistant_utilities.html index 7bfeeeee9..d446b3c02 100644 --- a/docs/reference/context/assistant/assistant_utilities.html +++ b/docs/reference/context/assistant/assistant_utilities.html @@ -3,7 +3,7 @@ - + slack_bolt.context.assistant.assistant_utilities API documentation @@ -289,7 +289,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/assistant/async_assistant_utilities.html b/docs/reference/context/assistant/async_assistant_utilities.html index b4af582a7..fc3cbbe8b 100644 --- a/docs/reference/context/assistant/async_assistant_utilities.html +++ b/docs/reference/context/assistant/async_assistant_utilities.html @@ -3,7 +3,7 @@ - + slack_bolt.context.assistant.async_assistant_utilities API documentation @@ -283,7 +283,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/assistant/index.html b/docs/reference/context/assistant/index.html index 73dcff282..d442e26cf 100644 --- a/docs/reference/context/assistant/index.html +++ b/docs/reference/context/assistant/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.assistant API documentation @@ -92,7 +92,7 @@

      Sub-modules

      diff --git a/docs/reference/context/assistant/internals.html b/docs/reference/context/assistant/internals.html index b1558b9d2..242bd6f19 100644 --- a/docs/reference/context/assistant/internals.html +++ b/docs/reference/context/assistant/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.context.assistant.internals API documentation @@ -89,7 +89,7 @@

      Functions

      diff --git a/docs/reference/context/assistant/thread_context/index.html b/docs/reference/context/assistant/thread_context/index.html index 7d1232b1d..f3767a1cf 100644 --- a/docs/reference/context/assistant/thread_context/index.html +++ b/docs/reference/context/assistant/thread_context/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.assistant.thread_context API documentation @@ -126,7 +126,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/assistant/thread_context_store/async_store.html b/docs/reference/context/assistant/thread_context_store/async_store.html index f5045739f..64f4e53ed 100644 --- a/docs/reference/context/assistant/thread_context_store/async_store.html +++ b/docs/reference/context/assistant/thread_context_store/async_store.html @@ -3,7 +3,7 @@ - + slack_bolt.context.assistant.thread_context_store.async_store API documentation @@ -124,7 +124,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/assistant/thread_context_store/default_async_store.html b/docs/reference/context/assistant/thread_context_store/default_async_store.html index 8344971de..f6cd66060 100644 --- a/docs/reference/context/assistant/thread_context_store/default_async_store.html +++ b/docs/reference/context/assistant/thread_context_store/default_async_store.html @@ -3,7 +3,7 @@ - + slack_bolt.context.assistant.thread_context_store.default_async_store API documentation @@ -190,7 +190,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/assistant/thread_context_store/default_store.html b/docs/reference/context/assistant/thread_context_store/default_store.html index d647b9c78..1594c5d38 100644 --- a/docs/reference/context/assistant/thread_context_store/default_store.html +++ b/docs/reference/context/assistant/thread_context_store/default_store.html @@ -3,7 +3,7 @@ - + slack_bolt.context.assistant.thread_context_store.default_store API documentation @@ -188,7 +188,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/assistant/thread_context_store/file/index.html b/docs/reference/context/assistant/thread_context_store/file/index.html index 4190a948f..4a5d944e1 100644 --- a/docs/reference/context/assistant/thread_context_store/file/index.html +++ b/docs/reference/context/assistant/thread_context_store/file/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.assistant.thread_context_store.file API documentation @@ -159,7 +159,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/assistant/thread_context_store/index.html b/docs/reference/context/assistant/thread_context_store/index.html index 400b3c37a..3083275d9 100644 --- a/docs/reference/context/assistant/thread_context_store/index.html +++ b/docs/reference/context/assistant/thread_context_store/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.assistant.thread_context_store API documentation @@ -92,7 +92,7 @@

      Sub-modules

      diff --git a/docs/reference/context/assistant/thread_context_store/store.html b/docs/reference/context/assistant/thread_context_store/store.html index fde47afc9..a0a177b09 100644 --- a/docs/reference/context/assistant/thread_context_store/store.html +++ b/docs/reference/context/assistant/thread_context_store/store.html @@ -3,7 +3,7 @@ - + slack_bolt.context.assistant.thread_context_store.store API documentation @@ -125,7 +125,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/async_context.html b/docs/reference/context/async_context.html index 76ac8c5de..9ce4ebd9e 100644 --- a/docs/reference/context/async_context.html +++ b/docs/reference/context/async_context.html @@ -3,7 +3,7 @@ - + slack_bolt.context.async_context API documentation @@ -706,7 +706,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/base_context.html b/docs/reference/context/base_context.html index 54617176b..4a177f8dc 100644 --- a/docs/reference/context/base_context.html +++ b/docs/reference/context/base_context.html @@ -3,7 +3,7 @@ - + slack_bolt.context.base_context API documentation @@ -640,7 +640,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/complete/async_complete.html b/docs/reference/context/complete/async_complete.html index e6ce03d74..36cf1f92f 100644 --- a/docs/reference/context/complete/async_complete.html +++ b/docs/reference/context/complete/async_complete.html @@ -3,7 +3,7 @@ - + slack_bolt.context.complete.async_complete API documentation @@ -127,7 +127,7 @@

      diff --git a/docs/reference/context/complete/complete.html b/docs/reference/context/complete/complete.html index ef6c6c78f..b1f01ea1a 100644 --- a/docs/reference/context/complete/complete.html +++ b/docs/reference/context/complete/complete.html @@ -3,7 +3,7 @@ - + slack_bolt.context.complete.complete API documentation @@ -125,7 +125,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/complete/index.html b/docs/reference/context/complete/index.html index f476fa258..7665622b6 100644 --- a/docs/reference/context/complete/index.html +++ b/docs/reference/context/complete/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.complete API documentation @@ -142,7 +142,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/context.html b/docs/reference/context/context.html index c1ae2789e..615432502 100644 --- a/docs/reference/context/context.html +++ b/docs/reference/context/context.html @@ -3,7 +3,7 @@ - + slack_bolt.context.context API documentation @@ -708,7 +708,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/fail/async_fail.html b/docs/reference/context/fail/async_fail.html index 91497eff9..6b3e4f1df 100644 --- a/docs/reference/context/fail/async_fail.html +++ b/docs/reference/context/fail/async_fail.html @@ -3,7 +3,7 @@ - + slack_bolt.context.fail.async_fail API documentation @@ -125,7 +125,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/fail/fail.html b/docs/reference/context/fail/fail.html index 20d44d1d5..0152561d8 100644 --- a/docs/reference/context/fail/fail.html +++ b/docs/reference/context/fail/fail.html @@ -3,7 +3,7 @@ - + slack_bolt.context.fail.fail API documentation @@ -125,7 +125,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/fail/index.html b/docs/reference/context/fail/index.html index 2b14ac772..eb2653106 100644 --- a/docs/reference/context/fail/index.html +++ b/docs/reference/context/fail/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.fail API documentation @@ -142,7 +142,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/get_thread_context/async_get_thread_context.html b/docs/reference/context/get_thread_context/async_get_thread_context.html index 66500752e..1c3fc4d6c 100644 --- a/docs/reference/context/get_thread_context/async_get_thread_context.html +++ b/docs/reference/context/get_thread_context/async_get_thread_context.html @@ -3,7 +3,7 @@ - + slack_bolt.context.get_thread_context.async_get_thread_context API documentation @@ -154,7 +154,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/get_thread_context/get_thread_context.html b/docs/reference/context/get_thread_context/get_thread_context.html index a6777da30..4ac274368 100644 --- a/docs/reference/context/get_thread_context/get_thread_context.html +++ b/docs/reference/context/get_thread_context/get_thread_context.html @@ -3,7 +3,7 @@ - + slack_bolt.context.get_thread_context.get_thread_context API documentation @@ -154,7 +154,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/get_thread_context/index.html b/docs/reference/context/get_thread_context/index.html index ffd095911..13dcd1388 100644 --- a/docs/reference/context/get_thread_context/index.html +++ b/docs/reference/context/get_thread_context/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.get_thread_context API documentation @@ -171,7 +171,7 @@

      diff --git a/docs/reference/context/index.html b/docs/reference/context/index.html index 4d3f472cc..65cb8054c 100644 --- a/docs/reference/context/index.html +++ b/docs/reference/context/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context API documentation @@ -790,7 +790,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/respond/async_respond.html b/docs/reference/context/respond/async_respond.html index 148e1607e..ed071afaf 100644 --- a/docs/reference/context/respond/async_respond.html +++ b/docs/reference/context/respond/async_respond.html @@ -3,7 +3,7 @@ - + slack_bolt.context.respond.async_respond API documentation @@ -160,7 +160,7 @@

      diff --git a/docs/reference/context/respond/index.html b/docs/reference/context/respond/index.html index 94693dccf..8c116c956 100644 --- a/docs/reference/context/respond/index.html +++ b/docs/reference/context/respond/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.respond API documentation @@ -182,7 +182,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/respond/internals.html b/docs/reference/context/respond/internals.html index 295a793a3..e61988ef6 100644 --- a/docs/reference/context/respond/internals.html +++ b/docs/reference/context/respond/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.context.respond.internals API documentation @@ -60,7 +60,7 @@

      Module slack_bolt.context.respond.internals

      diff --git a/docs/reference/context/respond/respond.html b/docs/reference/context/respond/respond.html index 5fb010d20..af2271eb6 100644 --- a/docs/reference/context/respond/respond.html +++ b/docs/reference/context/respond/respond.html @@ -3,7 +3,7 @@ - + slack_bolt.context.respond.respond API documentation @@ -160,7 +160,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/save_thread_context/async_save_thread_context.html b/docs/reference/context/save_thread_context/async_save_thread_context.html index 796970dd7..f57291c3c 100644 --- a/docs/reference/context/save_thread_context/async_save_thread_context.html +++ b/docs/reference/context/save_thread_context/async_save_thread_context.html @@ -3,7 +3,7 @@ - + slack_bolt.context.save_thread_context.async_save_thread_context API documentation @@ -123,7 +123,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/save_thread_context/index.html b/docs/reference/context/save_thread_context/index.html index 8b974a213..01f63ecd8 100644 --- a/docs/reference/context/save_thread_context/index.html +++ b/docs/reference/context/save_thread_context/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.save_thread_context API documentation @@ -140,7 +140,7 @@

      diff --git a/docs/reference/context/save_thread_context/save_thread_context.html b/docs/reference/context/save_thread_context/save_thread_context.html index 17c147505..328441034 100644 --- a/docs/reference/context/save_thread_context/save_thread_context.html +++ b/docs/reference/context/save_thread_context/save_thread_context.html @@ -3,7 +3,7 @@ - + slack_bolt.context.save_thread_context.save_thread_context API documentation @@ -123,7 +123,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/say/async_say.html b/docs/reference/context/say/async_say.html index 78d0b83a6..8547a1188 100644 --- a/docs/reference/context/say/async_say.html +++ b/docs/reference/context/say/async_say.html @@ -3,7 +3,7 @@ - + slack_bolt.context.say.async_say API documentation @@ -183,7 +183,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/say/index.html b/docs/reference/context/say/index.html index 5e2897f38..7a5850760 100644 --- a/docs/reference/context/say/index.html +++ b/docs/reference/context/say/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.say API documentation @@ -214,7 +214,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/say/internals.html b/docs/reference/context/say/internals.html index ac349a4b5..861065203 100644 --- a/docs/reference/context/say/internals.html +++ b/docs/reference/context/say/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.context.say.internals API documentation @@ -60,7 +60,7 @@

      Module slack_bolt.context.say.internals

      diff --git a/docs/reference/context/say/say.html b/docs/reference/context/say/say.html index 20ad41c0e..5db4f24ba 100644 --- a/docs/reference/context/say/say.html +++ b/docs/reference/context/say/say.html @@ -3,7 +3,7 @@ - + slack_bolt.context.say.say API documentation @@ -192,7 +192,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/set_status/async_set_status.html b/docs/reference/context/set_status/async_set_status.html index d22fb3aa8..6a15d70ae 100644 --- a/docs/reference/context/set_status/async_set_status.html +++ b/docs/reference/context/set_status/async_set_status.html @@ -3,7 +3,7 @@ - + slack_bolt.context.set_status.async_set_status API documentation @@ -123,7 +123,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/set_status/index.html b/docs/reference/context/set_status/index.html index 5a2e8be48..9e53da9a5 100644 --- a/docs/reference/context/set_status/index.html +++ b/docs/reference/context/set_status/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.set_status API documentation @@ -140,7 +140,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/set_status/set_status.html b/docs/reference/context/set_status/set_status.html index 337fbf576..0ec8df5da 100644 --- a/docs/reference/context/set_status/set_status.html +++ b/docs/reference/context/set_status/set_status.html @@ -3,7 +3,7 @@ - + slack_bolt.context.set_status.set_status API documentation @@ -123,7 +123,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html b/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html index ee7458fbc..449a72117 100644 --- a/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html +++ b/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html @@ -3,7 +3,7 @@ - + slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts API documentation @@ -135,7 +135,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/set_suggested_prompts/index.html b/docs/reference/context/set_suggested_prompts/index.html index f3084288a..ee5371cea 100644 --- a/docs/reference/context/set_suggested_prompts/index.html +++ b/docs/reference/context/set_suggested_prompts/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.set_suggested_prompts API documentation @@ -152,7 +152,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html b/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html index bdb31a3ca..133d3a55a 100644 --- a/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html +++ b/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html @@ -3,7 +3,7 @@ - + slack_bolt.context.set_suggested_prompts.set_suggested_prompts API documentation @@ -135,7 +135,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/set_title/async_set_title.html b/docs/reference/context/set_title/async_set_title.html index 9c195664c..e7db1ca1c 100644 --- a/docs/reference/context/set_title/async_set_title.html +++ b/docs/reference/context/set_title/async_set_title.html @@ -3,7 +3,7 @@ - + slack_bolt.context.set_title.async_set_title API documentation @@ -123,7 +123,7 @@

      diff --git a/docs/reference/context/set_title/index.html b/docs/reference/context/set_title/index.html index 4c88c8539..7ae070fe8 100644 --- a/docs/reference/context/set_title/index.html +++ b/docs/reference/context/set_title/index.html @@ -3,7 +3,7 @@ - + slack_bolt.context.set_title API documentation @@ -140,7 +140,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/context/set_title/set_title.html b/docs/reference/context/set_title/set_title.html index 59a4498bf..cd4d1e27e 100644 --- a/docs/reference/context/set_title/set_title.html +++ b/docs/reference/context/set_title/set_title.html @@ -3,7 +3,7 @@ - + slack_bolt.context.set_title.set_title API documentation @@ -123,7 +123,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/error/index.html b/docs/reference/error/index.html index 8e578eb61..f57d690e9 100644 --- a/docs/reference/error/index.html +++ b/docs/reference/error/index.html @@ -3,7 +3,7 @@ - + slack_bolt.error API documentation @@ -160,7 +160,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/index.html b/docs/reference/index.html index 6aae1d55a..430e36813 100644 --- a/docs/reference/index.html +++ b/docs/reference/index.html @@ -3,7 +3,7 @@ - + slack_bolt API documentation @@ -815,7 +815,7 @@

      Class variables

      """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new step from app listener. @@ -833,7 +833,7 @@

      Class variables

      # Pass Step to set up listeners app.step(ws) - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. + Refer to https://api.slack.com/workflows/steps for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -850,7 +850,7 @@

      Class variables

      warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps" + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" ), category=DeprecationWarning, ) @@ -927,7 +927,7 @@

      Class variables

      # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. + Refer to https://api.slack.com/apis/connections/events-api for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -965,7 +965,7 @@

      Class variables

      # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://docs.slack.dev/reference/events/message for details of `message` events. + Refer to https://api.slack.com/events/message for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1011,6 +1011,7 @@

      Class variables

      matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, auto_acknowledge: bool = True, + ack_timeout: int = 3, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. @@ -1039,13 +1040,17 @@

      Class variables

      Only when all the middleware call `next()` method, the listener function can be invoked. """ + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) + matchers = list(matchers) if matchers else [] middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger) - return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__ @@ -1071,7 +1076,7 @@

      Class variables

      # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands. + Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1118,7 +1123,7 @@

      Class variables

      # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts. + Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1186,9 +1191,9 @@

      Class variables

      # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for actions in `blocks`. - * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for actions in `attachments`. - * Refer to https://docs.slack.dev/legacy/legacy-dialogs for actions in dialogs. + * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. + * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. + * Refer to https://api.slack.com/dialogs for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1214,7 +1219,7 @@

      Class variables

      middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details. + Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ def __call__(*args, **kwargs): @@ -1231,7 +1236,7 @@

      Class variables

      middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. - Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.""" + Refer to https://api.slack.com/legacy/message-buttons for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1247,7 +1252,7 @@

      Class variables

      middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1263,7 +1268,7 @@

      Class variables

      middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1304,7 +1309,7 @@

      Class variables

      # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. + Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1330,7 +1335,7 @@

      Class variables

      middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1346,7 +1351,7 @@

      Class variables

      middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1387,7 +1392,8 @@

      Class variables

      Refer to the following documents for details: - * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select + * https://api.slack.com/reference/block-kit/block-elements#external_select + * https://api.slack.com/reference/block-kit/block-elements#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1427,7 +1433,7 @@

      Class variables

      middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1520,6 +1526,7 @@

      Class variables

      matchers: Optional[Sequence[Callable[..., bool]]], middleware: Optional[Sequence[Union[Callable, Middleware]]], auto_acknowledgement: bool = False, + ack_timeout: int = 3, ) -> Optional[Callable[..., Optional[BoltResponse]]]: value_to_return = None if not isinstance(functions, list): @@ -1546,10 +1553,11 @@

      Class variables

      CustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, + lazy_functions=functions, # type:ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + ack_timeout=ack_timeout, base_logger=self._base_logger, ) ) @@ -1769,9 +1777,9 @@

      Methods

      # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for actions in `blocks`. - * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for actions in `attachments`. - * Refer to https://docs.slack.dev/legacy/legacy-dialogs for actions in dialogs. + * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. + * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. + * Refer to https://api.slack.com/dialogs for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1800,9 +1808,9 @@

      Methods

      app.action("approve_button")(update_message)

      To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

      Args

      @@ -1845,7 +1853,7 @@

      Args

      middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. - Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.""" + Refer to https://api.slack.com/legacy/message-buttons for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1855,7 +1863,7 @@

      Args

      return __call__

      Registers a new interactive_message action listener. -Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons for details.

      +Refer to https://api.slack.com/legacy/message-buttons for details.

    def block_action(self,
    constraints: str | Pattern | Dict[str, str | Pattern],
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1872,7 +1880,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details. + Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. """ def __call__(*args, **kwargs): @@ -1883,7 +1891,7 @@

    Args

    return __call__

    Registers a new block_actions action listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.

    def block_suggestion(self,
    action_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1937,7 +1945,7 @@

    Args

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands. + Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1968,7 +1976,7 @@

    Args

    # Pass a function to this method app.command("/echo")(repeat_text)
    -

    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details of Slash Commands.

    +

    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2031,7 +2039,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2041,7 +2049,7 @@

    Args

    return __call__

    Registers a new dialog_cancellation listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def dialog_submission(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -2058,7 +2066,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2068,7 +2076,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def dialog_suggestion(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -2085,7 +2093,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.""" + Refer to https://api.slack.com/dialogs for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2095,7 +2103,7 @@

    Args

    return __call__

    Registers a new dialog_suggestion listener. -Refer to https://docs.slack.dev/legacy/legacy-dialogs for details.

    +Refer to https://api.slack.com/dialogs for details.

    def dispatch(self,
    req: BoltRequest) ‑> BoltResponse
    @@ -2321,7 +2329,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. + Refer to https://api.slack.com/apis/connections/events-api for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2353,7 +2361,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction)
    -

    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

    +

    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2369,7 +2377,7 @@

    Args

    -def function(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None,
    auto_acknowledge: bool = True) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    +def function(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None,
    auto_acknowledge: bool = True,
    ack_timeout: int = 3) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -2382,6 +2390,7 @@

    Args

    matchers: Optional[Sequence[Callable[..., bool]]] = None, middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, auto_acknowledge: bool = True, + ack_timeout: int = 3, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. @@ -2410,13 +2419,17 @@

    Args

    Only when all the middleware call `next()` method, the listener function can be invoked. """ + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) + matchers = list(matchers) if matchers else [] middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger) - return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__
    @@ -2500,7 +2513,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://docs.slack.dev/reference/events/message for details of `message` events. + Refer to https://api.slack.com/events/message for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2551,7 +2564,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) -

    Refer to https://docs.slack.dev/reference/events/message for details of message events.

    +

    Refer to https://api.slack.com/events/message for details of message events.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2694,7 +2707,8 @@

    Args

    Refer to the following documents for details: - * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select + * https://api.slack.com/reference/block-kit/block-elements#external_select + * https://api.slack.com/reference/block-kit/block-elements#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2734,7 +2748,8 @@

    Args

    Refer to the following documents for details:

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2780,7 +2795,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts. + Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2817,7 +2832,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) -

    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts for details about Shortcuts.

    +

    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2903,7 +2918,7 @@

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new step from app listener. @@ -2921,7 +2936,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. + Refer to https://api.slack.com/workflows/steps for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2938,7 +2953,7 @@

    Args

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps" + "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" ), category=DeprecationWarning, ) @@ -2960,7 +2975,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Registers a new step from app listener.

    Unlike others, this method doesn't behave as a decorator. If you want to register a step from app by a decorator, use WorkflowStepBuilder's methods.

    @@ -2975,7 +2990,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) -

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

    +

    Refer to https://api.slack.com/workflows/steps for details of steps from apps.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    For further information about WorkflowStep specific function arguments such as configure, update, complete, and fail, @@ -3046,7 +3061,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. + Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -3087,7 +3102,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) -

    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

    +

    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -3116,7 +3131,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3126,7 +3141,7 @@

    Args

    return __call__

    Registers a new view_closed listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_closed for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.

    def view_submission(self,
    constraints: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -3143,7 +3158,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.""" + Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3153,7 +3168,7 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload#view_submission for details.

    +Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.

    @@ -5307,6 +5322,7 @@

    Methods

    ack_function: Callable[..., BoltResponse] lazy_functions: Sequence[Callable[..., None]] auto_acknowledgement: bool + ack_timeout: int = 3 def matches( self, @@ -5372,6 +5388,10 @@

    Class variables

    The type of the None singleton.

    +
    var ack_timeout : int
    +
    +

    The type of the None singleton.

    +
    var auto_acknowledgement : bool

    The type of the None singleton.

    @@ -6115,6 +6135,7 @@

    Listener

    • ack_function
    • +
    • ack_timeout
    • auto_acknowledgement
    • lazy_functions
    • matchers
    • @@ -6180,7 +6201,7 @@

      SetTitle diff --git a/docs/reference/kwargs_injection/args.html b/docs/reference/kwargs_injection/args.html index 9d2eb2c40..4d03687d1 100644 --- a/docs/reference/kwargs_injection/args.html +++ b/docs/reference/kwargs_injection/args.html @@ -3,7 +3,7 @@ - + slack_bolt.kwargs_injection.args API documentation @@ -404,7 +404,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/kwargs_injection/async_args.html b/docs/reference/kwargs_injection/async_args.html index 65a14c2d1..959f35a43 100644 --- a/docs/reference/kwargs_injection/async_args.html +++ b/docs/reference/kwargs_injection/async_args.html @@ -3,7 +3,7 @@ - + slack_bolt.kwargs_injection.async_args API documentation @@ -401,7 +401,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/kwargs_injection/async_utils.html b/docs/reference/kwargs_injection/async_utils.html index 6f433ddca..80952518d 100644 --- a/docs/reference/kwargs_injection/async_utils.html +++ b/docs/reference/kwargs_injection/async_utils.html @@ -3,7 +3,7 @@ - + slack_bolt.kwargs_injection.async_utils API documentation @@ -171,7 +171,7 @@

      Functions

      diff --git a/docs/reference/kwargs_injection/index.html b/docs/reference/kwargs_injection/index.html index 4132bbaba..de7ef4a0a 100644 --- a/docs/reference/kwargs_injection/index.html +++ b/docs/reference/kwargs_injection/index.html @@ -3,7 +3,7 @@ - + slack_bolt.kwargs_injection API documentation @@ -544,7 +544,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/kwargs_injection/utils.html b/docs/reference/kwargs_injection/utils.html index a589350c9..2e6ecd001 100644 --- a/docs/reference/kwargs_injection/utils.html +++ b/docs/reference/kwargs_injection/utils.html @@ -3,7 +3,7 @@ - + slack_bolt.kwargs_injection.utils API documentation @@ -170,7 +170,7 @@

      Functions

      diff --git a/docs/reference/lazy_listener/async_internals.html b/docs/reference/lazy_listener/async_internals.html index 19becac19..9d86a02e5 100644 --- a/docs/reference/lazy_listener/async_internals.html +++ b/docs/reference/lazy_listener/async_internals.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener.async_internals API documentation @@ -102,7 +102,7 @@

      Functions

      diff --git a/docs/reference/lazy_listener/async_runner.html b/docs/reference/lazy_listener/async_runner.html index e58b0a044..701f1640a 100644 --- a/docs/reference/lazy_listener/async_runner.html +++ b/docs/reference/lazy_listener/async_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener.async_runner API documentation @@ -184,7 +184,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/lazy_listener/asyncio_runner.html b/docs/reference/lazy_listener/asyncio_runner.html index d05a4c9ac..2fdcf8ffe 100644 --- a/docs/reference/lazy_listener/asyncio_runner.html +++ b/docs/reference/lazy_listener/asyncio_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener.asyncio_runner API documentation @@ -113,7 +113,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/lazy_listener/index.html b/docs/reference/lazy_listener/index.html index 374164af8..c2eb1c9b0 100644 --- a/docs/reference/lazy_listener/index.html +++ b/docs/reference/lazy_listener/index.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener API documentation @@ -295,7 +295,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/lazy_listener/internals.html b/docs/reference/lazy_listener/internals.html index 96c04a56c..1801abafd 100644 --- a/docs/reference/lazy_listener/internals.html +++ b/docs/reference/lazy_listener/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener.internals API documentation @@ -102,7 +102,7 @@

      Functions

      diff --git a/docs/reference/lazy_listener/runner.html b/docs/reference/lazy_listener/runner.html index 56216c9c8..ff4f449a0 100644 --- a/docs/reference/lazy_listener/runner.html +++ b/docs/reference/lazy_listener/runner.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener.runner API documentation @@ -185,7 +185,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/lazy_listener/thread_runner.html b/docs/reference/lazy_listener/thread_runner.html index 19e6ff29e..b4ca0711a 100644 --- a/docs/reference/lazy_listener/thread_runner.html +++ b/docs/reference/lazy_listener/thread_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.lazy_listener.thread_runner API documentation @@ -119,7 +119,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/listener/async_builtins.html b/docs/reference/listener/async_builtins.html index 61e6fcd9f..015dd94b3 100644 --- a/docs/reference/listener/async_builtins.html +++ b/docs/reference/listener/async_builtins.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.async_builtins API documentation @@ -168,7 +168,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/listener/async_listener.html b/docs/reference/listener/async_listener.html index 52da6d342..a3d1a7fef 100644 --- a/docs/reference/listener/async_listener.html +++ b/docs/reference/listener/async_listener.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.async_listener API documentation @@ -48,7 +48,7 @@

      Classes

      class AsyncCustomListener -(*,
      app_name: str,
      ack_function: Callable[..., Awaitable[BoltResponse | None]],
      lazy_functions: Sequence[Callable[..., Awaitable[None]]],
      matchers: Sequence[AsyncListenerMatcher],
      middleware: Sequence[AsyncMiddleware],
      auto_acknowledgement: bool = False,
      base_logger: logging.Logger | None = None)
      +(*,
      app_name: str,
      ack_function: Callable[..., Awaitable[BoltResponse | None]],
      lazy_functions: Sequence[Callable[..., Awaitable[None]]],
      matchers: Sequence[AsyncListenerMatcher],
      middleware: Sequence[AsyncMiddleware],
      auto_acknowledgement: bool = False,
      ack_timeout: int = 3,
      base_logger: logging.Logger | None = None)
      @@ -62,6 +62,7 @@

      Classes

      matchers: Sequence[AsyncListenerMatcher] middleware: Sequence[AsyncMiddleware] auto_acknowledgement: bool + ack_timeout: int arg_names: MutableSequence[str] logger: Logger @@ -74,6 +75,7 @@

      Classes

      matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False, + ack_timeout: int = 3, base_logger: Optional[Logger] = None, ): self.app_name = app_name @@ -82,6 +84,7 @@

      Classes

      self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement + self.ack_timeout = ack_timeout self.arg_names = get_arg_names_of_callable(ack_function) self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) @@ -112,6 +115,10 @@

      Class variables

      The type of the None singleton.

      +
      var ack_timeout : int
      +
      +

      The type of the None singleton.

      +
      var app_name : str

      The type of the None singleton.

      @@ -182,7 +189,7 @@

      Returns

      class cls -(*,
      app_name: str,
      ack_function: Callable[..., Awaitable[BoltResponse | None]],
      lazy_functions: Sequence[Callable[..., Awaitable[None]]],
      matchers: Sequence[AsyncListenerMatcher],
      middleware: Sequence[AsyncMiddleware],
      auto_acknowledgement: bool = False,
      base_logger: logging.Logger | None = None)
      +(*,
      app_name: str,
      ack_function: Callable[..., Awaitable[BoltResponse | None]],
      lazy_functions: Sequence[Callable[..., Awaitable[None]]],
      matchers: Sequence[AsyncListenerMatcher],
      middleware: Sequence[AsyncMiddleware],
      auto_acknowledgement: bool = False,
      ack_timeout: int = 3,
      base_logger: logging.Logger | None = None)
      @@ -196,6 +203,7 @@

      Returns

      matchers: Sequence[AsyncListenerMatcher] middleware: Sequence[AsyncMiddleware] auto_acknowledgement: bool + ack_timeout: int arg_names: MutableSequence[str] logger: Logger @@ -208,6 +216,7 @@

      Returns

      matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False, + ack_timeout: int = 3, base_logger: Optional[Logger] = None, ): self.app_name = app_name @@ -216,6 +225,7 @@

      Returns

      self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement + self.ack_timeout = ack_timeout self.arg_names = get_arg_names_of_callable(ack_function) self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) @@ -260,6 +270,7 @@

      Inherited members

    • AsyncListener:
      • ack_function
      • +
      • ack_timeout
      • auto_acknowledgement
      • lazy_functions
      • matchers
      • @@ -284,6 +295,7 @@

        Inherited members

        ack_function: Callable[..., Awaitable[BoltResponse]] lazy_functions: Sequence[Callable[..., Awaitable[None]]] auto_acknowledgement: bool + ack_timeout: int async def async_matches( self, @@ -349,6 +361,10 @@

        Class variables

        The type of the None singleton.

        +
        var ack_timeout : int
        +
        +

        The type of the None singleton.

        +
        var auto_acknowledgement : bool

        The type of the None singleton.

        @@ -490,6 +506,7 @@

        Returns

        AsyncCustomListener

        • ack_function
        • +
        • ack_timeout
        • app_name
        • arg_names
        • auto_acknowledgement
        • @@ -512,6 +529,7 @@

          AsyncListener

          • ack_function
          • +
          • ack_timeout
          • async_matches
          • auto_acknowledgement
          • lazy_functions
          • @@ -527,7 +545,7 @@

            -

            Generated by pdoc 0.11.5.

            +

            Generated by pdoc 0.11.6.

            diff --git a/docs/reference/listener/async_listener_completion_handler.html b/docs/reference/listener/async_listener_completion_handler.html index 2a05e0213..6cde66b93 100644 --- a/docs/reference/listener/async_listener_completion_handler.html +++ b/docs/reference/listener/async_listener_completion_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.async_listener_completion_handler API documentation @@ -220,7 +220,7 @@

            -

            Generated by pdoc 0.11.5.

            +

            Generated by pdoc 0.11.6.

            diff --git a/docs/reference/listener/async_listener_error_handler.html b/docs/reference/listener/async_listener_error_handler.html index 9600a2cfd..1f3789c40 100644 --- a/docs/reference/listener/async_listener_error_handler.html +++ b/docs/reference/listener/async_listener_error_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.async_listener_error_handler API documentation @@ -234,7 +234,7 @@

            -

            Generated by pdoc 0.11.5.

            +

            Generated by pdoc 0.11.6.

            diff --git a/docs/reference/listener/async_listener_start_handler.html b/docs/reference/listener/async_listener_start_handler.html index 23ada5e08..80b25eb29 100644 --- a/docs/reference/listener/async_listener_start_handler.html +++ b/docs/reference/listener/async_listener_start_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.async_listener_start_handler API documentation @@ -220,7 +220,7 @@

            -

            Generated by pdoc 0.11.5.

            +

            Generated by pdoc 0.11.6.

            diff --git a/docs/reference/listener/asyncio_runner.html b/docs/reference/listener/asyncio_runner.html index 8262667f0..4d71a88a7 100644 --- a/docs/reference/listener/asyncio_runner.html +++ b/docs/reference/listener/asyncio_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.asyncio_runner API documentation @@ -180,7 +180,7 @@

            Classes

            self._start_lazy_function(lazy_func, request) # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= 3: + while ack.response is None and time.time() - starting_time <= listener.ack_timeout: await asyncio.sleep(0.01) if response is None and ack.response is None: @@ -359,7 +359,7 @@

            Methods

            self._start_lazy_function(lazy_func, request) # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= 3: + while ack.response is None and time.time() - starting_time <= listener.ack_timeout: await asyncio.sleep(0.01) if response is None and ack.response is None: @@ -414,7 +414,7 @@

            diff --git a/docs/reference/listener/builtins.html b/docs/reference/listener/builtins.html index 75f8ca620..5f3759658 100644 --- a/docs/reference/listener/builtins.html +++ b/docs/reference/listener/builtins.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.builtins API documentation @@ -168,7 +168,7 @@

            diff --git a/docs/reference/listener/custom_listener.html b/docs/reference/listener/custom_listener.html index 1cd261379..1f18502f2 100644 --- a/docs/reference/listener/custom_listener.html +++ b/docs/reference/listener/custom_listener.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.custom_listener API documentation @@ -48,7 +48,7 @@

            Classes

            class CustomListener -(*,
            app_name: str,
            ack_function: Callable[..., BoltResponse | None],
            lazy_functions: Sequence[Callable[..., None]],
            matchers: Sequence[ListenerMatcher],
            middleware: Sequence[Middleware],
            auto_acknowledgement: bool = False,
            base_logger: logging.Logger | None = None)
            +(*,
            app_name: str,
            ack_function: Callable[..., BoltResponse | None],
            lazy_functions: Sequence[Callable[..., None]],
            matchers: Sequence[ListenerMatcher],
            middleware: Sequence[Middleware],
            auto_acknowledgement: bool = False,
            ack_timeout: int = 3,
            base_logger: logging.Logger | None = None)
            @@ -62,6 +62,7 @@

            Classes

            matchers: Sequence[ListenerMatcher] middleware: Sequence[Middleware] auto_acknowledgement: bool + ack_timeout: int = 3 arg_names: MutableSequence[str] logger: Logger @@ -74,6 +75,7 @@

            Classes

            matchers: Sequence[ListenerMatcher], middleware: Sequence[Middleware], auto_acknowledgement: bool = False, + ack_timeout: int = 3, base_logger: Optional[Logger] = None, ): self.app_name = app_name @@ -82,6 +84,7 @@

            Classes

            self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement + self.ack_timeout = ack_timeout self.arg_names = get_arg_names_of_callable(ack_function) self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) @@ -126,6 +129,7 @@

            Inherited members

          • Listener:
            • ack_function
            • +
            • ack_timeout
            • auto_acknowledgement
            • lazy_functions
            • matchers
            • @@ -165,7 +169,7 @@

              -

              Generated by pdoc 0.11.5.

              +

              Generated by pdoc 0.11.6.

              diff --git a/docs/reference/listener/index.html b/docs/reference/listener/index.html index 677147e21..f31264cac 100644 --- a/docs/reference/listener/index.html +++ b/docs/reference/listener/index.html @@ -3,7 +3,7 @@ - + slack_bolt.listener API documentation @@ -107,7 +107,7 @@

              Classes

              class CustomListener -(*,
              app_name: str,
              ack_function: Callable[..., BoltResponse | None],
              lazy_functions: Sequence[Callable[..., None]],
              matchers: Sequence[ListenerMatcher],
              middleware: Sequence[Middleware],
              auto_acknowledgement: bool = False,
              base_logger: logging.Logger | None = None)
              +(*,
              app_name: str,
              ack_function: Callable[..., BoltResponse | None],
              lazy_functions: Sequence[Callable[..., None]],
              matchers: Sequence[ListenerMatcher],
              middleware: Sequence[Middleware],
              auto_acknowledgement: bool = False,
              ack_timeout: int = 3,
              base_logger: logging.Logger | None = None)
              @@ -121,6 +121,7 @@

              Classes

              matchers: Sequence[ListenerMatcher] middleware: Sequence[Middleware] auto_acknowledgement: bool + ack_timeout: int = 3 arg_names: MutableSequence[str] logger: Logger @@ -133,6 +134,7 @@

              Classes

              matchers: Sequence[ListenerMatcher], middleware: Sequence[Middleware], auto_acknowledgement: bool = False, + ack_timeout: int = 3, base_logger: Optional[Logger] = None, ): self.app_name = app_name @@ -141,6 +143,7 @@

              Classes

              self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement + self.ack_timeout = ack_timeout self.arg_names = get_arg_names_of_callable(ack_function) self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) @@ -185,6 +188,7 @@

              Inherited members

            • Listener:
              • ack_function
              • +
              • ack_timeout
              • auto_acknowledgement
              • lazy_functions
              • matchers
              • @@ -209,6 +213,7 @@

                Inherited members

                ack_function: Callable[..., BoltResponse] lazy_functions: Sequence[Callable[..., None]] auto_acknowledgement: bool + ack_timeout: int = 3 def matches( self, @@ -274,6 +279,10 @@

                Class variables

                The type of the None singleton.

                +
                var ack_timeout : int
                +
                +

                The type of the None singleton.

                +
                var auto_acknowledgement : bool

                The type of the None singleton.

                @@ -440,6 +449,7 @@

                Listener

                • ack_function
                • +
                • ack_timeout
                • auto_acknowledgement
                • lazy_functions
                • matchers
                • @@ -455,7 +465,7 @@

                  -

                  Generated by pdoc 0.11.5.

                  +

                  Generated by pdoc 0.11.6.

                  diff --git a/docs/reference/listener/listener.html b/docs/reference/listener/listener.html index 743fb2ceb..034dbe67f 100644 --- a/docs/reference/listener/listener.html +++ b/docs/reference/listener/listener.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.listener API documentation @@ -60,6 +60,7 @@

                  Classes

                  ack_function: Callable[..., BoltResponse] lazy_functions: Sequence[Callable[..., None]] auto_acknowledgement: bool + ack_timeout: int = 3 def matches( self, @@ -125,6 +126,10 @@

                  Class variables

                  The type of the None singleton.

                  +
                  var ack_timeout : int
                  +
                  +

                  The type of the None singleton.

                  +
                  var auto_acknowledgement : bool

                  The type of the None singleton.

                  @@ -266,6 +271,7 @@

                  Returns

                  Listener

                  • ack_function
                  • +
                  • ack_timeout
                  • auto_acknowledgement
                  • lazy_functions
                  • matchers
                  • @@ -281,7 +287,7 @@

                    -

                    Generated by pdoc 0.11.5.

                    +

                    Generated by pdoc 0.11.6.

                    diff --git a/docs/reference/listener/listener_completion_handler.html b/docs/reference/listener/listener_completion_handler.html index 35b2fe8cd..42b1b5413 100644 --- a/docs/reference/listener/listener_completion_handler.html +++ b/docs/reference/listener/listener_completion_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.listener_completion_handler API documentation @@ -221,7 +221,7 @@

                    -

                    Generated by pdoc 0.11.5.

                    +

                    Generated by pdoc 0.11.6.

                    diff --git a/docs/reference/listener/listener_error_handler.html b/docs/reference/listener/listener_error_handler.html index fc49894d2..c9f7c2ccd 100644 --- a/docs/reference/listener/listener_error_handler.html +++ b/docs/reference/listener/listener_error_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.listener_error_handler API documentation @@ -234,7 +234,7 @@

                    -

                    Generated by pdoc 0.11.5.

                    +

                    Generated by pdoc 0.11.6.

                    diff --git a/docs/reference/listener/listener_start_handler.html b/docs/reference/listener/listener_start_handler.html index 63cb98b91..d60c1b9dc 100644 --- a/docs/reference/listener/listener_start_handler.html +++ b/docs/reference/listener/listener_start_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.listener_start_handler API documentation @@ -232,7 +232,7 @@

                    -

                    Generated by pdoc 0.11.5.

                    +

                    Generated by pdoc 0.11.6.

                    diff --git a/docs/reference/listener/thread_runner.html b/docs/reference/listener/thread_runner.html index b6fafae99..5415f9ada 100644 --- a/docs/reference/listener/thread_runner.html +++ b/docs/reference/listener/thread_runner.html @@ -3,7 +3,7 @@ - + slack_bolt.listener.thread_runner API documentation @@ -148,7 +148,7 @@

                    Classes

                    if not request.lazy_only: # start the listener function asynchronously def run_ack_function_asynchronously(): - nonlocal ack, request, response + nonlocal response try: self.listener_start_handler.handle( request=request, @@ -197,7 +197,7 @@

                    Classes

                    self._start_lazy_function(lazy_func, request) # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= 3: + while ack.response is None and time.time() - starting_time <= listener.ack_timeout: time.sleep(0.01) if response is None and ack.response is None: @@ -346,7 +346,7 @@

                    Methods

                    if not request.lazy_only: # start the listener function asynchronously def run_ack_function_asynchronously(): - nonlocal ack, request, response + nonlocal response try: self.listener_start_handler.handle( request=request, @@ -395,7 +395,7 @@

                    Methods

                    self._start_lazy_function(lazy_func, request) # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= 3: + while ack.response is None and time.time() - starting_time <= listener.ack_timeout: time.sleep(0.01) if response is None and ack.response is None: @@ -451,7 +451,7 @@

                    diff --git a/docs/reference/listener_matcher/async_builtins.html b/docs/reference/listener_matcher/async_builtins.html index b99d07c82..0df1215de 100644 --- a/docs/reference/listener_matcher/async_builtins.html +++ b/docs/reference/listener_matcher/async_builtins.html @@ -3,7 +3,7 @@ - + slack_bolt.listener_matcher.async_builtins API documentation @@ -112,7 +112,7 @@

                    -

                    Generated by pdoc 0.11.5.

                    +

                    Generated by pdoc 0.11.6.

                    diff --git a/docs/reference/listener_matcher/async_listener_matcher.html b/docs/reference/listener_matcher/async_listener_matcher.html index bc8676302..1366da4e2 100644 --- a/docs/reference/listener_matcher/async_listener_matcher.html +++ b/docs/reference/listener_matcher/async_listener_matcher.html @@ -3,7 +3,7 @@ - + slack_bolt.listener_matcher.async_listener_matcher API documentation @@ -311,7 +311,7 @@

                    -

                    Generated by pdoc 0.11.5.

                    +

                    Generated by pdoc 0.11.6.

                    diff --git a/docs/reference/listener_matcher/builtins.html b/docs/reference/listener_matcher/builtins.html index 29af67f6c..d951deada 100644 --- a/docs/reference/listener_matcher/builtins.html +++ b/docs/reference/listener_matcher/builtins.html @@ -3,7 +3,7 @@ - + slack_bolt.listener_matcher.builtins API documentation @@ -80,7 +80,7 @@

                    Functions

                    return dialog_submission(constraints["callback_id"], asyncio) if action_type == "dialog_cancellation": return dialog_cancellation(constraints["callback_id"], asyncio) - # https://docs.slack.dev/legacy/legacy-steps-from-apps/ + # https://api.slack.com/workflows/steps if action_type == "workflow_step_edit": return workflow_step_edit(constraints["callback_id"], asyncio) @@ -692,7 +692,7 @@

                    diff --git a/docs/reference/listener_matcher/custom_listener_matcher.html b/docs/reference/listener_matcher/custom_listener_matcher.html index 8009e84a1..087d36907 100644 --- a/docs/reference/listener_matcher/custom_listener_matcher.html +++ b/docs/reference/listener_matcher/custom_listener_matcher.html @@ -3,7 +3,7 @@ - + slack_bolt.listener_matcher.custom_listener_matcher API documentation @@ -141,7 +141,7 @@

                    -

                    Generated by pdoc 0.11.5.

                    +

                    Generated by pdoc 0.11.6.

                    diff --git a/docs/reference/listener_matcher/index.html b/docs/reference/listener_matcher/index.html index 622a5e9d9..a93c86d98 100644 --- a/docs/reference/listener_matcher/index.html +++ b/docs/reference/listener_matcher/index.html @@ -3,7 +3,7 @@ - + slack_bolt.listener_matcher API documentation @@ -247,7 +247,7 @@

                    -

                    Generated by pdoc 0.11.5.

                    +

                    Generated by pdoc 0.11.6.

                    diff --git a/docs/reference/listener_matcher/listener_matcher.html b/docs/reference/listener_matcher/listener_matcher.html index a20816088..0618f7e4e 100644 --- a/docs/reference/listener_matcher/listener_matcher.html +++ b/docs/reference/listener_matcher/listener_matcher.html @@ -3,7 +3,7 @@ - + slack_bolt.listener_matcher.listener_matcher API documentation @@ -137,7 +137,7 @@

                    -

                    Generated by pdoc 0.11.5.

                    +

                    Generated by pdoc 0.11.6.

                    diff --git a/docs/reference/logger/index.html b/docs/reference/logger/index.html index c9defacdb..d0b2ef33f 100644 --- a/docs/reference/logger/index.html +++ b/docs/reference/logger/index.html @@ -3,7 +3,7 @@ - + slack_bolt.logger API documentation @@ -121,7 +121,7 @@

                    Functions

                    diff --git a/docs/reference/logger/messages.html b/docs/reference/logger/messages.html index c3ff45156..3c8d67a31 100644 --- a/docs/reference/logger/messages.html +++ b/docs/reference/logger/messages.html @@ -3,7 +3,7 @@ - + slack_bolt.logger.messages API documentation @@ -308,6 +308,20 @@

                    Functions

            • +
              +def warning_ack_timeout_has_no_effect(identifier: str | re.Pattern, ack_timeout: int) ‑> str +
              +
              +
              + +Expand source code + +
              def warning_ack_timeout_has_no_effect(identifier: Union[str, Pattern], ack_timeout: int) -> str:
              +    handler_example = f'@app.function("{identifier}")' if isinstance(identifier, str) else f"@app.function({identifier})"
              +    return f"On {handler_example}, as `auto_acknowledge` is `True`, " f"`ack_timeout={ack_timeout}` you gave will be unused"
              +
              +
              +
              def warning_bot_only_conflicts() ‑> str
              @@ -591,6 +605,7 @@

              Functions

            • error_token_required
            • error_unexpected_listener_middleware
            • info_default_oauth_settings_loaded
            • +
            • warning_ack_timeout_has_no_effect
            • warning_bot_only_conflicts
            • warning_client_prioritized_and_token_skipped
            • warning_did_not_call_ack
            • @@ -605,7 +620,7 @@

              Functions

              diff --git a/docs/reference/middleware/assistant/assistant.html b/docs/reference/middleware/assistant/assistant.html index 76e5dff76..d1184c407 100644 --- a/docs/reference/middleware/assistant/assistant.html +++ b/docs/reference/middleware/assistant/assistant.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.assistant.assistant API documentation @@ -647,7 +647,7 @@

              -

              Generated by pdoc 0.11.5.

              +

              Generated by pdoc 0.11.6.

              diff --git a/docs/reference/middleware/assistant/async_assistant.html b/docs/reference/middleware/assistant/async_assistant.html index 260d493ac..2faf0e34b 100644 --- a/docs/reference/middleware/assistant/async_assistant.html +++ b/docs/reference/middleware/assistant/async_assistant.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.assistant.async_assistant API documentation @@ -707,7 +707,7 @@

              -

              Generated by pdoc 0.11.5.

              +

              Generated by pdoc 0.11.6.

              diff --git a/docs/reference/middleware/assistant/index.html b/docs/reference/middleware/assistant/index.html index 857240adb..92f405cad 100644 --- a/docs/reference/middleware/assistant/index.html +++ b/docs/reference/middleware/assistant/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.assistant API documentation @@ -664,7 +664,7 @@

              -

              Generated by pdoc 0.11.5.

              +

              Generated by pdoc 0.11.6.

              diff --git a/docs/reference/middleware/async_builtins.html b/docs/reference/middleware/async_builtins.html index eb46581bb..7528dc0bb 100644 --- a/docs/reference/middleware/async_builtins.html +++ b/docs/reference/middleware/async_builtins.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.async_builtins API documentation @@ -205,7 +205,7 @@

              Inherited members

              """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. - Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack for details. + Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details. """ async def async_process( @@ -232,10 +232,10 @@

              Inherited members

          • Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

            -

            Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack for details.

            +

            Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

            Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

            -

            Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack for details.

            +

            Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

            Args

            signing_secret
            @@ -293,12 +293,12 @@

            Inherited members

    • A middleware can process request data before other middleware and listener functions.

      Handles ssl_check requests. -Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details.

      +Refer to https://api.slack.com/interactivity/slash-commands for details.

      Args

      verification_token
      The verification token to check -(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack#verification_token_deprecation)
      +(optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation)
      base_logger
      The base logger
      @@ -352,7 +352,7 @@

      Inherited members

      A middleware can process request data before other middleware and listener functions.

      Handles url_verification requests.

      -

      Refer to https://docs.slack.dev/reference/events/url_verification for details.

      +

      Refer to https://api.slack.com/events/url_verification for details.

      Args

      base_logger
      @@ -418,7 +418,7 @@

      diff --git a/docs/reference/middleware/async_custom_middleware.html b/docs/reference/middleware/async_custom_middleware.html index 6ef00baaf..d985458ed 100644 --- a/docs/reference/middleware/async_custom_middleware.html +++ b/docs/reference/middleware/async_custom_middleware.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.async_custom_middleware API documentation @@ -166,7 +166,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/async_middleware.html b/docs/reference/middleware/async_middleware.html index e0e38cf9c..33b4273e7 100644 --- a/docs/reference/middleware/async_middleware.html +++ b/docs/reference/middleware/async_middleware.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.async_middleware API documentation @@ -232,7 +232,7 @@

      diff --git a/docs/reference/middleware/async_middleware_error_handler.html b/docs/reference/middleware/async_middleware_error_handler.html index 617e490c7..e7cd8bb32 100644 --- a/docs/reference/middleware/async_middleware_error_handler.html +++ b/docs/reference/middleware/async_middleware_error_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.async_middleware_error_handler API documentation @@ -234,7 +234,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/attaching_function_token/async_attaching_function_token.html b/docs/reference/middleware/attaching_function_token/async_attaching_function_token.html index ab8b609a2..1becac04e 100644 --- a/docs/reference/middleware/attaching_function_token/async_attaching_function_token.html +++ b/docs/reference/middleware/attaching_function_token/async_attaching_function_token.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.attaching_function_token.async_attaching_function_token API documentation @@ -107,7 +107,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/attaching_function_token/attaching_function_token.html b/docs/reference/middleware/attaching_function_token/attaching_function_token.html index f005e8ac1..8eea36647 100644 --- a/docs/reference/middleware/attaching_function_token/attaching_function_token.html +++ b/docs/reference/middleware/attaching_function_token/attaching_function_token.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.attaching_function_token.attaching_function_token API documentation @@ -107,7 +107,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/attaching_function_token/index.html b/docs/reference/middleware/attaching_function_token/index.html index 107ae09d6..44efd27a2 100644 --- a/docs/reference/middleware/attaching_function_token/index.html +++ b/docs/reference/middleware/attaching_function_token/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.attaching_function_token API documentation @@ -124,7 +124,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/authorization/async_authorization.html b/docs/reference/middleware/authorization/async_authorization.html index aef0b3cd7..9f38ea711 100644 --- a/docs/reference/middleware/authorization/async_authorization.html +++ b/docs/reference/middleware/authorization/async_authorization.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.async_authorization API documentation @@ -102,7 +102,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/authorization/async_internals.html b/docs/reference/middleware/authorization/async_internals.html index 483e8c81f..22b709799 100644 --- a/docs/reference/middleware/authorization/async_internals.html +++ b/docs/reference/middleware/authorization/async_internals.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.async_internals API documentation @@ -60,7 +60,7 @@

      Module slack_bolt.middleware.authorization.async_interna diff --git a/docs/reference/middleware/authorization/async_multi_teams_authorization.html b/docs/reference/middleware/authorization/async_multi_teams_authorization.html index b9bfa138f..50b529f33 100644 --- a/docs/reference/middleware/authorization/async_multi_teams_authorization.html +++ b/docs/reference/middleware/authorization/async_multi_teams_authorization.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.async_multi_teams_authorization API documentation @@ -216,7 +216,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/authorization/async_single_team_authorization.html b/docs/reference/middleware/authorization/async_single_team_authorization.html index 2b3c40369..a167d1c68 100644 --- a/docs/reference/middleware/authorization/async_single_team_authorization.html +++ b/docs/reference/middleware/authorization/async_single_team_authorization.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.async_single_team_authorization API documentation @@ -157,7 +157,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/authorization/authorization.html b/docs/reference/middleware/authorization/authorization.html index 6922ca354..7ddd4ce41 100644 --- a/docs/reference/middleware/authorization/authorization.html +++ b/docs/reference/middleware/authorization/authorization.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.authorization API documentation @@ -101,7 +101,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/authorization/index.html b/docs/reference/middleware/authorization/index.html index f97cc17bb..9f5c3f393 100644 --- a/docs/reference/middleware/authorization/index.html +++ b/docs/reference/middleware/authorization/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization API documentation @@ -398,7 +398,7 @@

      diff --git a/docs/reference/middleware/authorization/internals.html b/docs/reference/middleware/authorization/internals.html index 62db9d31e..c64a7e0f3 100644 --- a/docs/reference/middleware/authorization/internals.html +++ b/docs/reference/middleware/authorization/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.internals API documentation @@ -60,7 +60,7 @@

      Module slack_bolt.middleware.authorization.internals diff --git a/docs/reference/middleware/authorization/multi_teams_authorization.html b/docs/reference/middleware/authorization/multi_teams_authorization.html index 1414820c6..c2a6a7964 100644 --- a/docs/reference/middleware/authorization/multi_teams_authorization.html +++ b/docs/reference/middleware/authorization/multi_teams_authorization.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.multi_teams_authorization API documentation @@ -213,7 +213,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/authorization/single_team_authorization.html b/docs/reference/middleware/authorization/single_team_authorization.html index 535b18532..7687be155 100644 --- a/docs/reference/middleware/authorization/single_team_authorization.html +++ b/docs/reference/middleware/authorization/single_team_authorization.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.authorization.single_team_authorization API documentation @@ -171,7 +171,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/custom_middleware.html b/docs/reference/middleware/custom_middleware.html index 4364973a6..aba9dc14b 100644 --- a/docs/reference/middleware/custom_middleware.html +++ b/docs/reference/middleware/custom_middleware.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.custom_middleware API documentation @@ -156,7 +156,7 @@

      diff --git a/docs/reference/middleware/ignoring_self_events/async_ignoring_self_events.html b/docs/reference/middleware/ignoring_self_events/async_ignoring_self_events.html index 5b0f31653..4d48b16b9 100644 --- a/docs/reference/middleware/ignoring_self_events/async_ignoring_self_events.html +++ b/docs/reference/middleware/ignoring_self_events/async_ignoring_self_events.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.ignoring_self_events.async_ignoring_self_events API documentation @@ -125,7 +125,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/ignoring_self_events/ignoring_self_events.html b/docs/reference/middleware/ignoring_self_events/ignoring_self_events.html index 2c1a5eff3..111c096c4 100644 --- a/docs/reference/middleware/ignoring_self_events/ignoring_self_events.html +++ b/docs/reference/middleware/ignoring_self_events/ignoring_self_events.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.ignoring_self_events.ignoring_self_events API documentation @@ -170,7 +170,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/ignoring_self_events/index.html b/docs/reference/middleware/ignoring_self_events/index.html index 9c37fbb89..f81603f4a 100644 --- a/docs/reference/middleware/ignoring_self_events/index.html +++ b/docs/reference/middleware/ignoring_self_events/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.ignoring_self_events API documentation @@ -187,7 +187,7 @@

      -

      Generated by pdoc 0.11.5.

      +

      Generated by pdoc 0.11.6.

      diff --git a/docs/reference/middleware/index.html b/docs/reference/middleware/index.html index 7c8daecd1..98aa15c5d 100644 --- a/docs/reference/middleware/index.html +++ b/docs/reference/middleware/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware API documentation @@ -639,7 +639,7 @@

      Inherited members

      """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. - Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack for details. + Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details. Args: signing_secret: The signing secret @@ -688,7 +688,7 @@

      Inherited members

      A middleware can process request data before other middleware and listener functions.

      Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

      -

      Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack for details.

      +

      Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

      Args

      signing_secret
      @@ -834,11 +834,11 @@

      Inherited members

      base_logger: Optional[Logger] = None, ): """Handles `ssl_check` requests. - Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details. + Refer to https://api.slack.com/interactivity/slash-commands for details. Args: verification_token: The verification token to check - (optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack#verification_token_deprecation) + (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) base_logger: The base logger """ # noqa: E501 self.verification_token = verification_token @@ -880,12 +880,12 @@

      Inherited members

      A middleware can process request data before other middleware and listener functions.

      Handles slack_bolt.middleware.ssl_check requests. -Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details.

      +Refer to https://api.slack.com/interactivity/slash-commands for details.

      Args

      verification_token
      The verification token to check -(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack#verification_token_deprecation)
      +(optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation)

    base_logger
    The base logger
    @@ -931,7 +931,7 @@

    Inherited members

    def __init__(self, base_logger: Optional[Logger] = None): """Handles url_verification requests. - Refer to https://docs.slack.dev/reference/events/url_verification for details. + Refer to https://api.slack.com/events/url_verification for details. Args: base_logger: The base logger @@ -965,7 +965,7 @@

    Inherited members

    A middleware can process request data before other middleware and listener functions.

    Handles url_verification requests.

    -

    Refer to https://docs.slack.dev/reference/events/url_verification for details.

    +

    Refer to https://api.slack.com/events/url_verification for details.

    Args

    base_logger
    @@ -1077,7 +1077,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/middleware/message_listener_matches/async_message_listener_matches.html b/docs/reference/middleware/message_listener_matches/async_message_listener_matches.html index 790669551..9cbee09ca 100644 --- a/docs/reference/middleware/message_listener_matches/async_message_listener_matches.html +++ b/docs/reference/middleware/message_listener_matches/async_message_listener_matches.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.message_listener_matches.async_message_listener_matches API documentation @@ -124,7 +124,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/middleware/message_listener_matches/index.html b/docs/reference/middleware/message_listener_matches/index.html index 340bfca8b..29dfbb861 100644 --- a/docs/reference/middleware/message_listener_matches/index.html +++ b/docs/reference/middleware/message_listener_matches/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.message_listener_matches API documentation @@ -141,7 +141,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/middleware/message_listener_matches/message_listener_matches.html b/docs/reference/middleware/message_listener_matches/message_listener_matches.html index 67481a683..35b5bfa7a 100644 --- a/docs/reference/middleware/message_listener_matches/message_listener_matches.html +++ b/docs/reference/middleware/message_listener_matches/message_listener_matches.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.message_listener_matches.message_listener_matches API documentation @@ -124,7 +124,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/middleware/middleware.html b/docs/reference/middleware/middleware.html index f2940d31b..fb05fd3cc 100644 --- a/docs/reference/middleware/middleware.html +++ b/docs/reference/middleware/middleware.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.middleware API documentation @@ -232,7 +232,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/middleware/middleware_error_handler.html b/docs/reference/middleware/middleware_error_handler.html index 8dcce6026..168fd0409 100644 --- a/docs/reference/middleware/middleware_error_handler.html +++ b/docs/reference/middleware/middleware_error_handler.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.middleware_error_handler API documentation @@ -234,7 +234,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/middleware/request_verification/async_request_verification.html b/docs/reference/middleware/request_verification/async_request_verification.html index dfa1b22bf..dc2b20908 100644 --- a/docs/reference/middleware/request_verification/async_request_verification.html +++ b/docs/reference/middleware/request_verification/async_request_verification.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.request_verification.async_request_verification API documentation @@ -59,7 +59,7 @@

    Classes

    """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. - Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack for details. + Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details. """ async def async_process( @@ -86,10 +86,10 @@

    Classes

    Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

    -

    Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack for details.

    +

    Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

    Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

    -

    Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack for details.

    +

    Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

    Args

    signing_secret
    @@ -142,7 +142,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/middleware/request_verification/index.html b/docs/reference/middleware/request_verification/index.html index c841a74dc..ec8e6b941 100644 --- a/docs/reference/middleware/request_verification/index.html +++ b/docs/reference/middleware/request_verification/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.request_verification API documentation @@ -71,7 +71,7 @@

    Classes

    """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. - Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack for details. + Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details. Args: signing_secret: The signing secret @@ -120,7 +120,7 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

    -

    Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack for details.

    +

    Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

    Args

    signing_secret
    @@ -176,7 +176,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/middleware/request_verification/request_verification.html b/docs/reference/middleware/request_verification/request_verification.html index 4c778807a..aa5da095f 100644 --- a/docs/reference/middleware/request_verification/request_verification.html +++ b/docs/reference/middleware/request_verification/request_verification.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.request_verification.request_verification API documentation @@ -60,7 +60,7 @@

    Classes

    """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. - Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack for details. + Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details. Args: signing_secret: The signing secret @@ -109,7 +109,7 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

    -

    Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack for details.

    +

    Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

    Args

    signing_secret
    @@ -159,7 +159,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/middleware/ssl_check/async_ssl_check.html b/docs/reference/middleware/ssl_check/async_ssl_check.html index a8d6bbfcd..eaacf0846 100644 --- a/docs/reference/middleware/ssl_check/async_ssl_check.html +++ b/docs/reference/middleware/ssl_check/async_ssl_check.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.ssl_check.async_ssl_check API documentation @@ -75,12 +75,12 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Handles ssl_check requests. -Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details.

    +Refer to https://api.slack.com/interactivity/slash-commands for details.

    Args

    verification_token
    The verification token to check -(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack#verification_token_deprecation)
    +(optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation)
    base_logger
    The base logger
    @@ -131,7 +131,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/middleware/ssl_check/index.html b/docs/reference/middleware/ssl_check/index.html index fd800326a..6a6477071 100644 --- a/docs/reference/middleware/ssl_check/index.html +++ b/docs/reference/middleware/ssl_check/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.ssl_check API documentation @@ -76,11 +76,11 @@

    Classes

    base_logger: Optional[Logger] = None, ): """Handles `ssl_check` requests. - Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details. + Refer to https://api.slack.com/interactivity/slash-commands for details. Args: verification_token: The verification token to check - (optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack#verification_token_deprecation) + (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) base_logger: The base logger """ # noqa: E501 self.verification_token = verification_token @@ -122,12 +122,12 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Handles slack_bolt.middleware.ssl_check.ssl_check requests. -Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details.

    +Refer to https://api.slack.com/interactivity/slash-commands for details.

    Args

    verification_token
    The verification token to check -(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack#verification_token_deprecation)
    +(optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation)
    base_logger
    The base logger
    @@ -194,7 +194,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/middleware/ssl_check/ssl_check.html b/docs/reference/middleware/ssl_check/ssl_check.html index 5d34eb280..72b98724a 100644 --- a/docs/reference/middleware/ssl_check/ssl_check.html +++ b/docs/reference/middleware/ssl_check/ssl_check.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.ssl_check.ssl_check API documentation @@ -65,11 +65,11 @@

    Classes

    base_logger: Optional[Logger] = None, ): """Handles `ssl_check` requests. - Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details. + Refer to https://api.slack.com/interactivity/slash-commands for details. Args: verification_token: The verification token to check - (optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack#verification_token_deprecation) + (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) base_logger: The base logger """ # noqa: E501 self.verification_token = verification_token @@ -111,12 +111,12 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Handles ssl_check requests. -Refer to https://docs.slack.dev/interactivity/implementing-slash-commands for details.

    +Refer to https://api.slack.com/interactivity/slash-commands for details.

    Args

    verification_token
    The verification token to check -(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack#verification_token_deprecation)
    +(optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation)
    base_logger
    The base logger
    @@ -177,7 +177,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/middleware/url_verification/async_url_verification.html b/docs/reference/middleware/url_verification/async_url_verification.html index 460aecf4d..e7fbb82fe 100644 --- a/docs/reference/middleware/url_verification/async_url_verification.html +++ b/docs/reference/middleware/url_verification/async_url_verification.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.url_verification.async_url_verification API documentation @@ -73,7 +73,7 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Handles url_verification requests.

    -

    Refer to https://docs.slack.dev/reference/events/url_verification for details.

    +

    Refer to https://api.slack.com/events/url_verification for details.

    Args

    base_logger
    @@ -124,7 +124,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/middleware/url_verification/index.html b/docs/reference/middleware/url_verification/index.html index e3e98f95f..9e08c1699 100644 --- a/docs/reference/middleware/url_verification/index.html +++ b/docs/reference/middleware/url_verification/index.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.url_verification API documentation @@ -70,7 +70,7 @@

    Classes

    def __init__(self, base_logger: Optional[Logger] = None): """Handles url_verification requests. - Refer to https://docs.slack.dev/reference/events/url_verification for details. + Refer to https://api.slack.com/events/url_verification for details. Args: base_logger: The base logger @@ -104,7 +104,7 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Handles url_verification requests.

    -

    Refer to https://docs.slack.dev/reference/events/url_verification for details.

    +

    Refer to https://api.slack.com/events/url_verification for details.

    Args

    base_logger
    @@ -158,7 +158,7 @@

    diff --git a/docs/reference/middleware/url_verification/url_verification.html b/docs/reference/middleware/url_verification/url_verification.html index 32dc64d49..e90bf0395 100644 --- a/docs/reference/middleware/url_verification/url_verification.html +++ b/docs/reference/middleware/url_verification/url_verification.html @@ -3,7 +3,7 @@ - + slack_bolt.middleware.url_verification.url_verification API documentation @@ -59,7 +59,7 @@

    Classes

    def __init__(self, base_logger: Optional[Logger] = None): """Handles url_verification requests. - Refer to https://docs.slack.dev/reference/events/url_verification for details. + Refer to https://api.slack.com/events/url_verification for details. Args: base_logger: The base logger @@ -93,7 +93,7 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Handles url_verification requests.

    -

    Refer to https://docs.slack.dev/reference/events/url_verification for details.

    +

    Refer to https://api.slack.com/events/url_verification for details.

    Args

    base_logger
    @@ -141,7 +141,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/oauth/async_callback_options.html b/docs/reference/oauth/async_callback_options.html index 40ae06c1f..822867ea8 100644 --- a/docs/reference/oauth/async_callback_options.html +++ b/docs/reference/oauth/async_callback_options.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.async_callback_options API documentation @@ -279,7 +279,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/oauth/async_internals.html b/docs/reference/oauth/async_internals.html index ba6a31642..2b35a69c9 100644 --- a/docs/reference/oauth/async_internals.html +++ b/docs/reference/oauth/async_internals.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.async_internals API documentation @@ -120,7 +120,7 @@

    Functions

    diff --git a/docs/reference/oauth/async_oauth_flow.html b/docs/reference/oauth/async_oauth_flow.html index 182973db5..3ccdfd6f0 100644 --- a/docs/reference/oauth/async_oauth_flow.html +++ b/docs/reference/oauth/async_oauth_flow.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.async_oauth_flow API documentation @@ -803,7 +803,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/oauth/async_oauth_settings.html b/docs/reference/oauth/async_oauth_settings.html index 18b108491..5e6a543c4 100644 --- a/docs/reference/oauth/async_oauth_settings.html +++ b/docs/reference/oauth/async_oauth_settings.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.async_oauth_settings API documentation @@ -417,7 +417,7 @@

    diff --git a/docs/reference/oauth/callback_options.html b/docs/reference/oauth/callback_options.html index ac2a200b1..7ad3734b3 100644 --- a/docs/reference/oauth/callback_options.html +++ b/docs/reference/oauth/callback_options.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.callback_options API documentation @@ -299,7 +299,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/oauth/index.html b/docs/reference/oauth/index.html index 6281fbcc1..d118a5e72 100644 --- a/docs/reference/oauth/index.html +++ b/docs/reference/oauth/index.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth API documentation @@ -856,7 +856,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/oauth/internals.html b/docs/reference/oauth/internals.html index e1b239782..3f1b43a7e 100644 --- a/docs/reference/oauth/internals.html +++ b/docs/reference/oauth/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.internals API documentation @@ -225,7 +225,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/oauth/oauth_flow.html b/docs/reference/oauth/oauth_flow.html index abae18cb5..75aa3cb88 100644 --- a/docs/reference/oauth/oauth_flow.html +++ b/docs/reference/oauth/oauth_flow.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.oauth_flow API documentation @@ -807,7 +807,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/oauth/oauth_settings.html b/docs/reference/oauth/oauth_settings.html index 0555b761c..1eb2ab7dd 100644 --- a/docs/reference/oauth/oauth_settings.html +++ b/docs/reference/oauth/oauth_settings.html @@ -3,7 +3,7 @@ - + slack_bolt.oauth.oauth_settings API documentation @@ -415,7 +415,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/request/async_internals.html b/docs/reference/request/async_internals.html index 635bdf586..35a250c8d 100644 --- a/docs/reference/request/async_internals.html +++ b/docs/reference/request/async_internals.html @@ -3,7 +3,7 @@ - + slack_bolt.request.async_internals API documentation @@ -129,7 +129,7 @@

    Functions

    diff --git a/docs/reference/request/async_request.html b/docs/reference/request/async_request.html index c08c47ec9..a3658710a 100644 --- a/docs/reference/request/async_request.html +++ b/docs/reference/request/async_request.html @@ -3,7 +3,7 @@ - + slack_bolt.request.async_request API documentation @@ -238,7 +238,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/request/index.html b/docs/reference/request/index.html index 61908105d..06d9b4933 100644 --- a/docs/reference/request/index.html +++ b/docs/reference/request/index.html @@ -3,7 +3,7 @@ - + slack_bolt.request API documentation @@ -37,7 +37,7 @@

    Module slack_bolt.request

    Incoming request from Slack through either HTTP request or Socket Mode connection.

    -

    Refer to https://docs.slack.dev/apis/events-api/ for the two types of connections. +

    Refer to https://api.slack.com/apis/connections for the two types of connections. This interface encapsulates the difference between the two.

    @@ -272,7 +272,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/request/internals.html b/docs/reference/request/internals.html index 68fa34dae..bc13932ec 100644 --- a/docs/reference/request/internals.html +++ b/docs/reference/request/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.request.internals API documentation @@ -601,7 +601,7 @@

    Functions

    diff --git a/docs/reference/request/payload_utils.html b/docs/reference/request/payload_utils.html index 21490bdc8..4fe75fd81 100644 --- a/docs/reference/request/payload_utils.html +++ b/docs/reference/request/payload_utils.html @@ -3,7 +3,7 @@ - + slack_bolt.request.payload_utils API documentation @@ -622,7 +622,7 @@

    Functions

    diff --git a/docs/reference/request/request.html b/docs/reference/request/request.html index dfd0fa3f1..870b65f08 100644 --- a/docs/reference/request/request.html +++ b/docs/reference/request/request.html @@ -3,7 +3,7 @@ - + slack_bolt.request.request API documentation @@ -237,7 +237,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/response/index.html b/docs/reference/response/index.html index bc86a6770..c986c7150 100644 --- a/docs/reference/response/index.html +++ b/docs/reference/response/index.html @@ -3,7 +3,7 @@ - + slack_bolt.response API documentation @@ -39,7 +39,7 @@

    Module slack_bolt.response

    This interface represents Bolt's synchronous response to Slack.

    In Socket Mode, the response data can be transformed to a WebSocket message. In the HTTP endpoint mode, the response data becomes an HTTP response data.

    -

    Refer to https://docs.slack.dev/apis/events-api/ for the two types of connections.

    +

    Refer to https://api.slack.com/apis/connections for the two types of connections.

    Sub-modules

    @@ -227,7 +227,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/response/response.html b/docs/reference/response/response.html index 65151cf64..5044254e8 100644 --- a/docs/reference/response/response.html +++ b/docs/reference/response/response.html @@ -3,7 +3,7 @@ - + slack_bolt.response.response API documentation @@ -211,7 +211,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/util/async_utils.html b/docs/reference/util/async_utils.html index f7ce77489..f74d8f0ac 100644 --- a/docs/reference/util/async_utils.html +++ b/docs/reference/util/async_utils.html @@ -3,7 +3,7 @@ - + slack_bolt.util.async_utils API documentation @@ -85,7 +85,7 @@

    Functions

    diff --git a/docs/reference/util/index.html b/docs/reference/util/index.html index 54f1dbd8d..6eadaacb9 100644 --- a/docs/reference/util/index.html +++ b/docs/reference/util/index.html @@ -3,7 +3,7 @@ - + slack_bolt.util API documentation @@ -78,7 +78,7 @@

    Sub-modules

    diff --git a/docs/reference/util/utils.html b/docs/reference/util/utils.html index d22c1f581..33e6b1de2 100644 --- a/docs/reference/util/utils.html +++ b/docs/reference/util/utils.html @@ -3,7 +3,7 @@ - + slack_bolt.util.utils API documentation @@ -268,7 +268,7 @@

    Returns

    diff --git a/docs/reference/version.html b/docs/reference/version.html index 404f38d79..c4a0f9b83 100644 --- a/docs/reference/version.html +++ b/docs/reference/version.html @@ -3,7 +3,7 @@ - + slack_bolt.version API documentation @@ -61,7 +61,7 @@

    Module slack_bolt.version

    diff --git a/docs/reference/workflows/index.html b/docs/reference/workflows/index.html index 3e43b0701..caaffe74d 100644 --- a/docs/reference/workflows/index.html +++ b/docs/reference/workflows/index.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows API documentation @@ -43,7 +43,7 @@

    Module slack_bolt.workflows

  • slack_bolt.workflows.step.utilities
  • slack_bolt.workflows.step.async_step (if you use asyncio-based AsyncApp)
  • -

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +

    Refer to https://api.slack.com/workflows/steps for details.

    Sub-modules

    @@ -80,7 +80,7 @@

    Sub-modules

    diff --git a/docs/reference/workflows/step/async_step.html b/docs/reference/workflows/step/async_step.html index 0c08a9707..3bf597134 100644 --- a/docs/reference/workflows/step/async_step.html +++ b/docs/reference/workflows/step/async_step.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.async_step API documentation @@ -78,7 +78,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Args: callback_id: The callback_id for this step from app @@ -124,7 +124,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt """ return AsyncWorkflowStepBuilder(callback_id, base_logger=base_logger) @@ -200,7 +200,7 @@

    Classes

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Args

    callback_id
    @@ -252,7 +252,7 @@

    Static methods

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    @@ -267,7 +267,7 @@

    Static methods

    class AsyncWorkflowStepBuilder:
         """Steps from apps
    -    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.
    +    Refer to https://api.slack.com/workflows/steps for details.
         """
     
         callback_id: Union[str, Pattern]
    @@ -285,7 +285,7 @@ 

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt This builder is supposed to be used as decorator. @@ -327,7 +327,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new edit listener with details. @@ -380,7 +380,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new save listener with details. @@ -433,7 +433,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new execute listener with details. @@ -480,7 +480,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -555,10 +555,10 @@

    Static methods

    return _middleware

    Steps from apps -Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +Refer to https://api.slack.com/workflows/steps for details.

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    This builder is supposed to be used as decorator.

    my_step = AsyncWorkflowStep.builder("my_step")
     @my_step.edit
    @@ -659,7 +659,7 @@ 

    Methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -685,7 +685,7 @@

    Methods

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object.

    Returns

    @@ -709,7 +709,7 @@

    Returns

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new edit listener with details. @@ -754,7 +754,7 @@

    Returns

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Registers a new edit listener with details.

    You can use this method as decorator as well.

    @my_step.edit
    @@ -799,7 +799,7 @@ 

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new execute listener with details. @@ -844,7 +844,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Registers a new execute listener with details.

    You can use this method as decorator as well.

    @my_step.execute
    @@ -889,7 +889,7 @@ 

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new save listener with details. @@ -934,7 +934,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Registers a new save listener with details.

    You can use this method as decorator as well.

    @my_step.save
    @@ -1007,7 +1007,7 @@ 

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/workflows/step/async_step_middleware.html b/docs/reference/workflows/step/async_step_middleware.html index fff1cda5c..a174b9c11 100644 --- a/docs/reference/workflows/step/async_step_middleware.html +++ b/docs/reference/workflows/step/async_step_middleware.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.async_step_middleware API documentation @@ -140,7 +140,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/workflows/step/index.html b/docs/reference/workflows/step/index.html index ce29a210f..62d989976 100644 --- a/docs/reference/workflows/step/index.html +++ b/docs/reference/workflows/step/index.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step API documentation @@ -103,7 +103,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepCompleted API method. - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + Refer to https://api.slack.com/methods/workflows.stepCompleted for details. """ def __init__(self, *, client: WebClient, body: dict): @@ -135,7 +135,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepCompleted API method. -Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +Refer to https://api.slack.com/methods/workflows.stepCompleted for details.

    class Configure @@ -174,7 +174,7 @@

    Classes

    ) app.step(ws) - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + Refer to https://api.slack.com/workflows/steps for details. """ def __init__(self, *, callback_id: str, client: WebClient, body: dict): @@ -219,7 +219,7 @@

    Classes

    ) app.step(ws)
    -

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +

    Refer to https://api.slack.com/workflows/steps for details.

    class Fail @@ -248,7 +248,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepFailed API method. - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + Refer to https://api.slack.com/methods/workflows.stepFailed for details. """ def __init__(self, *, client: WebClient, body: dict): @@ -281,7 +281,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepFailed API method. -Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +Refer to https://api.slack.com/methods/workflows.stepFailed for details.

    class Update @@ -329,7 +329,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepFailed API method. - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + Refer to https://api.slack.com/methods/workflows.updateStep for details. """ def __init__(self, *, client: WebClient, body: dict): @@ -377,7 +377,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepFailed API method. -Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +Refer to https://api.slack.com/methods/workflows.updateStep for details.

    class WorkflowStep @@ -411,7 +411,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Args: callback_id: The callback_id for this step from app @@ -453,7 +453,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt """ return WorkflowStepBuilder( callback_id, @@ -546,7 +546,7 @@

    Classes

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Args

    callback_id
    @@ -598,7 +598,7 @@

    Static methods

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    @@ -732,7 +732,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/workflows/step/internals.html b/docs/reference/workflows/step/internals.html index f2067677f..c5fda1012 100644 --- a/docs/reference/workflows/step/internals.html +++ b/docs/reference/workflows/step/internals.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.internals API documentation @@ -60,7 +60,7 @@

    Module slack_bolt.workflows.step.internals

    diff --git a/docs/reference/workflows/step/step.html b/docs/reference/workflows/step/step.html index efaaad899..6e1567bd6 100644 --- a/docs/reference/workflows/step/step.html +++ b/docs/reference/workflows/step/step.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.step API documentation @@ -78,7 +78,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Args: callback_id: The callback_id for this step from app @@ -120,7 +120,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt """ return WorkflowStepBuilder( callback_id, @@ -213,7 +213,7 @@

    Classes

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Args

    callback_id
    @@ -265,7 +265,7 @@

    Static methods

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    @@ -280,7 +280,7 @@

    Static methods

    class WorkflowStepBuilder:
         """Steps from apps
    -    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.
    +    Refer to https://api.slack.com/workflows/steps for details.
         """
     
         callback_id: Union[str, Pattern]
    @@ -298,7 +298,7 @@ 

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt This builder is supposed to be used as decorator. @@ -340,7 +340,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new edit listener with details. @@ -394,7 +394,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new save listener with details. @@ -447,7 +447,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new execute listener with details. @@ -494,7 +494,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -584,10 +584,10 @@

    Static methods

    return _middleware

    Steps from apps -Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +Refer to https://api.slack.com/workflows/steps for details.

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    This builder is supposed to be used as decorator.

    my_step = WorkflowStep.builder("my_step")
     @my_step.edit
    @@ -703,7 +703,7 @@ 

    Methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -729,7 +729,7 @@

    Methods

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object.

    Returns

    @@ -753,7 +753,7 @@

    Returns

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new edit listener with details. @@ -799,7 +799,7 @@

    Returns

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Registers a new edit listener with details.

    You can use this method as decorator as well.

    @my_step.edit
    @@ -844,7 +844,7 @@ 

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new execute listener with details. @@ -889,7 +889,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Registers a new execute listener with details.

    You can use this method as decorator as well.

    @my_step.execute
    @@ -934,7 +934,7 @@ 

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://docs.slack.dev/workflows/workflow-steps + Use new custom steps: https://api.slack.com/automation/functions/custom-bolt Registers a new save listener with details. @@ -979,7 +979,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://docs.slack.dev/workflows/workflow-steps

    +Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    Registers a new save listener with details.

    You can use this method as decorator as well.

    @my_step.save
    @@ -1052,7 +1052,7 @@ 

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/workflows/step/step_middleware.html b/docs/reference/workflows/step/step_middleware.html index 58a1c6194..2ac62dd93 100644 --- a/docs/reference/workflows/step/step_middleware.html +++ b/docs/reference/workflows/step/step_middleware.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.step_middleware API documentation @@ -143,7 +143,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/workflows/step/utilities/async_complete.html b/docs/reference/workflows/step/utilities/async_complete.html index 8a8790900..8e95cc267 100644 --- a/docs/reference/workflows/step/utilities/async_complete.html +++ b/docs/reference/workflows/step/utilities/async_complete.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.async_complete API documentation @@ -76,7 +76,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepCompleted API method. - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + Refer to https://api.slack.com/methods/workflows.stepCompleted for details. """ def __init__(self, *, client: AsyncWebClient, body: dict): @@ -108,7 +108,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepCompleted API method. -Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +Refer to https://api.slack.com/methods/workflows.stepCompleted for details.

    @@ -134,7 +134,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/workflows/step/utilities/async_configure.html b/docs/reference/workflows/step/utilities/async_configure.html index 3151e71bd..008c35ab5 100644 --- a/docs/reference/workflows/step/utilities/async_configure.html +++ b/docs/reference/workflows/step/utilities/async_configure.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.async_configure API documentation @@ -83,7 +83,7 @@

    Classes

    ) app.step(ws) - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + Refer to https://api.slack.com/workflows/steps for details. """ def __init__(self, *, callback_id: str, client: AsyncWebClient, body: dict): @@ -131,7 +131,7 @@

    Classes

    ) app.step(ws)
    -

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +

    Refer to https://api.slack.com/workflows/steps for details.

    @@ -157,7 +157,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/workflows/step/utilities/async_fail.html b/docs/reference/workflows/step/utilities/async_fail.html index 1206c45a6..b27c36251 100644 --- a/docs/reference/workflows/step/utilities/async_fail.html +++ b/docs/reference/workflows/step/utilities/async_fail.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.async_fail API documentation @@ -73,7 +73,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepFailed API method. - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + Refer to https://api.slack.com/methods/workflows.stepFailed for details. """ def __init__(self, *, client: AsyncWebClient, body: dict): @@ -106,7 +106,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepFailed API method. -Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +Refer to https://api.slack.com/methods/workflows.stepFailed for details.

    @@ -132,7 +132,7 @@

    diff --git a/docs/reference/workflows/step/utilities/async_update.html b/docs/reference/workflows/step/utilities/async_update.html index 0d1cad162..bfb210fc3 100644 --- a/docs/reference/workflows/step/utilities/async_update.html +++ b/docs/reference/workflows/step/utilities/async_update.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.async_update API documentation @@ -92,7 +92,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepFailed API method. - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + Refer to https://api.slack.com/methods/workflows.updateStep for details. """ def __init__(self, *, client: AsyncWebClient, body: dict): @@ -140,7 +140,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepFailed API method. -Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +Refer to https://api.slack.com/methods/workflows.updateStep for details.

    @@ -166,7 +166,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/workflows/step/utilities/complete.html b/docs/reference/workflows/step/utilities/complete.html index c15d51e9c..f1cf11f56 100644 --- a/docs/reference/workflows/step/utilities/complete.html +++ b/docs/reference/workflows/step/utilities/complete.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.complete API documentation @@ -76,7 +76,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepCompleted API method. - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + Refer to https://api.slack.com/methods/workflows.stepCompleted for details. """ def __init__(self, *, client: WebClient, body: dict): @@ -108,7 +108,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepCompleted API method. -Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +Refer to https://api.slack.com/methods/workflows.stepCompleted for details.

    @@ -134,7 +134,7 @@

    diff --git a/docs/reference/workflows/step/utilities/configure.html b/docs/reference/workflows/step/utilities/configure.html index 2c1aeadbf..26d646cf2 100644 --- a/docs/reference/workflows/step/utilities/configure.html +++ b/docs/reference/workflows/step/utilities/configure.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.configure API documentation @@ -83,7 +83,7 @@

    Classes

    ) app.step(ws) - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + Refer to https://api.slack.com/workflows/steps for details. """ def __init__(self, *, callback_id: str, client: WebClient, body: dict): @@ -128,7 +128,7 @@

    Classes

    ) app.step(ws)
    -

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +

    Refer to https://api.slack.com/workflows/steps for details.

    @@ -154,7 +154,7 @@

    diff --git a/docs/reference/workflows/step/utilities/fail.html b/docs/reference/workflows/step/utilities/fail.html index 4c4091fd5..00d0be83d 100644 --- a/docs/reference/workflows/step/utilities/fail.html +++ b/docs/reference/workflows/step/utilities/fail.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.fail API documentation @@ -73,7 +73,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepFailed API method. - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + Refer to https://api.slack.com/methods/workflows.stepFailed for details. """ def __init__(self, *, client: WebClient, body: dict): @@ -106,7 +106,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepFailed API method. -Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +Refer to https://api.slack.com/methods/workflows.stepFailed for details.

    @@ -132,7 +132,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/docs/reference/workflows/step/utilities/index.html b/docs/reference/workflows/step/utilities/index.html index 1594bacb6..54261ea96 100644 --- a/docs/reference/workflows/step/utilities/index.html +++ b/docs/reference/workflows/step/utilities/index.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities API documentation @@ -127,7 +127,7 @@

    Sub-modules

    diff --git a/docs/reference/workflows/step/utilities/update.html b/docs/reference/workflows/step/utilities/update.html index c93fc7f21..9899448f9 100644 --- a/docs/reference/workflows/step/utilities/update.html +++ b/docs/reference/workflows/step/utilities/update.html @@ -3,7 +3,7 @@ - + slack_bolt.workflows.step.utilities.update API documentation @@ -92,7 +92,7 @@

    Classes

    app.step(ws) This utility is a thin wrapper of workflows.stepFailed API method. - Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + Refer to https://api.slack.com/methods/workflows.updateStep for details. """ def __init__(self, *, client: WebClient, body: dict): @@ -140,7 +140,7 @@

    Classes

    app.step(ws)

    This utility is a thin wrapper of workflows.stepFailed API method. -Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    +Refer to https://api.slack.com/methods/workflows.updateStep for details.

    @@ -166,7 +166,7 @@

    -

    Generated by pdoc 0.11.5.

    +

    Generated by pdoc 0.11.6.

    diff --git a/scripts/generate_api_docs.sh b/scripts/generate_api_docs.sh index 459476122..f8ea39d0d 100755 --- a/scripts/generate_api_docs.sh +++ b/scripts/generate_api_docs.sh @@ -6,5 +6,9 @@ cd ${script_dir}/.. pip install -U pdoc3 rm -rf docs/reference -pdoc reference --html -o docs + +pdoc slack_bolt --html -o docs/reference +cp -R docs/reference/slack_bolt/* docs/reference/ +rm -rf docs/reference/slack_bolt + open docs/reference/index.html diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 03d768130..b996e1572 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,3 +1,3 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.23.0" +__version__ = "1.24.0" From 9e0b3ed2665d6bfee58f388d3879631e8ae102f3 Mon Sep 17 00:00:00 2001 From: Luke Russell <31357343+lukegalbraithrussell@users.noreply.github.com> Date: Mon, 8 Sep 2025 10:35:44 -0700 Subject: [PATCH 787/865] docs: updates gif and enterprise paths (#1358) --- docs/english/concepts/authenticating-oauth.md | 2 +- docs/english/tutorial/modals/modals.md | 2 +- docs/img/announce.gif | Bin 0 -> 260652 bytes docs/japanese/concepts/authenticating-oauth.md | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 docs/img/announce.gif diff --git a/docs/english/concepts/authenticating-oauth.md b/docs/english/concepts/authenticating-oauth.md index 88b422949..4ce9b205b 100644 --- a/docs/english/concepts/authenticating-oauth.md +++ b/docs/english/concepts/authenticating-oauth.md @@ -6,7 +6,7 @@ Bolt for Python will create a **Redirect URL** `slack/oauth_redirect`, which Sla Bolt for Python will also create a `slack/install` route, where you can find an **Add to Slack** button for your app to perform direct installs of your app. If you need any additional authorizations (user tokens) from users inside a team when your app is already installed or a reason to dynamically generate an install URL, you can pass your own custom URL generator to `oauth_settings` as `authorize_url_generator`. -Bolt for Python automatically includes support for [org wide installations](/enterprise-grid/) in version `1.1.0+`. Org wide installations can be enabled in your app configuration settings under **Org Level Apps**. +Bolt for Python automatically includes support for [org wide installations](/enterprise) in version `1.1.0+`. Org wide installations can be enabled in your app configuration settings under **Org Level Apps**. To learn more about the OAuth installation flow with Slack, [read the API documentation](/authentication/installing-with-oauth). diff --git a/docs/english/tutorial/modals/modals.md b/docs/english/tutorial/modals/modals.md index ee6d1e0d8..d25470b97 100644 --- a/docs/english/tutorial/modals/modals.md +++ b/docs/english/tutorial/modals/modals.md @@ -9,7 +9,7 @@ GitHub Codespaces is an online IDE that allows you to work on code and host your At the end of this tutorial, your final app will look like this: -![announce](https://github.com/user-attachments/assets/0bf1c2f0-4b22-4c9c-98b3-b21e9bcc14a8) +![announce](/img/bolt-python/announce.gif) And will make use of these Slack concepts: * [**Block Kit**](/block-kit/) is a UI framework for Slack apps that allows you to create beautiful, interactive messages within Slack. If you've ever seen a message in Slack with buttons or a select menu, that's Block Kit. diff --git a/docs/img/announce.gif b/docs/img/announce.gif new file mode 100644 index 0000000000000000000000000000000000000000..7602784cead5bdc59bd1cb94e45e9f7300856f85 GIT binary patch literal 260652 zcmV($K;yqhNk%w1VT1!D0(SraA^!_bMO0HmK~P09E-(WD0000X`2+wP0000i00000 zgaaf3hXVov5)29w4G+IABp3}O8x}Gi86h4aC?gs+CKx+08Yd; zU<3j~J48S{WKBXUNkTJOM=DrIHD6FITTwh@Oe|zeIAK#SX<0aQNjh^`Id@|`NJmIb zOHx!zM^H{sSyM(>S6E(NU|eKVVNOY4Oi*cBLu6l7d`m=kSweYQUSeHgZD2xbWLbG< zM0#gce{e*5a#d$%XKZY8Yj|vZU~O=2aCdfh6J?`6T8BJik1BJiL0W};e1;c-WH^#| zB%*RJw0<+Ec`&nmM5%u}gRecZgEhqPNSlI0u7pXqidCYFUZ|5%w2NN3lz)JLZnK

    P%vJ%w#Nh+9a7X-0@@T8VZ_i+NR)X-kx4 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

    U`-6DC-f*y~5oPR*T)5dc^}u;Iin9<)b@V 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Ó!exVjZiNQatMKlRS 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�Uhvd3tEy81|&dobZr$j{Am#7 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 Date: Tue, 9 Sep 2025 09:56:26 -0400 Subject: [PATCH 788/865] chore: remove 3.6 support (#1359) --- .github/maintainers_guide.md | 4 ++-- README.md | 4 ++-- docs/english/building-an-app.md | 2 +- docs/english/getting-started.md | 4 ++-- docs/english/tutorial/ai-chatbot/ai-chatbot.md | 2 +- .../custom-steps-for-jira/custom-steps-for-jira.md | 2 +- docs/japanese/getting-started.md | 2 +- pyproject.toml | 5 ++--- requirements/adapter.txt | 6 ++---- requirements/adapter_testing.txt | 2 +- requirements/tools.txt | 2 +- scripts/format.sh | 10 ++++++++++ scripts/install_all_and_run_tests.sh | 8 +------- slack_bolt/async_app.py | 2 +- slack_bolt/listener_matcher/builtins.py | 10 +++------- slack_bolt/logger/messages.py | 2 +- slack_bolt/util/utils.py | 14 +------------- 17 files changed, 33 insertions(+), 48 deletions(-) create mode 100755 scripts/format.sh diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index 69026d602..352398072 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -25,8 +25,8 @@ $ pyenv local 3.8.5 $ pyenv versions system - 3.6.10 - 3.7.7 + 3.7.17 + 3.13.7 * 3.8.5 (set by /path-to-bolt-python/.python-version) $ pyenv rehash diff --git a/README.md b/README.md index 10a44a0e5..b3f78adb0 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A Python framework to build Slack apps in a flash with the latest platform featu ## Setup ```bash -# Python 3.6+ required +# Python 3.7+ required python -m venv .venv source .venv/bin/activate @@ -153,7 +153,7 @@ Most of the app's functionality will be inside listener functions (the `fn` para If you'd prefer to build your app with [asyncio](https://docs.python.org/3/library/asyncio.html), you can import the [AIOHTTP](https://docs.aiohttp.org/en/stable/) library and call the `AsyncApp` constructor. Within async apps, you can use the async/await pattern. ```bash -# Python 3.6+ required +# Python 3.7+ required python -m venv .venv source .venv/bin/activate diff --git a/docs/english/building-an-app.md b/docs/english/building-an-app.md index 87b26163b..301cc52c6 100644 --- a/docs/english/building-an-app.md +++ b/docs/english/building-an-app.md @@ -72,7 +72,7 @@ $ mkdir first-bolt-app $ cd first-bolt-app ``` -Next, we recommend using a [Python virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment) to manage your project's dependencies. This is a great way to prevent conflicts with your system's Python packages. Let's create and activate a new virtual environment with [Python 3.6 or later](https://www.python.org/downloads/): +Next, we recommend using a [Python virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment) to manage your project's dependencies. This is a great way to prevent conflicts with your system's Python packages. Let's create and activate a new virtual environment with [Python 3.7 or later](https://www.python.org/downloads/): ```sh $ python3 -m venv .venv diff --git a/docs/english/getting-started.md b/docs/english/getting-started.md index ebdf47189..a198736bc 100644 --- a/docs/english/getting-started.md +++ b/docs/english/getting-started.md @@ -21,7 +21,7 @@ In search of the complete guide to building an app from scratch? Check out the [ A few tools are needed for the following steps. We recommend using the [**Slack CLI**](/tools/slack-cli/) for the smoothest experience, but other options remain available. -You can also begin by installing git and downloading [Python 3.6 or later](https://www.python.org/downloads/), or the latest stable version of Python. Refer to [Python's setup and building guide](https://devguide.python.org/getting-started/setup-building/) for more details. +You can also begin by installing git and downloading [Python 3.7 or later](https://www.python.org/downloads/), or the latest stable version of Python. Refer to [Python's setup and building guide](https://devguide.python.org/getting-started/setup-building/) for more details. Install the latest version of the Slack CLI to get started: @@ -83,7 +83,7 @@ Outlines of a project are taking shape, so we can move on to running the app! -We recommend using a [Python virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment) to manage your project's dependencies. This is a great way to prevent conflicts with your system's Python packages. Let's create and activate a new virtual environment with [Python 3.6 or later](https://www.python.org/downloads/): +We recommend using a [Python virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment) to manage your project's dependencies. This is a great way to prevent conflicts with your system's Python packages. Let's create and activate a new virtual environment with [Python 3.7 or later](https://www.python.org/downloads/): ```sh $ python3 -m venv .venv diff --git a/docs/english/tutorial/ai-chatbot/ai-chatbot.md b/docs/english/tutorial/ai-chatbot/ai-chatbot.md index fa4da90a7..9da54c149 100644 --- a/docs/english/tutorial/ai-chatbot/ai-chatbot.md +++ b/docs/english/tutorial/ai-chatbot/ai-chatbot.md @@ -13,7 +13,7 @@ In this tutorial, you'll learn how to bring the power of AI into your Slack work Before getting started, you will need the following: * a development workspace where you have permissions to install apps. If you don’t have a workspace, go ahead and set that up now — you can [go here](https://slack.com/get-started#create) to create one, or you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. -* a development environment with [Python 3.6](https://www.python.org/downloads/) or later. +* a development environment with [Python 3.7](https://www.python.org/downloads/) or later. * an Anthropic or OpenAI account with sufficient credits, and in which you have generated a secret key. **Skip to the code** diff --git a/docs/english/tutorial/custom-steps-for-jira/custom-steps-for-jira.md b/docs/english/tutorial/custom-steps-for-jira/custom-steps-for-jira.md index f310e75cc..d74b82b8e 100644 --- a/docs/english/tutorial/custom-steps-for-jira/custom-steps-for-jira.md +++ b/docs/english/tutorial/custom-steps-for-jira/custom-steps-for-jira.md @@ -12,7 +12,7 @@ In this tutorial, you'll learn how to configure custom steps for use with JIRA. Before getting started, you will need the following: * a development workspace where you have permissions to install apps. If you don’t have a workspace, go ahead and set that up now—you can [go here](https://slack.com/get-started#create) to create one, or you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. -* a development environment with [Python 3.6](https://www.python.org/downloads/) or later. +* a development environment with [Python 3.7](https://www.python.org/downloads/) or later. **Skip to the code** If you'd rather skip the tutorial and just head straight to the code, you can use our [Bolt for Python JIRA functions sample](https://github.com/slack-samples/bolt-python-jira-functions) as a template. diff --git a/docs/japanese/getting-started.md b/docs/japanese/getting-started.md index 2ec34d193..30538bf94 100644 --- a/docs/japanese/getting-started.md +++ b/docs/japanese/getting-started.md @@ -64,7 +64,7 @@ mkdir first-bolt-app cd first-bolt-app ``` -次に、プロジェクトの依存ライブラリを管理する方法として、[Python 仮想環境](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment)を使ったおすすめの方法を紹介します。これはシステム Python に存在するパッケージとのコンフリクトを防ぐために推奨されている優れた方法です。[Python 3.6 以降](https://www.python.org/downloads/)の仮想環境を作成し、アクティブにしてみましょう。 +次に、プロジェクトの依存ライブラリを管理する方法として、[Python 仮想環境](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment)を使ったおすすめの方法を紹介します。これはシステム Python に存在するパッケージとのコンフリクトを防ぐために推奨されている優れた方法です。[Python 3.7 以降](https://www.python.org/downloads/)の仮想環境を作成し、アクティブにしてみましょう。 ```shell python3 -m venv .venv diff --git a/pyproject.toml b/pyproject.toml index 5ce2c62bc..07b338300 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools", "pytest-runner==6.0.1", "wheel"] +requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -8,7 +8,6 @@ dynamic = ["version", "readme", "dependencies", "authors"] description = "The Bolt Framework for Python" license = { text = "MIT" } classifiers = [ - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -20,7 +19,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -requires-python = ">=3.6" +requires-python = ">=3.7" [project.urls] diff --git a/requirements/adapter.txt b/requirements/adapter.txt index b8cadb510..8fd5cd33e 100644 --- a/requirements/adapter.txt +++ b/requirements/adapter.txt @@ -3,8 +3,7 @@ # used only under slack_bolt/adapter boto3<=2 bottle>=0.12,<1 -chalice<=1.27.3; python_version=="3.6" -chalice>=1.28,<2; python_version>"3.6" +chalice>=1.28,<2; CherryPy>=18,<19 Django>=3,<6 falcon>=2,<5; python_version<"3.11" @@ -17,8 +16,7 @@ pyramid>=1,<3 # Sanic and its dependencies # Note: Sanic imports tracerite with wild card versions tracerite<1.1.2; python_version<="3.8" # older versions of python are not compatible with tracerite>1.1.2 -sanic>=20,<21; python_version=="3.6" -sanic>=21,<24; python_version>"3.6" and python_version<="3.8" +sanic>=21,<24; python_version<="3.8" sanic>=21,<26; python_version>"3.8" starlette>=0.19.1,<1 diff --git a/requirements/adapter_testing.txt b/requirements/adapter_testing.txt index 3d829ee15..c497a1f3f 100644 --- a/requirements/adapter_testing.txt +++ b/requirements/adapter_testing.txt @@ -2,4 +2,4 @@ moto>=3,<6 # For AWS tests docker>=5,<8 # Used by moto boddle>=0.2,<0.3 # For Bottle app tests -sanic-testing>=0.7; python_version>"3.6" +sanic-testing>=0.7 diff --git a/requirements/tools.txt b/requirements/tools.txt index c3a383a13..b4cf790b9 100644 --- a/requirements/tools.txt +++ b/requirements/tools.txt @@ -1,3 +1,3 @@ mypy==1.17.1 flake8==7.3.0 -black==24.8.0 # Until we drop Python 3.6 support, we have to stay with this version +black==25.1.0 diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 000000000..10987bdd6 --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# ./scripts/format.sh + +script_dir=`dirname $0` +cd ${script_dir}/.. + +pip install -U pip +pip install -r requirements/tools.txt + +black slack_bolt/ tests/ diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index 21660dca5..a3154ae69 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -14,14 +14,8 @@ pip install -U pip pip uninstall python-lambda test_target="$1" -python_version=`python --version | awk '{print $2}'` -if [ ${python_version:0:3} == "3.6" ] -then - pip install -U -r requirements.txt -else - pip install -e . -fi +pip install -e . if [[ $test_target != "" ]] then diff --git a/slack_bolt/async_app.py b/slack_bolt/async_app.py index 10878c51b..fdf724d4c 100644 --- a/slack_bolt/async_app.py +++ b/slack_bolt/async_app.py @@ -5,7 +5,7 @@ If you'd prefer to build your app with [asyncio](https://docs.python.org/3/library/asyncio.html), you can import the [AIOHTTP](https://docs.aiohttp.org/en/stable/) library and call the `AsyncApp` constructor. Within async apps, you can use the async/await pattern. ```bash -# Python 3.6+ required +# Python 3.7+ required python -m venv .venv source .venv/bin/activate diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index fe06b9eef..57dbdf4f1 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -1,5 +1,4 @@ import re -import sys from logging import Logger from slack_bolt.error import BoltError @@ -25,10 +24,7 @@ from ..logger.messages import error_message_event_type from ..util.utils import get_arg_names_of_callable -if sys.version_info.major == 3 and sys.version_info.minor <= 6: - from re import _pattern_type as Pattern # type: ignore[attr-defined] -else: - from re import Pattern +from re import Pattern from typing import Callable, Awaitable, Any, Sequence, Optional, Union, Dict from slack_bolt.kwargs_injection import build_required_kwargs @@ -169,7 +165,7 @@ def _check_event_subtype(event_payload: dict, constraints: dict) -> bool: return True -def _verify_message_event_type(event_type: str) -> None: +def _verify_message_event_type(event_type: Union[str, Pattern]) -> None: if isinstance(event_type, str) and event_type.startswith("message."): raise ValueError(error_message_event_type(event_type)) if isinstance(event_type, Pattern) and "message\\." in event_type.pattern: @@ -324,7 +320,7 @@ def _block_action( elif isinstance(constraints, dict): # block_id matching is optional block_id: Optional[Union[str, Pattern]] = constraints.get("block_id") - action_id = constraints.get("action_id") + action_id = constraints.get("action_id") # type: ignore[assignment] if block_id is None and action_id is None: return False block_id_matched = block_id is None or _matches(block_id, action.get("block_id")) # type: ignore[union-attr] diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index cffdc445f..d30f51acb 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -60,7 +60,7 @@ def error_authorize_conflicts() -> str: return "`authorize` in the top-level arguments is not allowed when you pass either `oauth_settings` or `oauth_flow`" -def error_message_event_type(event_type: str) -> str: +def error_message_event_type(event_type: Union[str, Pattern]) -> str: return ( f'Although the document mentions "{event_type}", ' 'it is not a valid event type. Use "message" instead. ' diff --git a/slack_bolt/util/utils.py b/slack_bolt/util/utils.py index 0abdcfcbd..9ee313821 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -32,19 +32,7 @@ def convert_to_dict(obj: Union[Dict, JsonObject]) -> Dict: def create_copy(original: Any) -> Any: - if sys.version_info.major == 3 and sys.version_info.minor <= 6: - # NOTE: Unfortunately, copy.deepcopy doesn't work in Python 3.6.5. - # -------------------- - # > rv = reductor(4) - # E TypeError: can't pickle _thread.RLock objects - # ../../.pyenv/versions/3.6.10/lib/python3.6/copy.py:169: TypeError - # -------------------- - # As a workaround, this operation uses shallow copies in Python 3.6. - # If your code modifies the shared data in threads / async functions, race conditions may arise. - # Please consider upgrading Python major version to 3.7+ if you encounter some issues due to this. - return copy.copy(original) - else: - return copy.deepcopy(original) + return copy.deepcopy(original) def get_boot_message(development_server: bool = False) -> str: From a3adffaac15237e27f2522c60602697484431554 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 9 Sep 2025 13:33:43 -0400 Subject: [PATCH 789/865] chore: move dependencies to pyproject.toml (#1360) --- .github/dependabot.yml | 12 ------------ .github/workflows/tests.yml | 2 +- pyproject.toml | 4 ++-- requirements.txt | 1 - scripts/build_pypi_package.sh | 2 +- scripts/deploy_to_pypi_org.sh | 2 +- scripts/deploy_to_test_pypi_org.sh | 2 +- scripts/format.sh | 2 +- scripts/install_all_and_run_tests.sh | 2 +- scripts/run_flake8.sh | 2 +- scripts/run_mypy.sh | 8 ++++---- 11 files changed, 13 insertions(+), 26 deletions(-) delete mode 100644 requirements.txt diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 34b2ad725..dc523d227 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,15 +12,3 @@ updates: directory: "/" schedule: interval: "monthly" - - package-ecosystem: "npm" - directory: "/docs" - schedule: - interval: "monthly" - groups: - docusaurus: - patterns: - - "@docusaurus/*" - react: - patterns: - - "react" - - "react-dom" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f53a603ff..8ee9be411 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,7 +37,7 @@ jobs: - name: Install synchronous dependencies run: | pip install -U pip - pip install -r requirements.txt + pip install . pip install -r requirements/testing_without_asyncio.txt - name: Run tests without aiohttp run: | diff --git a/pyproject.toml b/pyproject.toml index 07b338300..024ee6654 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "slack_bolt" -dynamic = ["version", "readme", "dependencies", "authors"] +dynamic = ["version", "readme", "authors"] description = "The Bolt Framework for Python" license = { text = "MIT" } classifiers = [ @@ -20,6 +20,7 @@ classifiers = [ "Operating System :: OS Independent", ] requires-python = ">=3.7" +dependencies = ["slack_sdk>=3.35.0,<4"] [project.urls] @@ -31,7 +32,6 @@ include = ["slack_bolt*"] [tool.setuptools.dynamic] version = { attr = "slack_bolt.version.__version__" } readme = { file = ["README.md"], content-type = "text/markdown" } -dependencies = { file = ["requirements.txt"] } [tool.distutils.bdist_wheel] universal = true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index c8d66106a..000000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -slack_sdk>=3.35.0,<4 diff --git a/scripts/build_pypi_package.sh b/scripts/build_pypi_package.sh index 79c6db9f2..5806262a6 100755 --- a/scripts/build_pypi_package.sh +++ b/scripts/build_pypi_package.sh @@ -5,7 +5,7 @@ cd ${script_dir}/.. rm -rf ./slack_bolt.egg-info pip install -U pip && \ - pip install twine build && \ + pip install -U twine build && \ rm -rf dist/ build/ slack_bolt.egg-info/ && \ python -m build --sdist --wheel && \ twine check dist/* diff --git a/scripts/deploy_to_pypi_org.sh b/scripts/deploy_to_pypi_org.sh index a3cf431fa..8c5234902 100755 --- a/scripts/deploy_to_pypi_org.sh +++ b/scripts/deploy_to_pypi_org.sh @@ -5,7 +5,7 @@ cd ${script_dir}/.. rm -rf ./slack_bolt.egg-info pip install -U pip && \ - pip install twine build && \ + pip install -U twine build && \ rm -rf dist/ build/ slack_bolt.egg-info/ && \ python -m build --sdist --wheel && \ twine check dist/* && \ diff --git a/scripts/deploy_to_test_pypi_org.sh b/scripts/deploy_to_test_pypi_org.sh index b2cc65a12..a6b9c352d 100644 --- a/scripts/deploy_to_test_pypi_org.sh +++ b/scripts/deploy_to_test_pypi_org.sh @@ -5,7 +5,7 @@ cd ${script_dir}/.. rm -rf ./slack_bolt.egg-info pip install -U pip && \ - pip install twine build && \ + pip install -U twine build && \ rm -rf dist/ build/ slack_bolt.egg-info/ && \ python -m build --sdist --wheel && \ twine check dist/* && \ diff --git a/scripts/format.sh b/scripts/format.sh index 10987bdd6..77cecf9e4 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -5,6 +5,6 @@ script_dir=`dirname $0` cd ${script_dir}/.. pip install -U pip -pip install -r requirements/tools.txt +pip install -U -r requirements/tools.txt black slack_bolt/ tests/ diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index a3154ae69..1f2690414 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -15,7 +15,7 @@ pip uninstall python-lambda test_target="$1" -pip install -e . +pip install -U -e . if [[ $test_target != "" ]] then diff --git a/scripts/run_flake8.sh b/scripts/run_flake8.sh index 73562da29..e523920f9 100755 --- a/scripts/run_flake8.sh +++ b/scripts/run_flake8.sh @@ -3,5 +3,5 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ - pip install -r requirements/tools.txt && \ + pip install -U -r requirements/tools.txt && \ flake8 slack_bolt/ && flake8 examples/ diff --git a/scripts/run_mypy.sh b/scripts/run_mypy.sh index 77a2bacb7..c018443b7 100755 --- a/scripts/run_mypy.sh +++ b/scripts/run_mypy.sh @@ -3,8 +3,8 @@ script_dir=$(dirname $0) cd ${script_dir}/.. && \ - pip install . - pip install -r requirements/async.txt && \ - pip install -r requirements/adapter.txt && \ - pip install -r requirements/tools.txt && \ + pip install -U . + pip install -U -r requirements/async.txt && \ + pip install -U -r requirements/adapter.txt && \ + pip install -U -r requirements/tools.txt && \ mypy --config-file pyproject.toml From 3274d7a2b36101256ad498eee4aba794ab4ee888 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 9 Sep 2025 15:45:59 -0400 Subject: [PATCH 790/865] chore: version 1.25.0 (#1366) --- docs/reference/async_app.html | 2 +- docs/reference/logger/messages.html | 4 ++-- docs/reference/util/utils.html | 14 +------------- slack_bolt/version.py | 2 +- 4 files changed, 5 insertions(+), 17 deletions(-) diff --git a/docs/reference/async_app.html b/docs/reference/async_app.html index 07c1f3627..ad9192253 100644 --- a/docs/reference/async_app.html +++ b/docs/reference/async_app.html @@ -39,7 +39,7 @@

    Module slack_bolt.async_app

    Module for creating asyncio based apps

    Creating an async app

    If you'd prefer to build your app with asyncio, you can import the AIOHTTP library and call the AsyncApp constructor. Within async apps, you can use the async/await pattern.

    -
    # Python 3.6+ required
    +
    # Python 3.7+ required
     python -m venv .venv
     source .venv/bin/activate
     
    diff --git a/docs/reference/logger/messages.html b/docs/reference/logger/messages.html
    index 3c8d67a31..85e0d94dd 100644
    --- a/docs/reference/logger/messages.html
    +++ b/docs/reference/logger/messages.html
    @@ -208,14 +208,14 @@ 

    Functions

    -def error_message_event_type(event_type: str) ‑> str +def error_message_event_type(event_type: str | re.Pattern) ‑> str
    Expand source code -
    def error_message_event_type(event_type: str) -> str:
    +
    def error_message_event_type(event_type: Union[str, Pattern]) -> str:
         return (
             f'Although the document mentions "{event_type}", '
             'it is not a valid event type. Use "message" instead. '
    diff --git a/docs/reference/util/utils.html b/docs/reference/util/utils.html
    index 33e6b1de2..85d336513 100644
    --- a/docs/reference/util/utils.html
    +++ b/docs/reference/util/utils.html
    @@ -83,19 +83,7 @@ 

    Functions

    Expand source code
    def create_copy(original: Any) -> Any:
    -    if sys.version_info.major == 3 and sys.version_info.minor <= 6:
    -        # NOTE: Unfortunately, copy.deepcopy doesn't work in Python 3.6.5.
    -        # --------------------
    -        # >     rv = reductor(4)
    -        # E     TypeError: can't pickle _thread.RLock objects
    -        # ../../.pyenv/versions/3.6.10/lib/python3.6/copy.py:169: TypeError
    -        # --------------------
    -        # As a workaround, this operation uses shallow copies in Python 3.6.
    -        # If your code modifies the shared data in threads / async functions, race conditions may arise.
    -        # Please consider upgrading Python major version to 3.7+ if you encounter some issues due to this.
    -        return copy.copy(original)
    -    else:
    -        return copy.deepcopy(original)
    + return copy.deepcopy(original)
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index b996e1572..7f9c19341 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,3 +1,3 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.24.0" +__version__ = "1.25.0" From 420ec6bc4376890f892b2f51c6b92cac71d4978f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 20:00:34 +0000 Subject: [PATCH 791/865] chore(deps): update pytest-cov requirement from <7,>=3 to >=3,<8 (#1365) --- requirements/testing_without_asyncio.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/testing_without_asyncio.txt b/requirements/testing_without_asyncio.txt index d10c4345e..0e493f0e2 100644 --- a/requirements/testing_without_asyncio.txt +++ b/requirements/testing_without_asyncio.txt @@ -1,3 +1,3 @@ # pip install -r requirements/testing_without_asyncio.txt pytest>=6.2.5,<8.5 # https://github.com/tornadoweb/tornado/issues/3375 -pytest-cov>=3,<7 +pytest-cov>=3,<8 From 6f4fbf013ac796421347b6dc354c48577d6d8b6e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 20:55:11 +0000 Subject: [PATCH 792/865] chore(deps): bump actions/setup-python from 5.6.0 to 6.0.0 (#1363) --- .github/workflows/codecov.yml | 2 +- .github/workflows/flake8.yml | 2 +- .github/workflows/mypy.yml | 2 +- .github/workflows/tests.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 391c135c6..0eaf192ba 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -22,7 +22,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index 87f3496e1..ce6271c46 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -20,7 +20,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} - name: Run flake8 verification diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index f333756b5..fd9ae0203 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -20,7 +20,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} - name: Run mypy verification diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8ee9be411..8501cae36 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,7 +31,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} - name: Install synchronous dependencies From f7114844960d3e4949c0d263510467f6256341fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 21:07:47 +0000 Subject: [PATCH 793/865] chore(deps): bump actions/checkout from 4.2.2 to 5.0.0 (#1362) --- .github/workflows/codecov.yml | 2 +- .github/workflows/flake8.yml | 2 +- .github/workflows/mypy.yml | 2 +- .github/workflows/tests.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 0eaf192ba..01e6e6a5d 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -18,7 +18,7 @@ jobs: env: BOLT_PYTHON_CODECOV_RUNNING: "1" steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index ce6271c46..bd4e3dfd8 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -16,7 +16,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index fd9ae0203..1bf4abf0d 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -16,7 +16,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8501cae36..e68997aef 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} From 055b320de04b394264cfb7361837af22b96dea0e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 21:11:57 +0000 Subject: [PATCH 794/865] chore(deps): bump actions/stale from 9.1.0 to 10.0.0 (#1361) --- .github/workflows/triage-issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index b37c13422..5cb75bf93 100644 --- a/.github/workflows/triage-issues.yml +++ b/.github/workflows/triage-issues.yml @@ -16,7 +16,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 + - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: days-before-issue-stale: 30 days-before-issue-close: 10 From c512c6b08d70f830932031b7d87485e23e9d9fc3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 21:16:01 +0000 Subject: [PATCH 795/865] chore(deps): bump codecov/codecov-action from 5.4.3 to 5.5.1 (#1364) --- .github/workflows/codecov.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 01e6e6a5d..7381117bb 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -36,7 +36,7 @@ jobs: run: | pytest --cov=./slack_bolt/ --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: fail_ci_if_error: true verbose: true From 7cedaac2853d55d2329422c59a1c0bcec3b6ded0 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 15 Sep 2025 12:26:39 -0700 Subject: [PATCH 796/865] docs: add ai provider token instructions (#1340) --- .../english/tutorial/ai-chatbot/ai-chatbot.md | 69 ++++++++++++++----- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/docs/english/tutorial/ai-chatbot/ai-chatbot.md b/docs/english/tutorial/ai-chatbot/ai-chatbot.md index 9da54c149..72005f817 100644 --- a/docs/english/tutorial/ai-chatbot/ai-chatbot.md +++ b/docs/english/tutorial/ai-chatbot/ai-chatbot.md @@ -12,9 +12,9 @@ In this tutorial, you'll learn how to bring the power of AI into your Slack work Before getting started, you will need the following: -* a development workspace where you have permissions to install apps. If you don’t have a workspace, go ahead and set that up now — you can [go here](https://slack.com/get-started#create) to create one, or you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. -* a development environment with [Python 3.7](https://www.python.org/downloads/) or later. -* an Anthropic or OpenAI account with sufficient credits, and in which you have generated a secret key. +- a development workspace where you have permissions to install apps. If you don’t have a workspace, go ahead and set that up now — you can [go here](https://slack.com/get-started#create) to create one, or you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. +- a development environment with [Python 3.7](https://www.python.org/downloads/) or later. +- an Anthropic or OpenAI account with sufficient credits, and in which you have generated a secret key. **Skip to the code** If you'd rather skip the tutorial and just head straight to the code, you can use our [Bolt for Python AI Chatbot sample](https://github.com/slack-samples/bolt-python-ai-chatbot) as a template. @@ -25,31 +25,66 @@ If you'd rather skip the tutorial and just head straight to the code, you can us 2. Select the workspace you want to install the application in. 3. Copy the contents of the [`manifest.json`](https://github.com/slack-samples/bolt-python-ai-chatbot/blob/main/manifest.json) file into the text box that says **Paste your manifest code here** (within the **JSON** tab) and click **Next**. 4. Review the configuration and click **Create**. -5. You're now in your app configuration's **Basic Information** page. Navigate to the **Install App** link in the left nav and click **Install to Workspace*, then **Allow** on the screen that follows. +5. You're now in your app configuration's **Basic Information** page. Navigate to the **Install App** link in the left nav and click **Install to Workspace**, then **Allow** on the screen that follows. ### Obtaining and storing your environment variables {#environment-variables} Before you'll be able to successfully run the app, you'll need to first obtain and set some environment variables. +#### Slack tokens {#slack-tokens} + +From your app's page on [app settings](https://api.slack.com/apps) collect an app and bot token: + 1. On the **Install App** page, copy your **Bot User OAuth Token**. You will store this in your environment as `SLACK_BOT_TOKEN` (we'll get to that next). 2. Navigate to **Basic Information** and in the **App-Level Tokens** section , click **Generate Token and Scopes**. Add the [`connections:write`](/reference/scopes/connections.write) scope, name the token, and click **Generate**. (For more details, refer to [understanding OAuth scopes for bots](/authentication/tokens#bot)). Copy this token. You will store this in your environment as `SLACK_APP_TOKEN`. -To store your tokens and environment variables, run the following commands in the terminal. Replace the placeholder values with your bot and app tokens collected above, as well as the key or keys for the AI provider or providers you want to use: +To store your tokens and environment variables, run the following commands in the terminal. Replace the placeholder values with your bot and app tokens collected above: **For macOS** + ```bash export SLACK_BOT_TOKEN= export SLACK_APP_TOKEN= -export OPENAI_API_KEY= -export ANTHROPIC_API_KEY= ``` **For Windows** -```bash + +```pwsh set SLACK_BOT_TOKEN= set SLACK_APP_TOKEN= -set OPENAI_API_KEY= -set ANTHROPIC_API_KEY= +``` + +#### Provider tokens {#provider-tokens} + +Models from different AI providers are available if the corresponding environment variable is added as shown in the sections below. + +##### Anthropic {#anthropic} + +To interact with Anthropic models, navigate to your Anthropic account dashboard to [create an API key](https://console.anthropic.com/settings/keys), then export the key as follows: + +```bash +export ANTHROPIC_API_KEY= +``` + +##### Google Cloud Vertex AI {#google-cloud-vertex-ai} + +To use Google Cloud Vertex AI, [follow this quick start](https://cloud.google.com/vertex-ai/generative-ai/docs/start/quickstarts/quickstart-multimodal#expandable-1) to create a project for sending requests to the Gemini API, then gather [Application Default Credentials](https://cloud.google.com/docs/authentication/provide-credentials-adc) with the strategy to match your development environment. + +Once your project and credentials are configured, export environment variables to select from Gemini models: + +```bash +export VERTEX_AI_PROJECT_ID= +export VERTEX_AI_LOCATION= +``` + +The project location can be located under the **Region** on the [Vertex AI](https://console.cloud.google.com/vertex-ai) dashboard, as well as more details about available Gemini models. + +##### OpenAI {#openai} + +Unlock the OpenAI models from your OpenAI account dashboard by clicking [create a new secret key](https://platform.openai.com/api-keys), then export the key like so: + +```bash +export OPENAI_API_KEY= ``` ## Setting up and running your local project {#configure-project} @@ -69,12 +104,14 @@ cd bolt-python-ai-chatbot Start your Python virtual environment: **For macOS** + ```bash python3 -m venv .venv source .venv/bin/activate ``` **For Windows** + ```bash py -m venv .venv .venv\Scripts\activate @@ -123,7 +160,7 @@ Under **Then, do these things**, click **Add steps** and complete the following: ![Send a message](3.png) -We'll add two more steps under the **Then, do these things** section. +We'll add two more steps under the **Then, do these things** section. First, scroll to the bottom of the list of steps and choose **Custom**, then choose **Bolty** and **Bolty Custom Function**. In the **Channel** drop-down menu, select **Channel that the user joined**. Click **Save**. @@ -142,7 +179,7 @@ When finished, click **Finish Up**, then click **Publish** to make the workflow ### Summarizing recent conversations {#summarize} -In order for Bolty to provide summaries of recent conversation in a channel, Bolty _must_ be a member of that channel. +In order for Bolty to provide summaries of recent conversation in a channel, Bolty _must_ be a member of that channel. 1. Invite Bolty to a channel that you are able to leave and rejoin (for example, not the **#general** channel or a private channel someone else created) by mentioning the app in the channel — i.e., tagging **@Bolty** in the channel and sending your message. 2. Slackbot will prompt you to either invite Bolty to the channel, or do nothing. Click **Invite Them**. Now when new users join the channel, the workflow you just created will be kicked off. @@ -189,7 +226,7 @@ def handle_summary_function_callback( To ask Bolty a question, you can chat with Bolty in any channel the app is in. Use the `\ask-bolty` slash command to provide a prompt for Bolty to answer. Note that Bolty is currently not supported in threads. -You can also navigate to **Bolty** in your **Apps** list and select the **Messages** tab to chat with Bolty directly. +You can also navigate to **Bolty** in your **Apps** list and select the **Messages** tab to chat with Bolty directly. ![Ask Bolty](8.png) @@ -197,6 +234,6 @@ You can also navigate to **Bolty** in your **Apps** list and select the **Messag Congratulations! You've successfully integrated the power of AI into your workspace. Check out these links to take the next steps in your Bolt for Python journey. -* To learn more about Bolt for Python, refer to the [Getting started](/tools/bolt-python/getting-started) documentation. -* For more details about creating workflow steps using the Bolt SDK, refer to the [workflow steps for Bolt](/workflows/workflow-steps) guide. -* To use the Bolt for Python SDK to develop on the automations platform, refer to the [Create a workflow step for Workflow Builder: Bolt for Python](/tools/bolt-python/tutorial/custom-steps-workflow-builder-new) tutorial. +- To learn more about Bolt for Python, refer to the [Getting started](/tools/bolt-python/getting-started) documentation. +- For more details about creating workflow steps using the Bolt SDK, refer to the [workflow steps for Bolt](/workflows/workflow-steps) guide. +- To use the Bolt for Python SDK to develop on the automations platform, refer to the [Create a workflow step for Workflow Builder: Bolt for Python](/tools/bolt-python/tutorial/custom-steps-workflow-builder-new) tutorial. From 811d6a25466f7eb119331b47eb94f13402d34ab9 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 25 Sep 2025 16:53:25 -0700 Subject: [PATCH 797/865] build: require cheroot<11 with adapter test dependencies (#1375) --- requirements/adapter.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements/adapter.txt b/requirements/adapter.txt index 8fd5cd33e..3cefd621d 100644 --- a/requirements/adapter.txt +++ b/requirements/adapter.txt @@ -4,6 +4,7 @@ boto3<=2 bottle>=0.12,<1 chalice>=1.28,<2; +cheroot<11 # https://github.com/slackapi/bolt-python/issues/1374 CherryPy>=18,<19 Django>=3,<6 falcon>=2,<5; python_version<"3.11" From e21c4e82800ddff12e8933b0f4ba9ff19e3202fd Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 25 Sep 2025 17:57:42 -0700 Subject: [PATCH 798/865] build(deps): remove pytest lower bounds from testing requirements (#1333) Co-authored-by: William Bergamin --- requirements/testing_without_asyncio.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/testing_without_asyncio.txt b/requirements/testing_without_asyncio.txt index 0e493f0e2..441b49f8b 100644 --- a/requirements/testing_without_asyncio.txt +++ b/requirements/testing_without_asyncio.txt @@ -1,3 +1,3 @@ # pip install -r requirements/testing_without_asyncio.txt -pytest>=6.2.5,<8.5 # https://github.com/tornadoweb/tornado/issues/3375 +pytest<8.5 pytest-cov>=3,<8 From ef3e178b950cd4a22abf1ed59fe50152e9b0138e Mon Sep 17 00:00:00 2001 From: Haley Elmendorf <31392893+haleychaas@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:39:09 -0500 Subject: [PATCH 799/865] docs: updates for combined quickstart (#1378) --- docs/english/getting-started.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/english/getting-started.md b/docs/english/getting-started.md index a198736bc..8b7438d65 100644 --- a/docs/english/getting-started.md +++ b/docs/english/getting-started.md @@ -1,9 +1,8 @@ --- sidebar_label: Quickstart +title: Quickstart guide with Bolt for Python --- -# Quickstart guide with Bolt for Python - This quickstart guide aims to help you get a Slack app using Bolt for Python up and running as soon as possible! import Tabs from '@theme/Tabs'; @@ -292,8 +291,8 @@ Follow along with the steps that went into making this app on the [building an a You can now continue customizing your app with various features to make it right for whatever job's at hand. Here are some ideas about what to explore next: -- Explore the different events your bot can listen to with the [`app.event()`](/tools/bolt-python/concepts/event-listening) method. All of the [events](/reference/events) are listed on the API docs site. -- Bolt allows you to call [Web API](/tools/bolt-python/concepts/web-api) methods with the client attached to your app. There are [over 200 methods](/reference/methods) on the API docs site. +- Explore the different events your bot can listen to with the [`app.event()`](/tools/bolt-python/concepts/event-listening) method. See the full events reference [here](/reference/events). +- Bolt allows you to call [Web API](/tools/bolt-python/concepts/web-api) methods with the client attached to your app. There are [over 200 methods](/reference/methods) available. - Learn more about the different [token types](/authentication/tokens) and [authentication setups](/tools/bolt-python/concepts/authenticating-oauth). Your app might need different tokens depending on the actions you want to perform or for installations to multiple workspaces. - Receive events using HTTP for various deployment methods, such as deploying to Heroku or AWS Lambda. - Read on [app design](/surfaces/app-design) and compose fancy messages with blocks using [Block Kit Builder](https://app.slack.com/block-kit-builder) to prototype messages. From 9fa6e86fa345c243d2a604c5ef91f225e0448064 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 30 Sep 2025 11:49:12 -0700 Subject: [PATCH 800/865] build: install dependencies needed to autogenerate reference docs (#1377) --- scripts/generate_api_docs.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/generate_api_docs.sh b/scripts/generate_api_docs.sh index f8ea39d0d..c3b9fd260 100755 --- a/scripts/generate_api_docs.sh +++ b/scripts/generate_api_docs.sh @@ -1,10 +1,15 @@ #!/bin/bash # Generate API documents from the latest source code -script_dir=`dirname $0` -cd ${script_dir}/.. +set -e +script_dir=$(dirname "$0") +cd "${script_dir}/.." +pip install -U pip +pip install -U -r requirements/adapter.txt +pip install -U -r requirements/async.txt pip install -U pdoc3 +pip install . rm -rf docs/reference pdoc slack_bolt --html -o docs/reference From cb4130adf305a1299d9864d227e333fddb1739e6 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 30 Sep 2025 13:27:32 -0700 Subject: [PATCH 801/865] ci: post regression notifications if scheduled tests do not succeed (#1376) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e68997aef..167dc2ce4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -86,7 +86,7 @@ jobs: name: Regression notifications runs-on: ubuntu-latest needs: build - if: failure() && github.ref == 'refs/heads/main' && github.event_name != 'workflow_dispatch' + if: ${{ !success() && github.ref == 'refs/heads/main' && github.event_name != 'workflow_dispatch' }} steps: - name: Send notifications of failing tests uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 From c3e26d94b529278f5107e8fde1b1f011b1727cf7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:35:16 -0700 Subject: [PATCH 802/865] chore(deps): bump mypy from 1.17.1 to 1.18.2 (#1379) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/tools.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/tools.txt b/requirements/tools.txt index b4cf790b9..73a342ca6 100644 --- a/requirements/tools.txt +++ b/requirements/tools.txt @@ -1,3 +1,3 @@ -mypy==1.17.1 +mypy==1.18.2 flake8==7.3.0 black==25.1.0 From 95150b80e2ebe688fd834d6ceeaa3468334b25ae Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Oct 2025 10:23:07 -0700 Subject: [PATCH 803/865] docs: replace links from api.slack.com to docs.slack.dev redirects (#1383) --- .github/ISSUE_TEMPLATE/03_document.md | 2 +- README.md | 12 +- .../custom-steps-workflow-builder-existing.md | 2 +- docs/reference/app/app.html | 132 ++++++++--------- docs/reference/app/async_app.html | 126 ++++++++-------- docs/reference/app/index.html | 132 ++++++++--------- docs/reference/async_app.html | 126 ++++++++-------- docs/reference/authorization/index.html | 2 +- .../thread_context_store/file/index.html | 2 +- docs/reference/context/index.html | 2 +- docs/reference/index.html | 138 +++++++++--------- docs/reference/lazy_listener/index.html | 2 +- docs/reference/listener_matcher/builtins.html | 2 +- docs/reference/logger/messages.html | 2 +- docs/reference/middleware/async_builtins.html | 12 +- docs/reference/middleware/index.html | 16 +- .../async_request_verification.html | 6 +- .../request_verification/index.html | 4 +- .../request_verification.html | 4 +- .../middleware/ssl_check/async_ssl_check.html | 4 +- .../reference/middleware/ssl_check/index.html | 8 +- .../middleware/ssl_check/ssl_check.html | 8 +- .../async_url_verification.html | 2 +- .../middleware/url_verification/index.html | 4 +- .../url_verification/url_verification.html | 4 +- docs/reference/oauth/index.html | 2 +- docs/reference/request/index.html | 2 +- docs/reference/response/index.html | 2 +- docs/reference/workflows/index.html | 2 +- docs/reference/workflows/step/async_step.html | 40 ++--- docs/reference/workflows/step/index.html | 12 +- docs/reference/workflows/step/step.html | 40 ++--- .../step/utilities/async_configure.html | 4 +- .../workflows/step/utilities/configure.html | 4 +- examples/aws_lambda/README.md | 14 +- examples/django/README.md | 8 +- examples/getting_started/README.md | 8 +- examples/message_events.py | 6 +- examples/readme_app.py | 4 +- examples/readme_async_app.py | 4 +- .../workflow_steps/async_steps_from_apps.py | 2 +- .../async_steps_from_apps_decorator.py | 2 +- .../async_steps_from_apps_primitive.py | 2 +- examples/workflow_steps/steps_from_apps.py | 2 +- .../steps_from_apps_decorator.py | 2 +- .../steps_from_apps_primitive.py | 2 +- pyproject.toml | 2 +- slack_bolt/__init__.py | 4 +- slack_bolt/app/app.py | 48 +++--- slack_bolt/app/async_app.py | 46 +++--- slack_bolt/authorization/__init__.py | 2 +- slack_bolt/context/__init__.py | 2 +- slack_bolt/lazy_listener/__init__.py | 2 +- slack_bolt/listener_matcher/builtins.py | 2 +- slack_bolt/logger/messages.py | 2 +- .../async_request_verification.py | 2 +- .../request_verification.py | 2 +- slack_bolt/middleware/ssl_check/ssl_check.py | 4 +- .../url_verification/url_verification.py | 2 +- slack_bolt/oauth/__init__.py | 2 +- slack_bolt/request/__init__.py | 2 +- slack_bolt/response/__init__.py | 2 +- slack_bolt/workflows/__init__.py | 2 +- slack_bolt/workflows/step/async_step.py | 16 +- slack_bolt/workflows/step/step.py | 16 +- .../step/utilities/async_configure.py | 2 +- .../workflows/step/utilities/configure.py | 2 +- .../scenario_tests/test_attachment_actions.py | 2 +- .../test_attachment_actions.py | 2 +- 69 files changed, 545 insertions(+), 541 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/03_document.md b/.github/ISSUE_TEMPLATE/03_document.md index 3ce4e48e5..4eb5af847 100644 --- a/.github/ISSUE_TEMPLATE/03_document.md +++ b/.github/ISSUE_TEMPLATE/03_document.md @@ -10,7 +10,7 @@ assignees: '' ### The page URLs -* https://slack.dev/bolt-python/ +* https://docs.slack.dev/tools/bolt-python/ ## Requirements diff --git a/README.md b/README.md index b3f78adb0..39747df40 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ ngrok http 3000 ## Running a Socket Mode app -If you use [Socket Mode](https://api.slack.com/socket-mode) for running your app, `SocketModeHandler` is available for it. +If you use [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode/) for running your app, `SocketModeHandler` is available for it. ```python import os @@ -91,7 +91,7 @@ python app.py ## Listening for events -Apps typically react to a collection of incoming events, which can correspond to [Events API events](https://api.slack.com/events-api), [actions](https://api.slack.com/interactivity/components), [shortcuts](https://api.slack.com/interactivity/shortcuts), [slash commands](https://api.slack.com/interactivity/slash-commands) or [options requests](https://api.slack.com/reference/block-kit/block-elements#external_select). For each type of +Apps typically react to a collection of incoming events, which can correspond to [Events API events](https://docs.slack.dev/apis/events-api/), [actions](https://docs.slack.dev/block-kit/#making-things-interactive), [shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts/), [slash commands](https://docs.slack.dev/interactivity/implementing-slash-commands/) or [options requests](https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select). For each type of request, there's a method to build a listener function. ```python @@ -138,12 +138,12 @@ Most of the app's functionality will be inside listener functions (the `fn` para | Argument | Description | | :---: | :--- | | `body` | Dictionary that contains the entire body of the request (superset of `payload`). Some accessory data is only available outside of the payload (such as `trigger_id` and `authorizations`). -| `payload` | Contents of the incoming event. The payload structure depends on the listener. For example, for an Events API event, `payload` will be the [event type structure](https://api.slack.com/events-api#event_type_structure). For a block action, it will be the action from within the `actions` list. The `payload` dictionary is also accessible via the alias corresponding to the listener (`message`, `event`, `action`, `shortcut`, `view`, `command`, or `options`). For example, if you were building a `message()` listener, you could use the `payload` and `message` arguments interchangably. **An easy way to understand what's in a payload is to log it**. | +| `payload` | Contents of the incoming event. The payload structure depends on the listener. For example, for an Events API event, `payload` will be the [event type structure](https://docs.slack.dev/apis/events-api/#event-type-structure). For a block action, it will be the action from within the `actions` list. The `payload` dictionary is also accessible via the alias corresponding to the listener (`message`, `event`, `action`, `shortcut`, `view`, `command`, or `options`). For example, if you were building a `message()` listener, you could use the `payload` and `message` arguments interchangably. **An easy way to understand what's in a payload is to log it**. | | `context` | Event context. This dictionary contains data about the event and app, such as the `botId`. Middleware can add additional context before the event is passed to listeners. -| `ack` | Function that **must** be called to acknowledge that your app received the incoming event. `ack` exists for all actions, shortcuts, view submissions, slash command and options requests. `ack` returns a promise that resolves when complete. Read more in [Acknowledging events](https://tools.slack.dev/bolt-python/concepts/acknowledge). +| `ack` | Function that **must** be called to acknowledge that your app received the incoming event. `ack` exists for all actions, shortcuts, view submissions, slash command and options requests. `ack` returns a promise that resolves when complete. Read more in [Acknowledging events](https://docs.slack.dev/tools/bolt-python/concepts/acknowledge/). | `respond` | Utility function that responds to incoming events **if** it contains a `response_url` (shortcuts, actions, and slash commands). | `say` | Utility function to send a message to the channel associated with the incoming event. This argument is only available when the listener is triggered for events that contain a `channel_id` (the most common being `message` events). `say` accepts simple strings (for plain-text messages) and dictionaries (for messages containing blocks). -| `client` | Web API client that uses the token associated with the event. For single-workspace installations, the token is provided to the constructor. For multi-workspace installations, the token is returned by using [the OAuth library](https://tools.slack.dev/bolt-python/concepts/authenticating-oauth), or manually using the `authorize` function. +| `client` | Web API client that uses the token associated with the event. For single-workspace installations, the token is provided to the constructor. For multi-workspace installations, the token is returned by using [the OAuth library](https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth/), or manually using the `authorize` function. | `logger` | The built-in [`logging.Logger`](https://docs.python.org/3/library/logging.html) instance you can use in middleware/listeners. | `complete` | Utility function used to signal the successful completion of a custom step execution. This tells Slack to proceed with the next steps in the workflow. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. | `fail` | Utility function used to signal that a custom step failed to complete. This tells Slack to stop the workflow execution. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. @@ -192,7 +192,7 @@ Apps can be run the same way as the syncronous example above. If you'd prefer an ## Getting Help -[The documentation](https://tools.slack.dev/bolt-python) has more information on basic and advanced concepts for Bolt for Python. Also, all the Python module documents of this library are available [here](https://tools.slack.dev/bolt-python/reference/). +[The documentation](https://docs.slack.dev/tools/bolt-python/) has more information on basic and advanced concepts for Bolt for Python. Also, all the Python module documents of this library are available [here](https://docs.slack.dev/tools/bolt-python/reference/). If you otherwise get stuck, we're here to help. The following are the best ways to get assistance working through your issue: diff --git a/docs/english/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing.md b/docs/english/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing.md index 0441b033c..c3c5e2af7 100644 --- a/docs/english/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing.md +++ b/docs/english/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing.md @@ -9,7 +9,7 @@ If you followed along with our [create a custom step for Workflow Builder: new a In this tutorial we will: - Start with an existing Bolt app - Add a custom **workflow step** in the [app settings](https://api.slack.com/apps) -- Wire up the new step to a **function listener** in our project, using the [Bolt for Python](https://slack.dev/bolt-python/) framework +- Wire up the new step to a **function listener** in our project, using the [Bolt for Python](https://docs.slack.dev/tools/bolt-python/) framework - See the step as a custom workflow step in Workflow Builder ## Prerequisites {#prereqs} diff --git a/docs/reference/app/app.html b/docs/reference/app/app.html index d1224dd5d..3ee02b07c 100644 --- a/docs/reference/app/app.html +++ b/docs/reference/app/app.html @@ -117,10 +117,10 @@

    Classes

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) - Refer to https://slack.dev/bolt-python/tutorial/getting-started for details. + Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details. If you would like to build an OAuth app for enabling the app to run with multiple workspaces, - refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app. + refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app. Args: logger: The custom logger that can be used in this app. @@ -629,7 +629,7 @@

    Classes

    # Pass a function to this method app.middleware(middleware_func) - Refer to https://slack.dev/bolt-python/concepts#global-middleware for details. + Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -675,7 +675,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new step from app listener. @@ -693,7 +693,7 @@

    Classes

    # Pass Step to set up listeners app.step(ws) - Refer to https://api.slack.com/workflows/steps for details of steps from apps. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -710,7 +710,7 @@

    Classes

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" ), category=DeprecationWarning, ) @@ -787,7 +787,7 @@

    Classes

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://api.slack.com/apis/connections/events-api for details of Events API. + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -825,7 +825,7 @@

    Classes

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://api.slack.com/events/message for details of `message` events. + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -936,7 +936,7 @@

    Classes

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -983,7 +983,7 @@

    Classes

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1051,9 +1051,9 @@

    Classes

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. - * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. - * Refer to https://api.slack.com/dialogs for actions in dialogs. + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1079,7 +1079,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. - Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. """ def __call__(*args, **kwargs): @@ -1096,7 +1096,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. - Refer to https://api.slack.com/legacy/message-buttons for details.""" + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1112,7 +1112,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1128,7 +1128,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1169,7 +1169,7 @@

    Classes

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1195,7 +1195,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1211,7 +1211,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1252,8 +1252,8 @@

    Classes

    Refer to the following documents for details: - * https://api.slack.com/reference/block-kit/block-elements#external_select - * https://api.slack.com/reference/block-kit/block-elements#external_multi_select + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1293,7 +1293,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1443,9 +1443,9 @@

    Classes

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000)))
    -

    Refer to https://slack.dev/bolt-python/tutorial/getting-started for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details.

    If you would like to build an OAuth app for enabling the app to run with multiple workspaces, -refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app.

    +refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app.

    Args

    logger
    @@ -1637,9 +1637,9 @@

    Methods

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. - * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. - * Refer to https://api.slack.com/dialogs for actions in dialogs. + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1668,9 +1668,9 @@

    Methods

    app.action("approve_button")(update_message)

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -1713,7 +1713,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. - Refer to https://api.slack.com/legacy/message-buttons for details.""" + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1723,7 +1723,7 @@

    Args

    return __call__

    Registers a new interactive_message action listener. -Refer to https://api.slack.com/legacy/message-buttons for details.

    +Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.

    def block_action(self,
    constraints: str | Pattern | Dict[str, str | Pattern],
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1740,7 +1740,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. - Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. """ def __call__(*args, **kwargs): @@ -1751,7 +1751,7 @@

    Args

    return __call__

    Registers a new block_actions action listener. -Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.

    def block_suggestion(self,
    action_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1805,7 +1805,7 @@

    Args

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1836,7 +1836,7 @@

    Args

    # Pass a function to this method app.command("/echo")(repeat_text)
    -

    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.

    +

    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -1899,7 +1899,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1909,7 +1909,7 @@

    Args

    return __call__

    Registers a new dialog_cancellation listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def dialog_submission(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1926,7 +1926,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1936,7 +1936,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def dialog_suggestion(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1953,7 +1953,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1963,7 +1963,7 @@

    Args

    return __call__

    Registers a new dialog_suggestion listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def dispatch(self,
    req: BoltRequest) ‑> BoltResponse
    @@ -2189,7 +2189,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://api.slack.com/apis/connections/events-api for details of Events API. + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2221,7 +2221,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction)
    -

    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.

    +

    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2373,7 +2373,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://api.slack.com/events/message for details of `message` events. + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2424,7 +2424,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello)
    -

    Refer to https://api.slack.com/events/message for details of message events.

    +

    Refer to https://docs.slack.dev/reference/events/message/ for details of message events.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2484,7 +2484,7 @@

    Args

    # Pass a function to this method app.middleware(middleware_func) - Refer to https://slack.dev/bolt-python/concepts#global-middleware for details. + Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2522,7 +2522,7 @@

    Args

    # Pass a function to this method app.middleware(middleware_func)
    -

    Refer to https://slack.dev/bolt-python/concepts#global-middleware for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2567,8 +2567,8 @@

    Args

    Refer to the following documents for details: - * https://api.slack.com/reference/block-kit/block-elements#external_select - * https://api.slack.com/reference/block-kit/block-elements#external_multi_select + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2608,8 +2608,8 @@

    Args

    Refer to the following documents for details:

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2655,7 +2655,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2692,7 +2692,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) -

    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.

    +

    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2778,7 +2778,7 @@

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new step from app listener. @@ -2796,7 +2796,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) - Refer to https://api.slack.com/workflows/steps for details of steps from apps. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2813,7 +2813,7 @@

    Args

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" ), category=DeprecationWarning, ) @@ -2835,7 +2835,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Registers a new step from app listener.

    Unlike others, this method doesn't behave as a decorator. If you want to register a step from app by a decorator, use WorkflowStepBuilder's methods.

    @@ -2850,7 +2850,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) -

    Refer to https://api.slack.com/workflows/steps for details of steps from apps.

    +

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    For further information about WorkflowStep specific function arguments such as configure, update, complete, and fail, @@ -2921,7 +2921,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2962,7 +2962,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) -

    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.

    +

    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2991,7 +2991,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3001,7 +3001,7 @@

    Args

    return __call__

    Registers a new view_closed listener. -Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.

    def view_submission(self,
    constraints: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -3018,7 +3018,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3028,7 +3028,7 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.

    diff --git a/docs/reference/app/async_app.html b/docs/reference/app/async_app.html index b6634710a..78959986b 100644 --- a/docs/reference/app/async_app.html +++ b/docs/reference/app/async_app.html @@ -114,10 +114,10 @@

    Classes

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) - Refer to https://slack.dev/bolt-python/concepts#async for details. + Refer to https://docs.slack.dev/tools/bolt-python/concepts/async for details. If you would like to build an OAuth app for enabling the app to run with multiple workspaces, - refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app. + refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app. Args: logger: The custom logger that can be used in this app. @@ -687,7 +687,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new step from app listener. @@ -705,7 +705,7 @@

    Classes

    # Pass Step to set up listeners app.step(ws) - Refer to https://api.slack.com/workflows/steps for details of steps from apps. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. For further information about AsyncWorkflowStep specific function arguments @@ -721,7 +721,7 @@

    Classes

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" ), category=DeprecationWarning, ) @@ -803,7 +803,7 @@

    Classes

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://api.slack.com/apis/connections/events-api for details of Events API. + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -841,7 +841,7 @@

    Classes

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://api.slack.com/events/message for details of `message` events. + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -956,7 +956,7 @@

    Classes

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1003,7 +1003,7 @@

    Classes

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1071,9 +1071,9 @@

    Classes

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. - * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. - * Refer to https://api.slack.com/dialogs for actions in dialogs. + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1099,7 +1099,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_actions` action listener. - Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. """ def __call__(*args, **kwargs): @@ -1116,7 +1116,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `interactive_message` action listener. - Refer to https://api.slack.com/legacy/message-buttons for details.""" + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1132,7 +1132,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1148,7 +1148,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1189,7 +1189,7 @@

    Classes

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1215,7 +1215,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1231,7 +1231,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_closed` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1272,8 +1272,8 @@

    Classes

    Refer to the following documents for details: - * https://api.slack.com/reference/block-kit/block-elements#external_select - * https://api.slack.com/reference/block-kit/block-elements#external_multi_select + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1313,7 +1313,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1473,9 +1473,9 @@

    Classes

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) -

    Refer to https://slack.dev/bolt-python/concepts#async for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/concepts/async for details.

    If you would like to build an OAuth app for enabling the app to run with multiple workspaces, -refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app.

    +refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app.

    Args

    logger
    @@ -1669,9 +1669,9 @@

    Methods

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. - * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. - * Refer to https://api.slack.com/dialogs for actions in dialogs. + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1700,9 +1700,9 @@

    Methods

    app.action("approve_button")(update_message)

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -1873,7 +1873,7 @@

    Returns

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `interactive_message` action listener. - Refer to https://api.slack.com/legacy/message-buttons for details.""" + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1883,7 +1883,7 @@

    Returns

    return __call__

    Registers a new interactive_message action listener. -Refer to https://api.slack.com/legacy/message-buttons for details.

    +Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.

    def block_action(self,
    constraints: str | Pattern | Dict[str, str | Pattern],
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -1900,7 +1900,7 @@

    Returns

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_actions` action listener. - Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. """ def __call__(*args, **kwargs): @@ -1911,7 +1911,7 @@

    Returns

    return __call__

    Registers a new block_actions action listener. -Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.

    def block_suggestion(self,
    action_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -1965,7 +1965,7 @@

    Returns

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1996,7 +1996,7 @@

    Returns

    # Pass a function to this method app.command("/echo")(repeat_text)
    -

    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.

    +

    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2059,7 +2059,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2069,7 +2069,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def dialog_submission(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -2086,7 +2086,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2096,7 +2096,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def dialog_suggestion(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -2113,7 +2113,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2123,7 +2123,7 @@

    Args

    return __call__

    Registers a new dialog_suggestion listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def enable_token_revocation_listeners(self) ‑> None @@ -2229,7 +2229,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://api.slack.com/apis/connections/events-api for details of Events API. + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2261,7 +2261,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction)
    -

    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.

    +

    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2414,7 +2414,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://api.slack.com/events/message for details of `message` events. + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2468,7 +2468,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) -

    Refer to https://api.slack.com/events/message for details of message events.

    +

    Refer to https://docs.slack.dev/reference/events/message/ for details of message events.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2608,8 +2608,8 @@

    Args

    Refer to the following documents for details: - * https://api.slack.com/reference/block-kit/block-elements#external_select - * https://api.slack.com/reference/block-kit/block-elements#external_multi_select + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2649,8 +2649,8 @@

    Args

    Refer to the following documents for details:

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2739,7 +2739,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2776,7 +2776,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) -

    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.

    +

    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2839,7 +2839,7 @@

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new step from app listener. @@ -2857,7 +2857,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) - Refer to https://api.slack.com/workflows/steps for details of steps from apps. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. For further information about AsyncWorkflowStep specific function arguments @@ -2873,7 +2873,7 @@

    Args

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" ), category=DeprecationWarning, ) @@ -2895,7 +2895,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Registers a new step from app listener.

    Unlike others, this method doesn't behave as a decorator. If you want to register a step from app by a decorator, use AsyncWorkflowStepBuilder's methods.

    @@ -2910,7 +2910,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) -

    Refer to https://api.slack.com/workflows/steps for details of steps from apps.

    +

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document. For further information about AsyncWorkflowStep specific function arguments such as configure, update, complete, and fail, @@ -2978,7 +2978,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -3019,7 +3019,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) -

    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.

    +

    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -3048,7 +3048,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_closed` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3058,7 +3058,7 @@

    Args

    return __call__

    Registers a new view_closed listener. -Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.

    def view_submission(self,
    constraints: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -3075,7 +3075,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3085,7 +3085,7 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.

    def web_app(self, path: str = '/slack/events', port: int = 3000) ‑> aiohttp.web_app.Application diff --git a/docs/reference/app/index.html b/docs/reference/app/index.html index 857fb22c8..a46bc2e71 100644 --- a/docs/reference/app/index.html +++ b/docs/reference/app/index.html @@ -136,10 +136,10 @@

    Classes

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) - Refer to https://slack.dev/bolt-python/tutorial/getting-started for details. + Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details. If you would like to build an OAuth app for enabling the app to run with multiple workspaces, - refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app. + refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app. Args: logger: The custom logger that can be used in this app. @@ -648,7 +648,7 @@

    Classes

    # Pass a function to this method app.middleware(middleware_func) - Refer to https://slack.dev/bolt-python/concepts#global-middleware for details. + Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -694,7 +694,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new step from app listener. @@ -712,7 +712,7 @@

    Classes

    # Pass Step to set up listeners app.step(ws) - Refer to https://api.slack.com/workflows/steps for details of steps from apps. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -729,7 +729,7 @@

    Classes

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" ), category=DeprecationWarning, ) @@ -806,7 +806,7 @@

    Classes

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://api.slack.com/apis/connections/events-api for details of Events API. + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -844,7 +844,7 @@

    Classes

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://api.slack.com/events/message for details of `message` events. + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -955,7 +955,7 @@

    Classes

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1002,7 +1002,7 @@

    Classes

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1070,9 +1070,9 @@

    Classes

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. - * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. - * Refer to https://api.slack.com/dialogs for actions in dialogs. + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1098,7 +1098,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. - Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. """ def __call__(*args, **kwargs): @@ -1115,7 +1115,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. - Refer to https://api.slack.com/legacy/message-buttons for details.""" + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1131,7 +1131,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1147,7 +1147,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1188,7 +1188,7 @@

    Classes

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1214,7 +1214,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1230,7 +1230,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1271,8 +1271,8 @@

    Classes

    Refer to the following documents for details: - * https://api.slack.com/reference/block-kit/block-elements#external_select - * https://api.slack.com/reference/block-kit/block-elements#external_multi_select + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1312,7 +1312,7 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1462,9 +1462,9 @@

    Classes

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000)))
    -

    Refer to https://slack.dev/bolt-python/tutorial/getting-started for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details.

    If you would like to build an OAuth app for enabling the app to run with multiple workspaces, -refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app.

    +refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app.

    Args

    logger
    @@ -1656,9 +1656,9 @@

    Methods

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. - * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. - * Refer to https://api.slack.com/dialogs for actions in dialogs. + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1687,9 +1687,9 @@

    Methods

    app.action("approve_button")(update_message)

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -1732,7 +1732,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. - Refer to https://api.slack.com/legacy/message-buttons for details.""" + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1742,7 +1742,7 @@

    Args

    return __call__

    Registers a new interactive_message action listener. -Refer to https://api.slack.com/legacy/message-buttons for details.

    +Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.

    def block_action(self,
    constraints: str | Pattern | Dict[str, str | Pattern],
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1759,7 +1759,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. - Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. """ def __call__(*args, **kwargs): @@ -1770,7 +1770,7 @@

    Args

    return __call__

    Registers a new block_actions action listener. -Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.

    def block_suggestion(self,
    action_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1824,7 +1824,7 @@

    Args

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1855,7 +1855,7 @@

    Args

    # Pass a function to this method app.command("/echo")(repeat_text)
    -

    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.

    +

    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -1918,7 +1918,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1928,7 +1928,7 @@

    Args

    return __call__

    Registers a new dialog_cancellation listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def dialog_submission(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1945,7 +1945,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1955,7 +1955,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def dialog_suggestion(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1972,7 +1972,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1982,7 +1982,7 @@

    Args

    return __call__

    Registers a new dialog_suggestion listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def dispatch(self,
    req: BoltRequest) ‑> BoltResponse
    @@ -2208,7 +2208,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://api.slack.com/apis/connections/events-api for details of Events API. + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2240,7 +2240,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction)
    -

    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.

    +

    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2392,7 +2392,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://api.slack.com/events/message for details of `message` events. + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2443,7 +2443,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) -

    Refer to https://api.slack.com/events/message for details of message events.

    +

    Refer to https://docs.slack.dev/reference/events/message/ for details of message events.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2503,7 +2503,7 @@

    Args

    # Pass a function to this method app.middleware(middleware_func) - Refer to https://slack.dev/bolt-python/concepts#global-middleware for details. + Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2541,7 +2541,7 @@

    Args

    # Pass a function to this method app.middleware(middleware_func) -

    Refer to https://slack.dev/bolt-python/concepts#global-middleware for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2586,8 +2586,8 @@

    Args

    Refer to the following documents for details: - * https://api.slack.com/reference/block-kit/block-elements#external_select - * https://api.slack.com/reference/block-kit/block-elements#external_multi_select + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2627,8 +2627,8 @@

    Args

    Refer to the following documents for details:

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2674,7 +2674,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2711,7 +2711,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) -

    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.

    +

    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2797,7 +2797,7 @@

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new step from app listener. @@ -2815,7 +2815,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) - Refer to https://api.slack.com/workflows/steps for details of steps from apps. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2832,7 +2832,7 @@

    Args

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" ), category=DeprecationWarning, ) @@ -2854,7 +2854,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Registers a new step from app listener.

    Unlike others, this method doesn't behave as a decorator. If you want to register a step from app by a decorator, use WorkflowStepBuilder's methods.

    @@ -2869,7 +2869,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) -

    Refer to https://api.slack.com/workflows/steps for details of steps from apps.

    +

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    For further information about WorkflowStep specific function arguments such as configure, update, complete, and fail, @@ -2940,7 +2940,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2981,7 +2981,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) -

    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.

    +

    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -3010,7 +3010,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3020,7 +3020,7 @@

    Args

    return __call__

    Registers a new view_closed listener. -Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.

    def view_submission(self,
    constraints: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -3037,7 +3037,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3047,7 +3047,7 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.

    diff --git a/docs/reference/async_app.html b/docs/reference/async_app.html index ad9192253..707bfc3dd 100644 --- a/docs/reference/async_app.html +++ b/docs/reference/async_app.html @@ -205,10 +205,10 @@

    Class variables

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) - Refer to https://slack.dev/bolt-python/concepts#async for details. + Refer to https://docs.slack.dev/tools/bolt-python/concepts/async for details. If you would like to build an OAuth app for enabling the app to run with multiple workspaces, - refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app. + refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app. Args: logger: The custom logger that can be used in this app. @@ -778,7 +778,7 @@

    Class variables

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new step from app listener. @@ -796,7 +796,7 @@

    Class variables

    # Pass Step to set up listeners app.step(ws) - Refer to https://api.slack.com/workflows/steps for details of steps from apps. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. For further information about AsyncWorkflowStep specific function arguments @@ -812,7 +812,7 @@

    Class variables

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" ), category=DeprecationWarning, ) @@ -894,7 +894,7 @@

    Class variables

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://api.slack.com/apis/connections/events-api for details of Events API. + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -932,7 +932,7 @@

    Class variables

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://api.slack.com/events/message for details of `message` events. + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1047,7 +1047,7 @@

    Class variables

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1094,7 +1094,7 @@

    Class variables

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1162,9 +1162,9 @@

    Class variables

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. - * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. - * Refer to https://api.slack.com/dialogs for actions in dialogs. + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1190,7 +1190,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_actions` action listener. - Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. """ def __call__(*args, **kwargs): @@ -1207,7 +1207,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `interactive_message` action listener. - Refer to https://api.slack.com/legacy/message-buttons for details.""" + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1223,7 +1223,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1239,7 +1239,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1280,7 +1280,7 @@

    Class variables

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1306,7 +1306,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1322,7 +1322,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_closed` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1363,8 +1363,8 @@

    Class variables

    Refer to the following documents for details: - * https://api.slack.com/reference/block-kit/block-elements#external_select - * https://api.slack.com/reference/block-kit/block-elements#external_multi_select + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1404,7 +1404,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1564,9 +1564,9 @@

    Class variables

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) -

    Refer to https://slack.dev/bolt-python/concepts#async for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/concepts/async for details.

    If you would like to build an OAuth app for enabling the app to run with multiple workspaces, -refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app.

    +refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app.

    Args

    logger
    @@ -1760,9 +1760,9 @@

    Methods

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. - * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. - * Refer to https://api.slack.com/dialogs for actions in dialogs. + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1791,9 +1791,9 @@

    Methods

    app.action("approve_button")(update_message)

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -1964,7 +1964,7 @@

    Returns

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `interactive_message` action listener. - Refer to https://api.slack.com/legacy/message-buttons for details.""" + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1974,7 +1974,7 @@

    Returns

    return __call__

    Registers a new interactive_message action listener. -Refer to https://api.slack.com/legacy/message-buttons for details.

    +Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.

    def block_action(self,
    constraints: str | Pattern | Dict[str, str | Pattern],
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -1991,7 +1991,7 @@

    Returns

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_actions` action listener. - Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. """ def __call__(*args, **kwargs): @@ -2002,7 +2002,7 @@

    Returns

    return __call__

    Registers a new block_actions action listener. -Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.

    def block_suggestion(self,
    action_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -2056,7 +2056,7 @@

    Returns

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2087,7 +2087,7 @@

    Returns

    # Pass a function to this method app.command("/echo")(repeat_text)
    -

    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.

    +

    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2150,7 +2150,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2160,7 +2160,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def dialog_submission(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -2177,7 +2177,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2187,7 +2187,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def dialog_suggestion(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -2204,7 +2204,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2214,7 +2214,7 @@

    Args

    return __call__

    Registers a new dialog_suggestion listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def enable_token_revocation_listeners(self) ‑> None @@ -2320,7 +2320,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://api.slack.com/apis/connections/events-api for details of Events API. + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2352,7 +2352,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction)
    -

    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.

    +

    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2505,7 +2505,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://api.slack.com/events/message for details of `message` events. + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2559,7 +2559,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) -

    Refer to https://api.slack.com/events/message for details of message events.

    +

    Refer to https://docs.slack.dev/reference/events/message/ for details of message events.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2699,8 +2699,8 @@

    Args

    Refer to the following documents for details: - * https://api.slack.com/reference/block-kit/block-elements#external_select - * https://api.slack.com/reference/block-kit/block-elements#external_multi_select + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2740,8 +2740,8 @@

    Args

    Refer to the following documents for details:

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2830,7 +2830,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -2867,7 +2867,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) -

    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.

    +

    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -2930,7 +2930,7 @@

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new step from app listener. @@ -2948,7 +2948,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) - Refer to https://api.slack.com/workflows/steps for details of steps from apps. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. For further information about AsyncWorkflowStep specific function arguments @@ -2964,7 +2964,7 @@

    Args

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" ), category=DeprecationWarning, ) @@ -2986,7 +2986,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Registers a new step from app listener.

    Unlike others, this method doesn't behave as a decorator. If you want to register a step from app by a decorator, use AsyncWorkflowStepBuilder's methods.

    @@ -3001,7 +3001,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) -

    Refer to https://api.slack.com/workflows/steps for details of steps from apps.

    +

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document. For further information about AsyncWorkflowStep specific function arguments such as configure, update, complete, and fail, @@ -3069,7 +3069,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -3110,7 +3110,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) -

    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.

    +

    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

    Args

    @@ -3139,7 +3139,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_closed` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3149,7 +3149,7 @@

    Args

    return __call__

    Registers a new view_closed listener. -Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.

    def view_submission(self,
    constraints: str | Pattern,
    matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
    middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
    @@ -3166,7 +3166,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3176,7 +3176,7 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.

    def web_app(self, path: str = '/slack/events', port: int = 3000) ‑> aiohttp.web_app.Application diff --git a/docs/reference/authorization/index.html b/docs/reference/authorization/index.html index 64ca14f0e..19de311df 100644 --- a/docs/reference/authorization/index.html +++ b/docs/reference/authorization/index.html @@ -39,7 +39,7 @@

    Module slack_bolt.authorization

    Authorization is the process of determining which Slack credentials should be available while processing an incoming Slack event.

    -

    Refer to https://slack.dev/bolt-python/concepts#authorization for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/concepts/authorization for details.

    Sub-modules

    diff --git a/docs/reference/context/assistant/thread_context_store/file/index.html b/docs/reference/context/assistant/thread_context_store/file/index.html index 4a5d944e1..cbb4e4db6 100644 --- a/docs/reference/context/assistant/thread_context_store/file/index.html +++ b/docs/reference/context/assistant/thread_context_store/file/index.html @@ -48,7 +48,7 @@

    Classes

    class FileAssistantThreadContextStore -(base_dir: str = '/Users/wbergamin/.bolt-app-assistant-thread-contexts') +(base_dir: str = '/Users/eden.zimbelman/.bolt-app-assistant-thread-contexts')
    diff --git a/docs/reference/context/index.html b/docs/reference/context/index.html index 65cb8054c..c761aa47e 100644 --- a/docs/reference/context/index.html +++ b/docs/reference/context/index.html @@ -40,7 +40,7 @@

    Module slack_bolt.context

    All listeners have access to a context dictionary, which can be used to enrich events with additional information. Bolt automatically attaches information that is included in the incoming event, like user_id, team_id, channel_id, and enterprise_id.

    -

    Refer to https://slack.dev/bolt-python/concepts#context for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/concepts/context for details.

    Sub-modules

    diff --git a/docs/reference/index.html b/docs/reference/index.html index 430e36813..1ce8cd134 100644 --- a/docs/reference/index.html +++ b/docs/reference/index.html @@ -36,9 +36,9 @@

    Package slack_bolt

    -

    A Python framework to build Slack apps in a flash with the latest platform features.Read the getting started guide and look at our code examples to learn how to build apps using Bolt.

    +

    A Python framework to build Slack apps in a flash with the latest platform features.Read the getting started guide and look at our code examples to learn how to build apps using Bolt.

    @@ -257,10 +257,10 @@

    Class variables

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) - Refer to https://slack.dev/bolt-python/tutorial/getting-started for details. + Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details. If you would like to build an OAuth app for enabling the app to run with multiple workspaces, - refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app. + refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app. Args: logger: The custom logger that can be used in this app. @@ -769,7 +769,7 @@

    Class variables

    # Pass a function to this method app.middleware(middleware_func) - Refer to https://slack.dev/bolt-python/concepts#global-middleware for details. + Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -815,7 +815,7 @@

    Class variables

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new step from app listener. @@ -833,7 +833,7 @@

    Class variables

    # Pass Step to set up listeners app.step(ws) - Refer to https://api.slack.com/workflows/steps for details of steps from apps. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -850,7 +850,7 @@

    Class variables

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" ), category=DeprecationWarning, ) @@ -927,7 +927,7 @@

    Class variables

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://api.slack.com/apis/connections/events-api for details of Events API. + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -965,7 +965,7 @@

    Class variables

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://api.slack.com/events/message for details of `message` events. + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1076,7 +1076,7 @@

    Class variables

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1123,7 +1123,7 @@

    Class variables

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1191,9 +1191,9 @@

    Class variables

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. - * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. - * Refer to https://api.slack.com/dialogs for actions in dialogs. + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1219,7 +1219,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. - Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. """ def __call__(*args, **kwargs): @@ -1236,7 +1236,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. - Refer to https://api.slack.com/legacy/message-buttons for details.""" + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1252,7 +1252,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1268,7 +1268,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1309,7 +1309,7 @@

    Class variables

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1335,7 +1335,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1351,7 +1351,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1392,8 +1392,8 @@

    Class variables

    Refer to the following documents for details: - * https://api.slack.com/reference/block-kit/block-elements#external_select - * https://api.slack.com/reference/block-kit/block-elements#external_multi_select + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1433,7 +1433,7 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1583,9 +1583,9 @@

    Class variables

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000)))
    -

    Refer to https://slack.dev/bolt-python/tutorial/getting-started for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details.

    If you would like to build an OAuth app for enabling the app to run with multiple workspaces, -refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app.

    +refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app.

    Args

    logger
    @@ -1777,9 +1777,9 @@

    Methods

    # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. - * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. - * Refer to https://api.slack.com/dialogs for actions in dialogs. + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1808,9 +1808,9 @@

    Methods

    app.action("approve_button")(update_message)

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -1853,7 +1853,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. - Refer to https://api.slack.com/legacy/message-buttons for details.""" + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1863,7 +1863,7 @@

    Args

    return __call__

    Registers a new interactive_message action listener. -Refer to https://api.slack.com/legacy/message-buttons for details.

    +Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.

    def block_action(self,
    constraints: str | Pattern | Dict[str, str | Pattern],
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1880,7 +1880,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. - Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. """ def __call__(*args, **kwargs): @@ -1891,7 +1891,7 @@

    Args

    return __call__

    Registers a new block_actions action listener. -Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.

    def block_suggestion(self,
    action_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -1945,7 +1945,7 @@

    Args

    # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1976,7 +1976,7 @@

    Args

    # Pass a function to this method app.command("/echo")(repeat_text)
    -

    Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands.

    +

    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2039,7 +2039,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2049,7 +2049,7 @@

    Args

    return __call__

    Registers a new dialog_cancellation listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def dialog_submission(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -2066,7 +2066,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2076,7 +2076,7 @@

    Args

    return __call__

    Registers a new dialog_submission listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def dialog_suggestion(self,
    callback_id: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -2093,7 +2093,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -2103,7 +2103,7 @@

    Args

    return __call__

    Registers a new dialog_suggestion listener. -Refer to https://api.slack.com/dialogs for details.

    +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

    def dispatch(self,
    req: BoltRequest) ‑> BoltResponse
    @@ -2329,7 +2329,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://api.slack.com/apis/connections/events-api for details of Events API. + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2361,7 +2361,7 @@

    Args

    # Pass a function to this method app.event("team_join")(ask_for_introduction)
    -

    Refer to https://api.slack.com/apis/connections/events-api for details of Events API.

    +

    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2513,7 +2513,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://api.slack.com/events/message for details of `message` events. + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2564,7 +2564,7 @@

    Args

    # Pass a function to this method app.message(":wave:")(say_hello) -

    Refer to https://api.slack.com/events/message for details of message events.

    +

    Refer to https://docs.slack.dev/reference/events/message/ for details of message events.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2624,7 +2624,7 @@

    Args

    # Pass a function to this method app.middleware(middleware_func) - Refer to https://slack.dev/bolt-python/concepts#global-middleware for details. + Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2662,7 +2662,7 @@

    Args

    # Pass a function to this method app.middleware(middleware_func) -

    Refer to https://slack.dev/bolt-python/concepts#global-middleware for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2707,8 +2707,8 @@

    Args

    Refer to the following documents for details: - * https://api.slack.com/reference/block-kit/block-elements#external_select - * https://api.slack.com/reference/block-kit/block-elements#external_multi_select + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2748,8 +2748,8 @@

    Args

    Refer to the following documents for details:

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2795,7 +2795,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2832,7 +2832,7 @@

    Args

    # Pass a function to this method app.shortcut("open_modal")(open_modal) -

    Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts.

    +

    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -2918,7 +2918,7 @@

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new step from app listener. @@ -2936,7 +2936,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) - Refer to https://api.slack.com/workflows/steps for details of steps from apps. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -2953,7 +2953,7 @@

    Args

    warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" ), category=DeprecationWarning, ) @@ -2975,7 +2975,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Registers a new step from app listener.

    Unlike others, this method doesn't behave as a decorator. If you want to register a step from app by a decorator, use WorkflowStepBuilder's methods.

    @@ -2990,7 +2990,7 @@

    Args

    # Pass Step to set up listeners app.step(ws) -

    Refer to https://api.slack.com/workflows/steps for details of steps from apps.

    +

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    For further information about WorkflowStep specific function arguments such as configure, update, complete, and fail, @@ -3061,7 +3061,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -3102,7 +3102,7 @@

    Args

    # Pass a function to this method app.view("view_1")(handle_submission) -

    Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads.

    +

    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

    To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

    Args

    @@ -3131,7 +3131,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3141,7 +3141,7 @@

    Args

    return __call__

    Registers a new view_closed listener. -Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.

    def view_submission(self,
    constraints: str | Pattern,
    matchers: Sequence[Callable[..., bool]] | None = None,
    middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
    @@ -3158,7 +3158,7 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3168,7 +3168,7 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.

    @@ -5225,7 +5225,7 @@

    Class variables

    class FileAssistantThreadContextStore -(base_dir: str = '/Users/wbergamin/.bolt-app-assistant-thread-contexts') +(base_dir: str = '/Users/eden.zimbelman/.bolt-app-assistant-thread-contexts')
    diff --git a/docs/reference/lazy_listener/index.html b/docs/reference/lazy_listener/index.html index c2eb1c9b0..6bc17015e 100644 --- a/docs/reference/lazy_listener/index.html +++ b/docs/reference/lazy_listener/index.html @@ -56,7 +56,7 @@

    Module slack_bolt.lazy_listener

    lazy=[run_long_process] ) -

    Refer to https://slack.dev/bolt-python/concepts#lazy-listeners for more details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/concepts/lazy-listeners for more details.

    Sub-modules

    diff --git a/docs/reference/listener_matcher/builtins.html b/docs/reference/listener_matcher/builtins.html index d951deada..a5aff3d0b 100644 --- a/docs/reference/listener_matcher/builtins.html +++ b/docs/reference/listener_matcher/builtins.html @@ -80,7 +80,7 @@

    Functions

    return dialog_submission(constraints["callback_id"], asyncio) if action_type == "dialog_cancellation": return dialog_cancellation(constraints["callback_id"], asyncio) - # https://api.slack.com/workflows/steps + # https://docs.slack.dev/legacy/legacy-steps-from-apps/ if action_type == "workflow_step_edit": return workflow_step_edit(constraints["callback_id"], asyncio) diff --git a/docs/reference/logger/messages.html b/docs/reference/logger/messages.html index 85e0d94dd..1072e6479 100644 --- a/docs/reference/logger/messages.html +++ b/docs/reference/logger/messages.html @@ -303,7 +303,7 @@

    Functions

    "Bolt has enabled the file-based InstallationStore/OAuthStateStore for you. " "Note that these file-based stores are for local development. " "If you'd like to use a different data store, set the oauth_settings argument in the App constructor. " - "Please refer to https://slack.dev/bolt-python/concepts#authenticating-oauth for more details." + "Please refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth for more details." )
    diff --git a/docs/reference/middleware/async_builtins.html b/docs/reference/middleware/async_builtins.html index 7528dc0bb..d32deff15 100644 --- a/docs/reference/middleware/async_builtins.html +++ b/docs/reference/middleware/async_builtins.html @@ -205,7 +205,7 @@

    Inherited members

    """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. - Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details. + Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details. """ async def async_process( @@ -232,10 +232,10 @@

    Inherited members

    Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

    -

    Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

    +

    Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

    Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

    -

    Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

    +

    Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

    Args

    signing_secret
    @@ -293,12 +293,12 @@

    Inherited members

    A middleware can process request data before other middleware and listener functions.

    Handles ssl_check requests. -Refer to https://api.slack.com/interactivity/slash-commands for details.

    +Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details.

    Args

    verification_token
    The verification token to check -(optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation)
    +(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation)
    base_logger
    The base logger
    @@ -352,7 +352,7 @@

    Inherited members

    A middleware can process request data before other middleware and listener functions.

    Handles url_verification requests.

    -

    Refer to https://api.slack.com/events/url_verification for details.

    +

    Refer to https://docs.slack.dev/reference/events/url_verification/ for details.

    Args

    base_logger
    diff --git a/docs/reference/middleware/index.html b/docs/reference/middleware/index.html index 98aa15c5d..05d773415 100644 --- a/docs/reference/middleware/index.html +++ b/docs/reference/middleware/index.html @@ -639,7 +639,7 @@

    Inherited members

    """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. - Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details. + Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details. Args: signing_secret: The signing secret @@ -688,7 +688,7 @@

    Inherited members

    A middleware can process request data before other middleware and listener functions.

    Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

    -

    Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

    +

    Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

    Args

    signing_secret
    @@ -834,11 +834,11 @@

    Inherited members

    base_logger: Optional[Logger] = None, ): """Handles `ssl_check` requests. - Refer to https://api.slack.com/interactivity/slash-commands for details. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details. Args: verification_token: The verification token to check - (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) + (optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation) base_logger: The base logger """ # noqa: E501 self.verification_token = verification_token @@ -880,12 +880,12 @@

    Inherited members

    A middleware can process request data before other middleware and listener functions.

    Handles slack_bolt.middleware.ssl_check requests. -Refer to https://api.slack.com/interactivity/slash-commands for details.

    +Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details.

    Args

    verification_token
    The verification token to check -(optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation)
    +(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation)
    base_logger
    The base logger
    @@ -931,7 +931,7 @@

    Inherited members

    def __init__(self, base_logger: Optional[Logger] = None): """Handles url_verification requests. - Refer to https://api.slack.com/events/url_verification for details. + Refer to https://docs.slack.dev/reference/events/url_verification/ for details. Args: base_logger: The base logger @@ -965,7 +965,7 @@

    Inherited members

    A middleware can process request data before other middleware and listener functions.

    Handles url_verification requests.

    -

    Refer to https://api.slack.com/events/url_verification for details.

    +

    Refer to https://docs.slack.dev/reference/events/url_verification/ for details.

    Args

    base_logger
    diff --git a/docs/reference/middleware/request_verification/async_request_verification.html b/docs/reference/middleware/request_verification/async_request_verification.html index dc2b20908..192f77933 100644 --- a/docs/reference/middleware/request_verification/async_request_verification.html +++ b/docs/reference/middleware/request_verification/async_request_verification.html @@ -59,7 +59,7 @@

    Classes

    """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. - Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details. + Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details. """ async def async_process( @@ -86,10 +86,10 @@

    Classes

    Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

    -

    Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

    +

    Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

    Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

    -

    Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

    +

    Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

    Args

    signing_secret
    diff --git a/docs/reference/middleware/request_verification/index.html b/docs/reference/middleware/request_verification/index.html index ec8e6b941..5dfd6ed82 100644 --- a/docs/reference/middleware/request_verification/index.html +++ b/docs/reference/middleware/request_verification/index.html @@ -71,7 +71,7 @@

    Classes

    """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. - Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details. + Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details. Args: signing_secret: The signing secret @@ -120,7 +120,7 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

    -

    Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

    +

    Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

    Args

    signing_secret
    diff --git a/docs/reference/middleware/request_verification/request_verification.html b/docs/reference/middleware/request_verification/request_verification.html index aa5da095f..99134110a 100644 --- a/docs/reference/middleware/request_verification/request_verification.html +++ b/docs/reference/middleware/request_verification/request_verification.html @@ -60,7 +60,7 @@

    Classes

    """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. - Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details. + Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details. Args: signing_secret: The signing secret @@ -109,7 +109,7 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Verifies an incoming request by checking the validity of x-slack-signature, x-slack-request-timestamp, and its body data.

    -

    Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details.

    +

    Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

    Args

    signing_secret
    diff --git a/docs/reference/middleware/ssl_check/async_ssl_check.html b/docs/reference/middleware/ssl_check/async_ssl_check.html index eaacf0846..48c4bb599 100644 --- a/docs/reference/middleware/ssl_check/async_ssl_check.html +++ b/docs/reference/middleware/ssl_check/async_ssl_check.html @@ -75,12 +75,12 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Handles ssl_check requests. -Refer to https://api.slack.com/interactivity/slash-commands for details.

    +Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details.

    Args

    verification_token
    The verification token to check -(optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation)
    +(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation)
    base_logger
    The base logger
    diff --git a/docs/reference/middleware/ssl_check/index.html b/docs/reference/middleware/ssl_check/index.html index 6a6477071..6c1e4725e 100644 --- a/docs/reference/middleware/ssl_check/index.html +++ b/docs/reference/middleware/ssl_check/index.html @@ -76,11 +76,11 @@

    Classes

    base_logger: Optional[Logger] = None, ): """Handles `ssl_check` requests. - Refer to https://api.slack.com/interactivity/slash-commands for details. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details. Args: verification_token: The verification token to check - (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) + (optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation) base_logger: The base logger """ # noqa: E501 self.verification_token = verification_token @@ -122,12 +122,12 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Handles slack_bolt.middleware.ssl_check.ssl_check requests. -Refer to https://api.slack.com/interactivity/slash-commands for details.

    +Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details.

    Args

    verification_token
    The verification token to check -(optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation)
    +(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation)
    base_logger
    The base logger
    diff --git a/docs/reference/middleware/ssl_check/ssl_check.html b/docs/reference/middleware/ssl_check/ssl_check.html index 72b98724a..f90ad4d87 100644 --- a/docs/reference/middleware/ssl_check/ssl_check.html +++ b/docs/reference/middleware/ssl_check/ssl_check.html @@ -65,11 +65,11 @@

    Classes

    base_logger: Optional[Logger] = None, ): """Handles `ssl_check` requests. - Refer to https://api.slack.com/interactivity/slash-commands for details. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details. Args: verification_token: The verification token to check - (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) + (optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation) base_logger: The base logger """ # noqa: E501 self.verification_token = verification_token @@ -111,12 +111,12 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Handles ssl_check requests. -Refer to https://api.slack.com/interactivity/slash-commands for details.

    +Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details.

    Args

    verification_token
    The verification token to check -(optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation)
    +(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation)
    base_logger
    The base logger
    diff --git a/docs/reference/middleware/url_verification/async_url_verification.html b/docs/reference/middleware/url_verification/async_url_verification.html index e7fbb82fe..d1408052d 100644 --- a/docs/reference/middleware/url_verification/async_url_verification.html +++ b/docs/reference/middleware/url_verification/async_url_verification.html @@ -73,7 +73,7 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Handles url_verification requests.

    -

    Refer to https://api.slack.com/events/url_verification for details.

    +

    Refer to https://docs.slack.dev/reference/events/url_verification/ for details.

    Args

    base_logger
    diff --git a/docs/reference/middleware/url_verification/index.html b/docs/reference/middleware/url_verification/index.html index 9e08c1699..480c861d6 100644 --- a/docs/reference/middleware/url_verification/index.html +++ b/docs/reference/middleware/url_verification/index.html @@ -70,7 +70,7 @@

    Classes

    def __init__(self, base_logger: Optional[Logger] = None): """Handles url_verification requests. - Refer to https://api.slack.com/events/url_verification for details. + Refer to https://docs.slack.dev/reference/events/url_verification/ for details. Args: base_logger: The base logger @@ -104,7 +104,7 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Handles url_verification requests.

    -

    Refer to https://api.slack.com/events/url_verification for details.

    +

    Refer to https://docs.slack.dev/reference/events/url_verification/ for details.

    Args

    base_logger
    diff --git a/docs/reference/middleware/url_verification/url_verification.html b/docs/reference/middleware/url_verification/url_verification.html index e90bf0395..ff22c2986 100644 --- a/docs/reference/middleware/url_verification/url_verification.html +++ b/docs/reference/middleware/url_verification/url_verification.html @@ -59,7 +59,7 @@

    Classes

    def __init__(self, base_logger: Optional[Logger] = None): """Handles url_verification requests. - Refer to https://api.slack.com/events/url_verification for details. + Refer to https://docs.slack.dev/reference/events/url_verification/ for details. Args: base_logger: The base logger @@ -93,7 +93,7 @@

    Classes

    A middleware can process request data before other middleware and listener functions.

    Handles url_verification requests.

    -

    Refer to https://api.slack.com/events/url_verification for details.

    +

    Refer to https://docs.slack.dev/reference/events/url_verification/ for details.

    Args

    base_logger
    diff --git a/docs/reference/oauth/index.html b/docs/reference/oauth/index.html index d118a5e72..d53dc6a41 100644 --- a/docs/reference/oauth/index.html +++ b/docs/reference/oauth/index.html @@ -37,7 +37,7 @@

    Module slack_bolt.oauth

    Slack OAuth flow support for building an app that is installable in any workspaces.

    -

    Refer to https://slack.dev/bolt-python/concepts#authenticating-oauth for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth for details.

    Sub-modules

    diff --git a/docs/reference/request/index.html b/docs/reference/request/index.html index 06d9b4933..84cd15050 100644 --- a/docs/reference/request/index.html +++ b/docs/reference/request/index.html @@ -37,7 +37,7 @@

    Module slack_bolt.request

    Incoming request from Slack through either HTTP request or Socket Mode connection.

    -

    Refer to https://api.slack.com/apis/connections for the two types of connections. +

    Refer to https://docs.slack.dev/apis/events-api/ for the two types of connections. This interface encapsulates the difference between the two.

    diff --git a/docs/reference/response/index.html b/docs/reference/response/index.html index c986c7150..a4f4989ee 100644 --- a/docs/reference/response/index.html +++ b/docs/reference/response/index.html @@ -39,7 +39,7 @@

    Module slack_bolt.response

    This interface represents Bolt's synchronous response to Slack.

    In Socket Mode, the response data can be transformed to a WebSocket message. In the HTTP endpoint mode, the response data becomes an HTTP response data.

    -

    Refer to https://api.slack.com/apis/connections for the two types of connections.

    +

    Refer to https://docs.slack.dev/apis/events-api/ for the two types of connections.

    Sub-modules

    diff --git a/docs/reference/workflows/index.html b/docs/reference/workflows/index.html index caaffe74d..0dfe7457f 100644 --- a/docs/reference/workflows/index.html +++ b/docs/reference/workflows/index.html @@ -43,7 +43,7 @@

    Module slack_bolt.workflows

  • slack_bolt.workflows.step.utilities
  • slack_bolt.workflows.step.async_step (if you use asyncio-based AsyncApp)
  • -

    Refer to https://api.slack.com/workflows/steps for details.

    +

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    Sub-modules

    diff --git a/docs/reference/workflows/step/async_step.html b/docs/reference/workflows/step/async_step.html index 3bf597134..18fdd3ab9 100644 --- a/docs/reference/workflows/step/async_step.html +++ b/docs/reference/workflows/step/async_step.html @@ -78,7 +78,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Args: callback_id: The callback_id for this step from app @@ -124,7 +124,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ """ return AsyncWorkflowStepBuilder(callback_id, base_logger=base_logger) @@ -200,7 +200,7 @@

    Classes

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Args

    callback_id
    @@ -252,7 +252,7 @@

    Static methods

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    @@ -267,7 +267,7 @@

    Static methods

    class AsyncWorkflowStepBuilder:
         """Steps from apps
    -    Refer to https://api.slack.com/workflows/steps for details.
    +    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.
         """
     
         callback_id: Union[str, Pattern]
    @@ -285,7 +285,7 @@ 

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ This builder is supposed to be used as decorator. @@ -327,7 +327,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new edit listener with details. @@ -380,7 +380,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new save listener with details. @@ -433,7 +433,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new execute listener with details. @@ -480,7 +480,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -555,10 +555,10 @@

    Static methods

    return _middleware

    Steps from apps -Refer to https://api.slack.com/workflows/steps for details.

    +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    This builder is supposed to be used as decorator.

    my_step = AsyncWorkflowStep.builder("my_step")
     @my_step.edit
    @@ -659,7 +659,7 @@ 

    Methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -685,7 +685,7 @@

    Methods

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object.

    Returns

    @@ -709,7 +709,7 @@

    Returns

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new edit listener with details. @@ -754,7 +754,7 @@

    Returns

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Registers a new edit listener with details.

    You can use this method as decorator as well.

    @my_step.edit
    @@ -799,7 +799,7 @@ 

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new execute listener with details. @@ -844,7 +844,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Registers a new execute listener with details.

    You can use this method as decorator as well.

    @my_step.execute
    @@ -889,7 +889,7 @@ 

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new save listener with details. @@ -934,7 +934,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Registers a new save listener with details.

    You can use this method as decorator as well.

    @my_step.save
    diff --git a/docs/reference/workflows/step/index.html b/docs/reference/workflows/step/index.html
    index 62d989976..50b52906b 100644
    --- a/docs/reference/workflows/step/index.html
    +++ b/docs/reference/workflows/step/index.html
    @@ -174,7 +174,7 @@ 

    Classes

    ) app.step(ws) - Refer to https://api.slack.com/workflows/steps for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, callback_id: str, client: WebClient, body: dict): @@ -219,7 +219,7 @@

    Classes

    ) app.step(ws)
    -

    Refer to https://api.slack.com/workflows/steps for details.

    +

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    class Fail @@ -411,7 +411,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Args: callback_id: The callback_id for this step from app @@ -453,7 +453,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ """ return WorkflowStepBuilder( callback_id, @@ -546,7 +546,7 @@

    Classes

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Args

    callback_id
    @@ -598,7 +598,7 @@

    Static methods

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    diff --git a/docs/reference/workflows/step/step.html b/docs/reference/workflows/step/step.html index 6e1567bd6..0309acd88 100644 --- a/docs/reference/workflows/step/step.html +++ b/docs/reference/workflows/step/step.html @@ -78,7 +78,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Args: callback_id: The callback_id for this step from app @@ -120,7 +120,7 @@

    Classes

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ """ return WorkflowStepBuilder( callback_id, @@ -213,7 +213,7 @@

    Classes

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Args

    callback_id
    @@ -265,7 +265,7 @@

    Static methods

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    @@ -280,7 +280,7 @@

    Static methods

    class WorkflowStepBuilder:
         """Steps from apps
    -    Refer to https://api.slack.com/workflows/steps for details.
    +    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.
         """
     
         callback_id: Union[str, Pattern]
    @@ -298,7 +298,7 @@ 

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ This builder is supposed to be used as decorator. @@ -340,7 +340,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new edit listener with details. @@ -394,7 +394,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new save listener with details. @@ -447,7 +447,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new execute listener with details. @@ -494,7 +494,7 @@

    Static methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -584,10 +584,10 @@

    Static methods

    return _middleware

    Steps from apps -Refer to https://api.slack.com/workflows/steps for details.

    +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    This builder is supposed to be used as decorator.

    my_step = WorkflowStep.builder("my_step")
     @my_step.edit
    @@ -703,7 +703,7 @@ 

    Methods

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -729,7 +729,7 @@

    Methods

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object.

    Returns

    @@ -753,7 +753,7 @@

    Returns

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new edit listener with details. @@ -799,7 +799,7 @@

    Returns

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Registers a new edit listener with details.

    You can use this method as decorator as well.

    @my_step.edit
    @@ -844,7 +844,7 @@ 

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new execute listener with details. @@ -889,7 +889,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Registers a new execute listener with details.

    You can use this method as decorator as well.

    @my_step.execute
    @@ -934,7 +934,7 @@ 

    Args

    """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new save listener with details. @@ -979,7 +979,7 @@

    Args

    Deprecated

    Steps from apps for legacy workflows are now deprecated. -Use new custom steps: https://api.slack.com/automation/functions/custom-bolt

    +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

    Registers a new save listener with details.

    You can use this method as decorator as well.

    @my_step.save
    diff --git a/docs/reference/workflows/step/utilities/async_configure.html b/docs/reference/workflows/step/utilities/async_configure.html
    index 008c35ab5..10f236c47 100644
    --- a/docs/reference/workflows/step/utilities/async_configure.html
    +++ b/docs/reference/workflows/step/utilities/async_configure.html
    @@ -83,7 +83,7 @@ 

    Classes

    ) app.step(ws) - Refer to https://api.slack.com/workflows/steps for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, callback_id: str, client: AsyncWebClient, body: dict): @@ -131,7 +131,7 @@

    Classes

    ) app.step(ws)
    -

    Refer to https://api.slack.com/workflows/steps for details.

    +

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    diff --git a/docs/reference/workflows/step/utilities/configure.html b/docs/reference/workflows/step/utilities/configure.html index 26d646cf2..258bce312 100644 --- a/docs/reference/workflows/step/utilities/configure.html +++ b/docs/reference/workflows/step/utilities/configure.html @@ -83,7 +83,7 @@

    Classes

    ) app.step(ws) - Refer to https://api.slack.com/workflows/steps for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, callback_id: str, client: WebClient, body: dict): @@ -128,7 +128,7 @@

    Classes

    ) app.step(ws)
    -

    Refer to https://api.slack.com/workflows/steps for details.

    +

    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

    diff --git a/examples/aws_lambda/README.md b/examples/aws_lambda/README.md index 454f080a7..49a8f7da2 100644 --- a/examples/aws_lambda/README.md +++ b/examples/aws_lambda/README.md @@ -32,16 +32,16 @@ Instructions on how to set up and deploy each example are provided below. `lazy_aws_lambda_config.yaml` - Optionally enter a description for the role, such as "Bolt Python basic role" -3. Ensure you have created an app on api.slack.com/apps as per the [Getting - Started Guide](https://slack.dev/bolt-python/tutorial/getting-started). +3. Ensure you have created an app on api.slack.com/apps as per the + [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide. Ensure you have installed it to a workspace. 4. Ensure you have exported your Slack Bot Token and Slack Signing Secret for your apps as the environment variables `SLACK_BOT_TOKEN` and - `SLACK_SIGNING_SECRET`, respectively, as per the [Getting - Started Guide](https://slack.dev/bolt-python/tutorial/getting-started). + `SLACK_SIGNING_SECRET`, respectively, as per the + [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide. 5. You may want to create a dedicated virtual environment for this example app, as - per the "Setting up your project" section of the [Getting - Started Guide](https://slack.dev/bolt-python/tutorial/getting-started). + per the "Setting up your project" section of the + [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide. 6. Let's deploy the Lambda! Run `./deploy_lazy.sh`. By default it deploys to the us-east-1 region in AWS - you can change this at the top of `lazy_aws_lambda_config.yaml` if you wish. 7. Load up AWS Lambda inside the AWS Console - make sure you are in the correct @@ -150,7 +150,7 @@ Let’s create a user role that will use the custom policy we created as well as 3. "Create Role" ### Create Slack App and Load your Lambda to AWS -Ensure you have created an app on [api.slack.com/apps](https://api.slack.com/apps) as per the [Getting Started Guide](https://slack.dev/bolt-python/tutorial/getting-started). You do not need to ensure you have installed it to a workspace, as the OAuth flow will provide your app the ability to be installed by anyone. +Ensure you have created an app on [api.slack.com/apps](https://api.slack.com/apps) as per the [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide. You do not need to ensure you have installed it to a workspace, as the OAuth flow will provide your app the ability to be installed by anyone. 1. Remember those S3 buckets we made? You will need the names of these buckets again in the next step. 2. You need many environment variables exported! Specifically the following from api.slack.com/apps diff --git a/examples/django/README.md b/examples/django/README.md index c50681c5b..ca0460fd1 100644 --- a/examples/django/README.md +++ b/examples/django/README.md @@ -4,7 +4,7 @@ This example demonstrates how you can use Bolt for Python in your Django applica ### `simple_app` - Single-workspace App Example -If you want to run a simple app like the one you've tried in the [Getting Started Guide](https://slack.dev/bolt-python/tutorial/getting-started), this is the right one for you. By default, this Django project runs this application. If you want to switch to OAuth flow supported one, modify `myslackapp/urls.py`. +If you want to run a simple app like the one you've tried in the [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide, this is the right one for you. By default, this Django project runs this application. If you want to switch to OAuth flow supported one, modify `myslackapp/urls.py`. To run this app, all you need to do are: @@ -31,7 +31,7 @@ python manage.py migrate python manage.py runserver 0.0.0.0:3000 ``` -As you did at [Getting Started Guide](https://slack.dev/bolt-python/tutorial/getting-started), configure ngrok or something similar to serve a public endpoint. Lastly, +As you did at [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide, configure ngrok or something similar to serve a public endpoint. Lastly, * Go back to the Slack app configuration page * Go to "Event Subscriptions" @@ -54,7 +54,7 @@ To run this app, all you need to do are: * Create a new Slack app configuration at https://api.slack.com/apps?new_app=1 * Go to "OAuth & Permissions" * Add `app_mentions:read`, `chat:write` in Scopes > Bot Token Scopes -* Follow the instructions [here](https://slack.dev/bolt-python/concepts#authenticating-oauth) for configuring OAuth flow supported Slack apps +* Follow the instructions [here](https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth) for configuring OAuth flow supported Slack apps You can start your Django application this way: @@ -73,7 +73,7 @@ python manage.py migrate python manage.py runserver 0.0.0.0:3000 ``` -As you did at [Getting Started Guide](https://slack.dev/bolt-python/tutorial/getting-started), configure ngrok or something similar to serve a public endpoint. Lastly, +As you did at [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide, configure ngrok or something similar to serve a public endpoint. Lastly, * Go back to the Slack app configuration page * Go to "Event Subscriptions" diff --git a/examples/getting_started/README.md b/examples/getting_started/README.md index 63875a4dd..5d3c2f61d 100644 --- a/examples/getting_started/README.md +++ b/examples/getting_started/README.md @@ -1,5 +1,5 @@ # Getting Started with ⚡️ Bolt for Python -> Slack app example from 📚 [Getting started with Bolt for Python][1] +> Slack app example from 📚 [Building an App with Bolt for Python][1] ## Overview @@ -42,6 +42,6 @@ ngrok http 3000 python3 app.py ``` -[1]: https://slack.dev/bolt-python/tutorial/getting-started -[2]: https://slack.dev/bolt-python/ -[3]: https://slack.dev/bolt-python/tutorial/getting-started#setting-up-events +[1]: https://docs.slack.dev/tools/bolt-python/building-an-app +[2]: https://docs.slack.dev/tools/bolt-python/ +[3]: https://docs.slack.dev/tools/bolt-python/building-an-app#setting-up-events diff --git a/examples/message_events.py b/examples/message_events.py index 7658be276..3fd424060 100644 --- a/examples/message_events.py +++ b/examples/message_events.py @@ -32,7 +32,7 @@ def extract_subtype(body: dict, context: BoltContext, next: Callable): next() -# https://api.slack.com/events/message +# https://docs.slack.dev/reference/events/message/ # Newly posted messages only # or @app.event("message") @app.event({"type": "message", "subtype": None}) @@ -55,8 +55,8 @@ def detect_deletion(say: Say, body: dict): say(f"You've deleted a message: {text}") -# https://api.slack.com/events/message/file_share -# https://api.slack.com/events/message/bot_message +# https://docs.slack.dev/reference/events/message/file_share +# https://docs.slack.dev/reference/events/message/bot_message @app.event( event={"type": "message", "subtype": re.compile("(me_message)|(file_share)")}, middleware=[extract_subtype], diff --git a/examples/readme_app.py b/examples/readme_app.py index 963938658..fe81a0904 100644 --- a/examples/readme_app.py +++ b/examples/readme_app.py @@ -16,13 +16,13 @@ def log_request(logger, body, next): return next() -# Events API: https://api.slack.com/events-api +# Events API: https://docs.slack.dev/apis/events-api/ @app.event("app_mention") def event_test(say): say("What's up?") -# Interactivity: https://api.slack.com/interactivity +# Interactivity: https://docs.slack.dev/interactivity/ @app.shortcut("callback-id-here") # @app.command("/hello-bolt-python") def open_modal(ack, client, logger, body): diff --git a/examples/readme_async_app.py b/examples/readme_async_app.py index c43d3af32..f11d308a0 100644 --- a/examples/readme_async_app.py +++ b/examples/readme_async_app.py @@ -28,13 +28,13 @@ async def log_request(logger, body, next): return await next() -# Events API: https://api.slack.com/events-api +# Events API: https://docs.slack.dev/apis/events-api/ @app.event("app_mention") async def event_test(say): await say("What's up?") -# Interactivity: https://api.slack.com/interactivity +# Interactivity: https://docs.slack.dev/interactivity/ @app.shortcut("callback-id-here") # @app.command("/hello-bolt-python") async def open_modal(ack, client, logger, body): diff --git a/examples/workflow_steps/async_steps_from_apps.py b/examples/workflow_steps/async_steps_from_apps.py index 11566de6c..ed108cf5e 100644 --- a/examples/workflow_steps/async_steps_from_apps.py +++ b/examples/workflow_steps/async_steps_from_apps.py @@ -11,7 +11,7 @@ ################################################################################ # Steps from apps for legacy workflows are now deprecated. # -# Use new custom steps: https://api.slack.com/automation/functions/custom-bolt # +# Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ # ################################################################################ logging.basicConfig(level=logging.DEBUG) diff --git a/examples/workflow_steps/async_steps_from_apps_decorator.py b/examples/workflow_steps/async_steps_from_apps_decorator.py index 423048a47..e04884723 100644 --- a/examples/workflow_steps/async_steps_from_apps_decorator.py +++ b/examples/workflow_steps/async_steps_from_apps_decorator.py @@ -13,7 +13,7 @@ ################################################################################ # Steps from apps for legacy workflows are now deprecated. # -# Use new custom steps: https://api.slack.com/automation/functions/custom-bolt # +# Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ # ################################################################################ logging.basicConfig(level=logging.DEBUG) diff --git a/examples/workflow_steps/async_steps_from_apps_primitive.py b/examples/workflow_steps/async_steps_from_apps_primitive.py index 2e636e600..06a2956db 100644 --- a/examples/workflow_steps/async_steps_from_apps_primitive.py +++ b/examples/workflow_steps/async_steps_from_apps_primitive.py @@ -5,7 +5,7 @@ ################################################################################ # Steps from apps for legacy workflows are now deprecated. # -# Use new custom steps: https://api.slack.com/automation/functions/custom-bolt # +# Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ # ################################################################################ logging.basicConfig(level=logging.DEBUG) diff --git a/examples/workflow_steps/steps_from_apps.py b/examples/workflow_steps/steps_from_apps.py index efbb2ce65..b5f591700 100644 --- a/examples/workflow_steps/steps_from_apps.py +++ b/examples/workflow_steps/steps_from_apps.py @@ -8,7 +8,7 @@ ################################################################################ # Steps from apps for legacy workflows are now deprecated. # -# Use new custom steps: https://api.slack.com/automation/functions/custom-bolt # +# Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ # ################################################################################ logging.basicConfig(level=logging.DEBUG) diff --git a/examples/workflow_steps/steps_from_apps_decorator.py b/examples/workflow_steps/steps_from_apps_decorator.py index 64ddfcc20..1558e825a 100644 --- a/examples/workflow_steps/steps_from_apps_decorator.py +++ b/examples/workflow_steps/steps_from_apps_decorator.py @@ -9,7 +9,7 @@ ################################################################################ # Steps from apps for legacy workflows are now deprecated. # -# Use new custom steps: https://api.slack.com/automation/functions/custom-bolt # +# Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ # ################################################################################ logging.basicConfig(level=logging.DEBUG) diff --git a/examples/workflow_steps/steps_from_apps_primitive.py b/examples/workflow_steps/steps_from_apps_primitive.py index 6aa5a98bb..dd4231ba6 100644 --- a/examples/workflow_steps/steps_from_apps_primitive.py +++ b/examples/workflow_steps/steps_from_apps_primitive.py @@ -7,7 +7,7 @@ ################################################################################ # Steps from apps for legacy workflows are now deprecated. # -# Use new custom steps: https://api.slack.com/automation/functions/custom-bolt # +# Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ # ################################################################################ logging.basicConfig(level=logging.DEBUG) diff --git a/pyproject.toml b/pyproject.toml index 024ee6654..5a6523f35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = ["slack_sdk>=3.35.0,<4"] [project.urls] -Documentation = "https://slack.dev/bolt-python" +Documentation = "https://docs.slack.dev/tools/bolt-python/" [tool.setuptools.packages.find] include = ["slack_bolt*"] diff --git a/slack_bolt/__init__.py b/slack_bolt/__init__.py index 32ab76721..6331925f8 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -1,7 +1,7 @@ """ -A Python framework to build Slack apps in a flash with the latest platform features.Read the [getting started guide](https://slack.dev/bolt-python/tutorial/getting-started) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. +A Python framework to build Slack apps in a flash with the latest platform features.Read the [getting started guide](https://docs.slack.dev/tools/bolt-python/building-an-app) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. -* Website: https://slack.dev/bolt-python/ +* Website: https://docs.slack.dev/tools/bolt-python/ * GitHub repository: https://github.com/slackapi/bolt-python * The class representing a Bolt app: `slack_bolt.app.app` """ # noqa: E501 diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 60f20ea9e..5a7f32917 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -159,10 +159,10 @@ def message_hello(message, say): if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) - Refer to https://slack.dev/bolt-python/tutorial/getting-started for details. + Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details. If you would like to build an OAuth app for enabling the app to run with multiple workspaces, - refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app. + refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app. Args: logger: The custom logger that can be used in this app. @@ -671,7 +671,7 @@ def middleware_func(logger, body, next): # Pass a function to this method app.middleware(middleware_func) - Refer to https://slack.dev/bolt-python/concepts#global-middleware for details. + Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -717,7 +717,7 @@ def step( """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new step from app listener. @@ -735,7 +735,7 @@ def step( # Pass Step to set up listeners app.step(ws) - Refer to https://api.slack.com/workflows/steps for details of steps from apps. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -752,7 +752,7 @@ def step( warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" ), category=DeprecationWarning, ) @@ -829,7 +829,7 @@ def ask_for_introduction(event, say): # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://api.slack.com/apis/connections/events-api for details of Events API. + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -867,7 +867,7 @@ def say_hello(message, say): # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://api.slack.com/events/message for details of `message` events. + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -978,7 +978,7 @@ def repeat_text(ack, say, command): # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1025,7 +1025,7 @@ def open_modal(ack, body, client): # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1093,9 +1093,9 @@ def update_message(ack): # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. - * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. - * Refer to https://api.slack.com/dialogs for actions in dialogs. + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1121,7 +1121,7 @@ def block_action( middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `block_actions` action listener. - Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. """ def __call__(*args, **kwargs): @@ -1138,7 +1138,7 @@ def attachment_action( middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `interactive_message` action listener. - Refer to https://api.slack.com/legacy/message-buttons for details.""" + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1154,7 +1154,7 @@ def dialog_submission( middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1170,7 +1170,7 @@ def dialog_cancellation( middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_cancellation` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1211,7 +1211,7 @@ def handle_submission(ack, body, client, view): # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1237,7 +1237,9 @@ def view_submission( middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1253,7 +1255,7 @@ def view_closed( middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_closed` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1294,8 +1296,8 @@ def show_menu_options(ack): Refer to the following documents for details: - * https://api.slack.com/reference/block-kit/block-elements#external_select - * https://api.slack.com/reference/block-kit/block-elements#external_multi_select + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. @@ -1335,7 +1337,7 @@ def dialog_suggestion( middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 906359fcc..39f3c3c0e 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -165,10 +165,10 @@ async def message_hello(message, say): # async function if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) - Refer to https://slack.dev/bolt-python/concepts#async for details. + Refer to https://docs.slack.dev/tools/bolt-python/concepts/async for details. If you would like to build an OAuth app for enabling the app to run with multiple workspaces, - refer to https://slack.dev/bolt-python/concepts#authenticating-oauth to learn how to configure the app. + refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app. Args: logger: The custom logger that can be used in this app. @@ -738,7 +738,7 @@ def step( """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new step from app listener. @@ -756,7 +756,7 @@ def step( # Pass Step to set up listeners app.step(ws) - Refer to https://api.slack.com/workflows/steps for details of steps from apps. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. For further information about AsyncWorkflowStep specific function arguments @@ -772,7 +772,7 @@ def step( warnings.warn( ( "Steps from apps for legacy workflows are now deprecated. " - "Use new custom steps: https://api.slack.com/automation/functions/custom-bolt" + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" ), category=DeprecationWarning, ) @@ -854,7 +854,7 @@ async def ask_for_introduction(event, say): # Pass a function to this method app.event("team_join")(ask_for_introduction) - Refer to https://api.slack.com/apis/connections/events-api for details of Events API. + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -892,7 +892,7 @@ async def say_hello(message, say): # Pass a function to this method app.message(":wave:")(say_hello) - Refer to https://api.slack.com/events/message for details of `message` events. + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1007,7 +1007,7 @@ async def repeat_text(ack, say, command): # Pass a function to this method app.command("/echo")(repeat_text) - Refer to https://api.slack.com/interactivity/slash-commands for details of Slash Commands. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1054,7 +1054,7 @@ async def open_modal(ack, body, client): # Pass a function to this method app.shortcut("open_modal")(open_modal) - Refer to https://api.slack.com/interactivity/shortcuts for details about Shortcuts. + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1122,9 +1122,9 @@ async def update_message(ack): # Pass a function to this method app.action("approve_button")(update_message) - * Refer to https://api.slack.com/reference/interaction-payloads/block-actions for actions in `blocks`. - * Refer to https://api.slack.com/legacy/message-buttons for actions in `attachments`. - * Refer to https://api.slack.com/dialogs for actions in dialogs. + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1150,7 +1150,7 @@ def block_action( middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `block_actions` action listener. - Refer to https://api.slack.com/reference/interaction-payloads/block-actions for details. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. """ def __call__(*args, **kwargs): @@ -1167,7 +1167,7 @@ def attachment_action( middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `interactive_message` action listener. - Refer to https://api.slack.com/legacy/message-buttons for details.""" + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1183,7 +1183,7 @@ def dialog_submission( middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1199,7 +1199,7 @@ def dialog_cancellation( middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_submission` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1240,7 +1240,7 @@ async def handle_submission(ack, body, client, view): # Pass a function to this method app.view("view_1")(handle_submission) - Refer to https://api.slack.com/reference/interaction-payloads/views for details of payloads. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1266,7 +1266,9 @@ def view_submission( middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1282,7 +1284,7 @@ def view_closed( middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_closed` listener. - Refer to https://api.slack.com/reference/interaction-payloads/views#view_closed for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -1323,8 +1325,8 @@ async def show_menu_options(ack): Refer to the following documents for details: - * https://api.slack.com/reference/block-kit/block-elements#external_select - * https://api.slack.com/reference/block-kit/block-elements#external_multi_select + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. @@ -1364,7 +1366,7 @@ def dialog_suggestion( middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `dialog_suggestion` listener. - Refer to https://api.slack.com/dialogs for details.""" + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) diff --git a/slack_bolt/authorization/__init__.py b/slack_bolt/authorization/__init__.py index a936a866b..4b80a93bb 100644 --- a/slack_bolt/authorization/__init__.py +++ b/slack_bolt/authorization/__init__.py @@ -1,7 +1,7 @@ """Authorization is the process of determining which Slack credentials should be available while processing an incoming Slack event. -Refer to https://slack.dev/bolt-python/concepts#authorization for details. +Refer to https://docs.slack.dev/tools/bolt-python/concepts/authorization for details. """ from .authorize_result import AuthorizeResult diff --git a/slack_bolt/context/__init__.py b/slack_bolt/context/__init__.py index fb3337c7a..865825601 100644 --- a/slack_bolt/context/__init__.py +++ b/slack_bolt/context/__init__.py @@ -2,7 +2,7 @@ Bolt automatically attaches information that is included in the incoming event, like `user_id`, `team_id`, `channel_id`, and `enterprise_id`. -Refer to https://slack.dev/bolt-python/concepts#context for details. +Refer to https://docs.slack.dev/tools/bolt-python/concepts/context for details. """ # Don't add async module imports here diff --git a/slack_bolt/lazy_listener/__init__.py b/slack_bolt/lazy_listener/__init__.py index 4d9111cc3..a92c18483 100644 --- a/slack_bolt/lazy_listener/__init__.py +++ b/slack_bolt/lazy_listener/__init__.py @@ -19,7 +19,7 @@ def run_long_process(respond, body): lazy=[run_long_process] ) -Refer to https://slack.dev/bolt-python/concepts#lazy-listeners for more details. +Refer to https://docs.slack.dev/tools/bolt-python/concepts/lazy-listeners for more details. """ # Don't add async module imports here diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 57dbdf4f1..76c12d452 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -294,7 +294,7 @@ def func(body: Dict[str, Any]) -> bool: return dialog_submission(constraints["callback_id"], asyncio) if action_type == "dialog_cancellation": return dialog_cancellation(constraints["callback_id"], asyncio) - # https://api.slack.com/workflows/steps + # https://docs.slack.dev/legacy/legacy-steps-from-apps/ if action_type == "workflow_step_edit": return workflow_step_edit(constraints["callback_id"], asyncio) diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index d30f51acb..80e68d022 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -348,7 +348,7 @@ def info_default_oauth_settings_loaded() -> str: "Bolt has enabled the file-based InstallationStore/OAuthStateStore for you. " "Note that these file-based stores are for local development. " "If you'd like to use a different data store, set the oauth_settings argument in the App constructor. " - "Please refer to https://slack.dev/bolt-python/concepts#authenticating-oauth for more details." + "Please refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth for more details." ) diff --git a/slack_bolt/middleware/request_verification/async_request_verification.py b/slack_bolt/middleware/request_verification/async_request_verification.py index 68484bde0..3fb9e209b 100644 --- a/slack_bolt/middleware/request_verification/async_request_verification.py +++ b/slack_bolt/middleware/request_verification/async_request_verification.py @@ -10,7 +10,7 @@ class AsyncRequestVerification(RequestVerification, AsyncMiddleware): """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. - Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details. + Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details. """ async def async_process( diff --git a/slack_bolt/middleware/request_verification/request_verification.py b/slack_bolt/middleware/request_verification/request_verification.py index 5662dcf08..2cf7e361e 100644 --- a/slack_bolt/middleware/request_verification/request_verification.py +++ b/slack_bolt/middleware/request_verification/request_verification.py @@ -14,7 +14,7 @@ def __init__(self, signing_secret: str, base_logger: Optional[Logger] = None): """Verifies an incoming request by checking the validity of `x-slack-signature`, `x-slack-request-timestamp`, and its body data. - Refer to https://api.slack.com/authentication/verifying-requests-from-slack for details. + Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details. Args: signing_secret: The signing secret diff --git a/slack_bolt/middleware/ssl_check/ssl_check.py b/slack_bolt/middleware/ssl_check/ssl_check.py index d608e5c3d..88c5105ef 100644 --- a/slack_bolt/middleware/ssl_check/ssl_check.py +++ b/slack_bolt/middleware/ssl_check/ssl_check.py @@ -17,11 +17,11 @@ def __init__( base_logger: Optional[Logger] = None, ): """Handles `ssl_check` requests. - Refer to https://api.slack.com/interactivity/slash-commands for details. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details. Args: verification_token: The verification token to check - (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) + (optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation) base_logger: The base logger """ # noqa: E501 self.verification_token = verification_token diff --git a/slack_bolt/middleware/url_verification/url_verification.py b/slack_bolt/middleware/url_verification/url_verification.py index e59398fd7..7505c9c15 100644 --- a/slack_bolt/middleware/url_verification/url_verification.py +++ b/slack_bolt/middleware/url_verification/url_verification.py @@ -11,7 +11,7 @@ class UrlVerification(Middleware): def __init__(self, base_logger: Optional[Logger] = None): """Handles url_verification requests. - Refer to https://api.slack.com/events/url_verification for details. + Refer to https://docs.slack.dev/reference/events/url_verification/ for details. Args: base_logger: The base logger diff --git a/slack_bolt/oauth/__init__.py b/slack_bolt/oauth/__init__.py index c4f806698..0a5c3db07 100644 --- a/slack_bolt/oauth/__init__.py +++ b/slack_bolt/oauth/__init__.py @@ -1,6 +1,6 @@ """Slack OAuth flow support for building an app that is installable in any workspaces. -Refer to https://slack.dev/bolt-python/concepts#authenticating-oauth for details. +Refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth for details. """ # Don't add async module imports here diff --git a/slack_bolt/request/__init__.py b/slack_bolt/request/__init__.py index ee8b435a7..8610b6019 100644 --- a/slack_bolt/request/__init__.py +++ b/slack_bolt/request/__init__.py @@ -1,6 +1,6 @@ """Incoming request from Slack through either HTTP request or Socket Mode connection. -Refer to https://api.slack.com/apis/connections for the two types of connections. +Refer to https://docs.slack.dev/apis/events-api/ for the two types of connections. This interface encapsulates the difference between the two. """ diff --git a/slack_bolt/response/__init__.py b/slack_bolt/response/__init__.py index 373acccf2..c390b2d8e 100644 --- a/slack_bolt/response/__init__.py +++ b/slack_bolt/response/__init__.py @@ -3,7 +3,7 @@ In Socket Mode, the response data can be transformed to a WebSocket message. In the HTTP endpoint mode, the response data becomes an HTTP response data. -Refer to https://api.slack.com/apis/connections for the two types of connections. +Refer to https://docs.slack.dev/apis/events-api/ for the two types of connections. """ from .response import BoltResponse diff --git a/slack_bolt/workflows/__init__.py b/slack_bolt/workflows/__init__.py index 97e6ec765..c0f6d96b7 100644 --- a/slack_bolt/workflows/__init__.py +++ b/slack_bolt/workflows/__init__.py @@ -6,5 +6,5 @@ * `slack_bolt.workflows.step.utilities` * `slack_bolt.workflows.step.async_step` (if you use asyncio-based `AsyncApp`) -Refer to https://api.slack.com/workflows/steps for details. +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ diff --git a/slack_bolt/workflows/step/async_step.py b/slack_bolt/workflows/step/async_step.py index 250d2e900..7fa0ed858 100644 --- a/slack_bolt/workflows/step/async_step.py +++ b/slack_bolt/workflows/step/async_step.py @@ -29,7 +29,7 @@ class AsyncWorkflowStepBuilder: """Steps from apps - Refer to https://api.slack.com/workflows/steps for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ callback_id: Union[str, Pattern] @@ -47,7 +47,7 @@ def __init__( """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ This builder is supposed to be used as decorator. @@ -89,7 +89,7 @@ def edit( """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new edit listener with details. @@ -142,7 +142,7 @@ def save( """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new save listener with details. @@ -195,7 +195,7 @@ def execute( """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new execute listener with details. @@ -242,7 +242,7 @@ def build(self, base_logger: Optional[Logger] = None) -> "AsyncWorkflowStep": """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -340,7 +340,7 @@ def __init__( """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Args: callback_id: The callback_id for this step from app @@ -386,7 +386,7 @@ def builder( """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ """ return AsyncWorkflowStepBuilder(callback_id, base_logger=base_logger) diff --git a/slack_bolt/workflows/step/step.py b/slack_bolt/workflows/step/step.py index 7cdbb913c..4fca25717 100644 --- a/slack_bolt/workflows/step/step.py +++ b/slack_bolt/workflows/step/step.py @@ -24,7 +24,7 @@ class WorkflowStepBuilder: """Steps from apps - Refer to https://api.slack.com/workflows/steps for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ callback_id: Union[str, Pattern] @@ -42,7 +42,7 @@ def __init__( """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ This builder is supposed to be used as decorator. @@ -84,7 +84,7 @@ def edit( """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new edit listener with details. @@ -138,7 +138,7 @@ def save( """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new save listener with details. @@ -191,7 +191,7 @@ def execute( """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Registers a new execute listener with details. @@ -238,7 +238,7 @@ def build(self, base_logger: Optional[Logger] = None) -> "WorkflowStep": """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Constructs a WorkflowStep object. This method may raise an exception if the builder doesn't have enough configurations to build the object. @@ -351,7 +351,7 @@ def __init__( """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ Args: callback_id: The callback_id for this step from app @@ -393,7 +393,7 @@ def builder(cls, callback_id: Union[str, Pattern], base_logger: Optional[Logger] """ Deprecated: Steps from apps for legacy workflows are now deprecated. - Use new custom steps: https://api.slack.com/automation/functions/custom-bolt + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ """ return WorkflowStepBuilder( callback_id, diff --git a/slack_bolt/workflows/step/utilities/async_configure.py b/slack_bolt/workflows/step/utilities/async_configure.py index 721d5049c..5b9a7f9ae 100644 --- a/slack_bolt/workflows/step/utilities/async_configure.py +++ b/slack_bolt/workflows/step/utilities/async_configure.py @@ -32,7 +32,7 @@ async def edit(ack, step, configure): ) app.step(ws) - Refer to https://api.slack.com/workflows/steps for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, callback_id: str, client: AsyncWebClient, body: dict): diff --git a/slack_bolt/workflows/step/utilities/configure.py b/slack_bolt/workflows/step/utilities/configure.py index d44c8d0da..1280be8f7 100644 --- a/slack_bolt/workflows/step/utilities/configure.py +++ b/slack_bolt/workflows/step/utilities/configure.py @@ -32,7 +32,7 @@ def edit(ack, step, configure): ) app.step(ws) - Refer to https://api.slack.com/workflows/steps for details. + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. """ def __init__(self, *, callback_id: str, client: WebClient, body: dict): diff --git a/tests/scenario_tests/test_attachment_actions.py b/tests/scenario_tests/test_attachment_actions.py index fa40187ee..f40deb22b 100644 --- a/tests/scenario_tests/test_attachment_actions.py +++ b/tests/scenario_tests/test_attachment_actions.py @@ -164,7 +164,7 @@ def test_failure_2(self): assert_auth_test_count(self, 1) -# https://api.slack.com/legacy/interactive-messages +# https://docs.slack.dev/legacy/legacy-messaging/legacy-making-messages-interactive/ body = { "type": "interactive_message", "actions": [ diff --git a/tests/scenario_tests_async/test_attachment_actions.py b/tests/scenario_tests_async/test_attachment_actions.py index f6613837b..c9817dcd7 100644 --- a/tests/scenario_tests_async/test_attachment_actions.py +++ b/tests/scenario_tests_async/test_attachment_actions.py @@ -191,7 +191,7 @@ async def test_failure_2(self): await assert_auth_test_count_async(self, 1) -# https://api.slack.com/legacy/interactive-messages +# https://docs.slack.dev/legacy/legacy-messaging/legacy-making-messages-interactive/ body = { "type": "interactive_message", "actions": [ From fc5bbc109cad1dffa1496c92e6da4ed7c5aea5fd Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 6 Oct 2025 16:32:48 -0700 Subject: [PATCH 804/865] feat: add ai-enabled features text streaming methods, feedback blocks, and loading state (#1387) Co-authored-by: Luke Russell <31357343+lukegalbraithrussell@users.noreply.github.com> Co-authored-by: Maria Alejandra <104795114+srtaalej@users.noreply.github.com> Co-authored-by: Michael Brooks --- docs/english/concepts/ai-apps.md | 408 ++++++++++++++---- docs/english/concepts/message-sending.md | 61 ++- docs/reference/app/app.html | 11 +- docs/reference/app/async_app.html | 11 +- docs/reference/app/index.html | 11 +- docs/reference/async_app.html | 26 +- docs/reference/context/say/async_say.html | 2 + docs/reference/context/say/index.html | 2 + docs/reference/context/say/say.html | 2 + .../context/set_status/async_set_status.html | 11 +- docs/reference/context/set_status/index.html | 11 +- .../context/set_status/set_status.html | 11 +- .../async_set_suggested_prompts.html | 2 +- .../context/set_suggested_prompts/index.html | 2 +- .../set_suggested_prompts.html | 2 +- docs/reference/index.html | 26 +- pyproject.toml | 2 +- slack_bolt/context/say/async_say.py | 12 +- slack_bolt/context/say/say.py | 4 +- .../context/set_status/async_set_status.py | 13 +- slack_bolt/context/set_status/set_status.py | 13 +- .../async_set_suggested_prompts.py | 4 +- .../set_suggested_prompts.py | 4 +- tests/slack_bolt/context/test_say.py | 10 +- tests/slack_bolt/context/test_set_status.py | 38 ++ .../context/test_set_suggested_prompts.py | 37 ++ .../context/test_async_say.py | 13 +- .../context/test_async_set_status.py | 45 ++ .../test_async_set_suggested_prompts.py | 45 ++ 29 files changed, 693 insertions(+), 146 deletions(-) create mode 100644 tests/slack_bolt/context/test_set_status.py create mode 100644 tests/slack_bolt/context/test_set_suggested_prompts.py create mode 100644 tests/slack_bolt_async/context/test_async_set_status.py create mode 100644 tests/slack_bolt_async/context/test_async_set_suggested_prompts.py diff --git a/docs/english/concepts/ai-apps.md b/docs/english/concepts/ai-apps.md index b294c6688..44bd08df1 100644 --- a/docs/english/concepts/ai-apps.md +++ b/docs/english/concepts/ai-apps.md @@ -1,83 +1,195 @@ -# Using AI in Apps -:::info[This feature requires a paid plan] +# Using AI in Apps {#using-ai-in-apps} + +The Slack platform offers features tailored for AI agents and assistants. Your apps can [utilize the `Assistant` class](#assistant) for a side-panel view designed with AI in mind, or they can utilize features applicable to messages throughout Slack, like [chat streaming](#text-streaming) and [feedback buttons](#adding-and-handling-feedback). + +If you're unfamiliar with using these feature within Slack, you may want to read the [API documentation on the subject](/ai/). Then come back here to implement them with Bolt! + +## The `Assistant` class instance {#assistant} + +:::info[Some features within this guide require a paid plan] If you don't have a paid workspace for development, you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. ::: -The Agents & AI Apps feature comprises a unique messaging experience for Slack. If you're unfamiliar with using the Agents & AI Apps feature within Slack, you'll want to read the [API documentation on the subject](/ai/). Then come back here to implement them with Bolt! +The [`Assistant`](/tools/bolt-js/reference#the-assistantconfig-configuration-object) class can be used to handle the incoming events expected from a user interacting with an app in Slack that has the Agents & AI Apps feature enabled. -## Configuring your app to support AI features {#configuring-your-app} +A typical flow would look like: -1. Within [App Settings](https://api.slack.com/apps), enable the **Agents & AI Apps** feature. +1. [The user starts a thread](#handling-new-thread). The `Assistant` class handles the incoming [`assistant_thread_started`](/reference/events/assistant_thread_started) event. +2. [The thread context may change at any point](#handling-thread-context-changes). The `Assistant` class can handle any incoming [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) events. The class also provides a default `context` store to keep track of thread context changes as the user moves through Slack. +3. [The user responds](#handling-user-response). The `Assistant` class handles the incoming [`message.im`](/reference/events/message.im) event. -2. Within the App Settings **OAuth & Permissions** page, add the following scopes: -* [`assistant:write`](/reference/scopes/assistant.write) -* [`chat:write`](/reference/scopes/chat.write) -* [`im:history`](/reference/scopes/im.history) -3. Within the App Settings **Event Subscriptions** page, subscribe to the following events: -* [`assistant_thread_started`](/reference/events/assistant_thread_started) -* [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) -* [`message.im`](/reference/events/message.im) +```python +assistant = Assistant() -:::info[You _could_ implement your own AI app by [listening](event-listening) for the `assistant_thread_started`, `assistant_thread_context_changed`, and `message.im` events (see implementation details below).] +# This listener is invoked when a human user opened an assistant thread +@assistant.thread_started +def start_assistant_thread( + say: Say, + get_thread_context: GetThreadContext, + set_suggested_prompts: SetSuggestedPrompts, + logger: logging.Logger, +): + try: + ... -That being said, using the `Assistant` class will streamline the process. And we already wrote this nice guide for you! +# This listener is invoked when the human user sends a reply in the assistant thread +@assistant.user_message +def respond_in_assistant_thread( + client: WebClient, + context: BoltContext, + get_thread_context: GetThreadContext, + logger: logging.Logger, + payload: dict, + say: Say, + set_status: SetStatus, +): + try: + ... + +# Enable this assistant middleware in your Bolt app +app.use(assistant) +``` + +:::info[Consider the following] +You _could_ go it alone and [listen](/tools/bolt-python/concepts/event-listening) for the `assistant_thread_started`, `assistant_thread_context_changed`, and `message.im` events in order to implement the AI features in your app. That being said, using the `Assistant` class will streamline the process. And we already wrote this nice guide for you! +::: + +While the `assistant_thread_started` and `assistant_thread_context_changed` events do provide Slack-client thread context information, the `message.im` event does not. Any subsequent user message events won't contain thread context data. For that reason, Bolt not only provides a way to store thread context — the `threadContextStore` property — but it also provides a `DefaultThreadContextStore` instance that is utilized by default. This implementation relies on storing and retrieving [message metadata](/messaging/message-metadata/) as the user interacts with the app. -## The `Assistant` class instance {#assistant-class} +If you do provide your own `threadContextStore` property, it must feature `get` and `save` methods. + +:::tip[Refer to the [reference docs](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments.] +::: + +### Configuring your app to support the `Assistant` class {#configuring-assistant-class} + +1. Within [App Settings](https://api.slack.com/apps), enable the **Agents & AI Apps** feature. + +2. Within the App Settings **OAuth & Permissions** page, add the following scopes: + * [`assistant:write`](/reference/scopes/assistant.write) + * [`chat:write`](/reference/scopes/chat.write) + * [`im:history`](/reference/scopes/im.history) -The `Assistant` class can be used to handle the incoming events expected from a user interacting with an app in Slack that has the Agents & AI Apps feature enabled. A typical flow would look like: +3. Within the App Settings **Event Subscriptions** page, subscribe to the following events: + * [`assistant_thread_started`](/reference/events/assistant_thread_started) + * [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) + * [`message.im`](/reference/events/message.im) -1. [The user starts a thread](#handling-a-new-thread). The `Assistant` class handles the incoming [`assistant_thread_started`](/reference/events/assistant_thread_started) event. -2. [The thread context may change at any point](#handling-thread-context-changes). The `Assistant` class can handle any incoming [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) events. The class also provides a default context store to keep track of thread context changes as the user moves through Slack. -3. [The user responds](#handling-the-user-response). The `Assistant` class handles the incoming [`message.im`](/reference/events/message.im) event. +### Handling a new thread {#handling-new-thread} +When the user opens a new thread with your AI-enabled app, the [`assistant_thread_started`](/reference/events/assistant_thread_started) event will be sent to your app. + +:::tip[When a user opens an app thread while in a channel, the channel info is stored as the thread's `AssistantThreadContext` data.] + +You can grab that info by using the `get_thread_context` utility, as subsequent user message event payloads won't include the channel info. +::: ```python assistant = Assistant() -# This listener is invoked when a human user opened an assistant thread @assistant.thread_started -def start_assistant_thread(say: Say, set_suggested_prompts: SetSuggestedPrompts): - # Send the first reply to the human who started chat with your app's assistant bot - say(":wave: Hi, how can I help you today?") - - # Setting suggested prompts is optional - set_suggested_prompts( - prompts=[ - # If the suggested prompt is long, you can use {"title": "short one to display", "message": "full prompt"} instead - "What does SLACK stand for?", - "When Slack was released?", - ], - ) +def start_assistant_thread( + say: Say, + get_thread_context: GetThreadContext, + set_suggested_prompts: SetSuggestedPrompts, + logger: logging.Logger, +): + try: + say("How can I help you?") + + prompts: List[Dict[str, str]] = [ + { + "title": "Suggest names for my Slack app", + "message": "Can you suggest a few names for my Slack app? The app helps my teammates better organize information and plan priorities and action items.", + }, + ] + + thread_context = get_thread_context() + if thread_context is not None and thread_context.channel_id is not None: + summarize_channel = { + "title": "Summarize the referred channel", + "message": "Can you generate a brief summary of the referred channel?", + } + prompts.append(summarize_channel) + + set_suggested_prompts(prompts=prompts) + except Exception as e: + logger.exception(f"Failed to handle an assistant_thread_started event: {e}", e) + say(f":warning: Something went wrong! ({e})") +``` + +You can send more complex messages to the user — see [Sending Block Kit alongside messages](#block-kit-interactions) for more info. + +### Handling thread context changes {#handling-thread-context-changes} + +When the user switches channels, the [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) event will be sent to your app. + +If you use the built-in `Assistant` middleware without any custom configuration, the updated context data is automatically saved as [message metadata](/messaging/message-metadata/) of the first reply from the app. + +As long as you use the built-in approach, you don't need to store the context data within a datastore. The downside of this default behavior is the overhead of additional calls to the Slack API. These calls include those to `conversations.history`, which are used to look up the stored message metadata that contains the thread context (via `get_thread_context`). + +To store context elsewhere, pass a custom `AssistantThreadContextStore` implementation to the `Assistant` constructor. We provide `FileAssistantThreadContextStore`, which is a reference implementation that uses the local file system. Since this reference implementation relies on local files, it's not advised for use in production. For production apps, we recommend creating a class that inherits `AssistantThreadContextStore`. + +```python +from slack_bolt import FileAssistantThreadContextStore +assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) +``` + +### Handling the user response {#handling-user-response} + +When the user messages your app, the [`message.im`](/reference/events/message.im) event will be sent to your app. +Messages sent to the app do not contain a [subtype](/reference/events/message#subtypes) and must be deduced based on their shape and any provided [message metadata](/messaging/message-metadata/). + +There are three utilities that are particularly useful in curating the user experience: +* [`say`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.Say) +* [`setTitle`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.SetTitle) +* [`setStatus`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.SetStatus) + +Within the `setStatus` utility, you can cycle through strings passed into a `loading_messages` array. + +```python # This listener is invoked when the human user sends a reply in the assistant thread @assistant.user_message def respond_in_assistant_thread( - payload: dict, - logger: logging.Logger, - context: BoltContext, - set_status: SetStatus, client: WebClient, + context: BoltContext, + get_thread_context: GetThreadContext, + logger: logging.Logger, + payload: dict, say: Say, + set_status: SetStatus, ): try: - # Tell the human user the assistant bot acknowledges the request and is working on it - set_status("is typing...") + channel_id = payload["channel"] + team_id = payload["team"] + thread_ts = payload["thread_ts"] + user_id = payload["user"] + user_message = payload["text"] + + set_status( + status="thinking...", + loading_messages=[ + "Untangling the internet cables…", + "Consulting the office goldfish…", + "Convincing the AI to stop overthinking…", + ], + ) # Collect the conversation history with this user - replies_in_thread = client.conversations_replies( + replies = client.conversations_replies( channel=context.channel_id, ts=context.thread_ts, oldest=context.thread_ts, limit=10, ) messages_in_thread: List[Dict[str, str]] = [] - for message in replies_in_thread["messages"]: + for message in replies["messages"]: role = "user" if message.get("bot_id") is None else "assistant" messages_in_thread.append({"role": role, "content": message["text"]}) - # Pass the latest prompt and chat history to the LLM (call_llm is your own code) returned_message = call_llm(messages_in_thread) # Post the result in the assistant thread @@ -93,23 +205,7 @@ def respond_in_assistant_thread( app.use(assistant) ``` -While the `assistant_thread_started` and `assistant_thread_context_changed` events do provide Slack-client thread context information, the `message.im` event does not. Any subsequent user message events won't contain thread context data. For that reason, Bolt not only provides a way to store thread context — the `threadContextStore` property — but it also provides an instance that is utilized by default. This implementation relies on storing and retrieving [message metadata](/messaging/message-metadata/) as the user interacts with the app. - -If you do provide your own `threadContextStore` property, it must feature `get` and `save` methods. - -:::tip[Refer to the [module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments.] -::: - -## Handling a new thread {#handling-a-new-thread} - -When the user opens a new thread with your AI-enabled app, the [`assistant_thread_started`](/reference/events/assistant_thread_started) event will be sent to your app. - -:::tip[When a user opens an app thread while in a channel, the channel info is stored as the thread's `AssistantThreadContext` data.] - -You can grab that info by using the `get_thread_context` utility, as subsequent user message event payloads won't include the channel info. -::: - -### Block Kit interactions in the app thread {#block-kit-interactions} +### Sending Block Kit alongside messages {#block-kit-interactions} For advanced use cases, Block Kit buttons may be used instead of suggested prompts, as well as the sending of messages with structured [metadata](/messaging/message-metadata/) to trigger subsequent interactions with the user. @@ -235,52 +331,182 @@ def respond_to_bot_messages(logger: logging.Logger, set_status: SetStatus, say: ... ``` -## Handling thread context changes {#handling-thread-context-changes} +See the [_Adding and handling feedback_](#adding-and-handling-feedback) section for adding feedback buttons with Block Kit. -When the user switches channels, the [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) event will be sent to your app. +## Text streaming in messages {#text-streaming} -If you use the built-in `Assistant` middleware without any custom configuration, the updated context data is automatically saved as [message metadata](/messaging/message-metadata/) of the first reply from the app. +Three Web API methods work together to provide users a text streaming experience: -As long as you use the built-in approach, you don't need to store the context data within a datastore. The downside of this default behavior is the overhead of additional calls to the Slack API. These calls include those to `conversations.history`, which are used to look up the stored message metadata that contains the thread context (via `get_thread_context`). +* the [`chat.startStream`](/reference/methods/chat.startstream) method starts the text stream, +* the [`chat.appendStream`](/reference/methods/chat.appendstream) method appends text to the stream, and +* the [`chat.stopStream`](/reference/methods/chat.stopstream) method stops it. + +Since you're using Bolt for Python, built upon the Python Slack SDK, you can use the [`chat_stream()`](https://docs.slack.dev/tools/python-slack-sdk/reference/web/client.html#slack_sdk.web.client.WebClient.chat_stream) utility to streamline all three aspects of streaming in your app's messages. + +The following example uses OpenAI's streaming API with the new `chat_stream()` functionality, but you can substitute it with the AI client of your choice. -To store context elsewhere, pass a custom `AssistantThreadContextStore` implementation to the `Assistant` constructor. We provide `FileAssistantThreadContextStore`, which is a reference implementation that uses the local file system. Since this reference implementation relies on local files, it's not advised for use in production. For production apps, we recommend creating a class that inherits `AssistantThreadContextStore`. ```python -from slack_bolt import FileAssistantThreadContextStore -assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) -``` +import os +from typing import List, Dict + +import openai +from openai import Stream +from openai.types.responses import ResponseStreamEvent + +DEFAULT_SYSTEM_CONTENT = """ +You're an assistant in a Slack workspace. +Users in the workspace will ask you to help them write something or to think better about a specific topic. +You'll respond to those questions in a professional way. +When you include markdown text, convert them to Slack compatible ones. +When a prompt has Slack's special syntax like <@USER_ID> or <#CHANNEL_ID>, you must keep them as-is in your response. +""" + +def call_llm( + messages_in_thread: List[Dict[str, str]], + system_content: str = DEFAULT_SYSTEM_CONTENT, +) -> Stream[ResponseStreamEvent]: + openai_client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + messages = [{"role": "system", "content": system_content}] + messages.extend(messages_in_thread) + response = openai_client.responses.create(model="gpt-4o-mini", input=messages, stream=True) + return response + +@assistant.user_message +def respond_in_assistant_thread( + ... +): + try: + ... + replies = client.conversations_replies( + channel=context.channel_id, + ts=context.thread_ts, + oldest=context.thread_ts, + limit=10, + ) + messages_in_thread: List[Dict[str, str]] = [] + for message in replies["messages"]: + role = "user" if message.get("bot_id") is None else "assistant" + messages_in_thread.append({"role": role, "content": message["text"]}) -## Handling the user response {#handling-the-user-response} + returned_message = call_llm(messages_in_thread) -When the user messages your app, the [`message.im`](/reference/events/message.im) event will be sent to your app. + streamer = client.chat_stream( + channel=channel_id, + recipient_team_id=team_id, + recipient_user_id=user_id, + thread_ts=thread_ts, + ) -Messages sent to the app do not contain a [subtype](/reference/events/message#subtypes) and must be deduced based on their shape and any provided [message metadata](/messaging/message-metadata/). + # Loop over OpenAI response stream + # https://platform.openai.com/docs/api-reference/responses/create + for event in returned_message: + if event.type == "response.output_text.delta": + streamer.append(markdown_text=f"{event.delta}") + else: + continue -There are three utilities that are particularly useful in curating the user experience: -* [`say`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.Say) -* [`setTitle`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.SetTitle) -* [`setStatus`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.SetStatus) + streamer.stop() -```python -... -# This listener is invoked when the human user posts a reply -@assistant.user_message -def respond_to_user_messages(logger: logging.Logger, set_status: SetStatus, say: Say): - try: - set_status("is typing...") - say("Please use the buttons in the first reply instead :bow:") except Exception as e: - logger.exception(f"Failed to respond to an inquiry: {e}") - say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + logger.exception(f"Failed to handle a user message event: {e}") + say(f":warning: Something went wrong! ({e})") +``` -# Enable this assistant middleware in your Bolt app -app.use(assistant) +## Adding and handling feedback {#adding-and-handling-feedback} + +Use the [feedback buttons block element](/reference/block-kit/block-elements/feedback-buttons-element/) to allow users to immediately provide feedback regarding your app's responses. Here's a quick example: + +```py +from typing import List +from slack_sdk.models.blocks import Block, ContextActionsBlock, FeedbackButtonsElement, FeedbackButtonObject + + +def create_feedback_block() -> List[Block]: + """ + Create feedback block with thumbs up/down buttons + + Returns: + Block Kit context_actions block + """ + blocks: List[Block] = [ + ContextActionsBlock( + elements=[ + FeedbackButtonsElement( + action_id="feedback", + positive_button=FeedbackButtonObject( + text="Good Response", + accessibility_label="Submit positive feedback on this response", + value="good-feedback", + ), + negative_button=FeedbackButtonObject( + text="Bad Response", + accessibility_label="Submit negative feedback on this response", + value="bad-feedback", + ), + ) + ] + ) + ] + return blocks +``` + +Use the `chat_stream` utility to render the feedback block at the bottom of your app's message. + +```js +... + streamer = client.chat_stream( + channel=channel_id, + recipient_team_id=team_id, + recipient_user_id=user_id, + thread_ts=thread_ts, + ) + + # Loop over OpenAI response stream + # https://platform.openai.com/docs/api-reference/responses/create + for event in returned_message: + if event.type == "response.output_text.delta": + streamer.append(markdown_text=f"{event.delta}") + else: + continue + + feedback_block = create_feedback_block() + streamer.stop(blocks=feedback_block) +... ``` -## Full example: Assistant Template {#full-example} +Then add a response for when the user provides feedback. + +```python +# Handle feedback buttons (thumbs up/down) +def handle_feedback(ack, body, client, logger: logging.Logger): + try: + ack() + message_ts = body["message"]["ts"] + channel_id = body["channel"]["id"] + feedback_type = body["actions"][0]["value"] + is_positive = feedback_type == "good-feedback" + + if is_positive: + client.chat_postEphemeral( + channel=channel_id, + user=body["user"]["id"], + thread_ts=message_ts, + text="We're glad you found this useful.", + ) + else: + client.chat_postEphemeral( + channel=channel_id, + user=body["user"]["id"], + thread_ts=message_ts, + text="Sorry to hear that response wasn't up to par :slightly_frowning_face: Starting a new chat may help with AI mistakes and hallucinations.", + ) + + logger.debug(f"Handled feedback: type={feedback_type}, message_ts={message_ts}") + except Exception as error: + logger.error(f":warning: Something went wrong! {error}") +``` -Below is the `assistant.py` listener file of the [Assistant Template repo](https://github.com/slack-samples/bolt-python-assistant-template) we've created for you to build off of. +## Full example: App Agent Template {#app-agent-template} -```py reference title="assistant.py" -https://github.com/slack-samples/bolt-python-assistant-template/blob/main/listeners/assistant.py -``` \ No newline at end of file +Want to see the functionality described throughout this guide in action? We've created a [App Agent Template](https://github.com/slack-samples/bolt-python-assistant-template) repo for you to build off of. diff --git a/docs/english/concepts/message-sending.md b/docs/english/concepts/message-sending.md index 228a7b6b8..9741bb396 100644 --- a/docs/english/concepts/message-sending.md +++ b/docs/english/concepts/message-sending.md @@ -5,6 +5,7 @@ Within your listener function, `say()` is available whenever there is an associa In the case that you'd like to send a message outside of a listener or you want to do something more advanced (like handle specific errors), you can call `client.chat_postMessage` [using the client attached to your Bolt instance](/tools/bolt-python/concepts/web-api). Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + ```python # Listens for messages containing "knock knock" and responds with an italicized "who's there?" @app.message("knock knock") @@ -38,4 +39,62 @@ def show_datepicker(event, say): blocks=blocks, text="Pick a date for me to remind you" ) -``` \ No newline at end of file +``` + +## Streaming messages {#streaming-messages} + +You can have your app's messages stream in to replicate conventional AI chatbot behavior. This is done through three Web API methods: + +* [`chat_startStream`](/reference/methods/chat.startstream) +* [`chat_appendStream`](/reference/methods/chat.appendstream) +* [`chat_stopStream`](/reference/methods/chat.stopstream) + +The Python Slack SDK provides a [`chat_stream()`](https://docs.slack.dev/tools/python-slack-sdk/reference/web/client.html#slack_sdk.web.client.WebClient.chat_stream) helper utility to streamline calling these methods. Here's an excerpt from our [Assistant template app](https://github.com/slack-samples/bolt-python-assistant-template): + +```python +streamer = client.chat_stream( + channel=channel_id, + recipient_team_id=team_id, + recipient_user_id=user_id, + thread_ts=thread_ts, +) + +# Loop over OpenAI response stream +# https://platform.openai.com/docs/api-reference/responses/create +for event in returned_message: + if event.type == "response.output_text.delta": + streamer.append(markdown_text=f"{event.delta}") + else: + continue + +feedback_block = create_feedback_block() +streamer.stop(blocks=feedback_block) +``` + +In that example, a [feedback buttons](/reference/block-kit/block-elements/feedback-buttons-element) block element is passed to `streamer.stop` to provide feedback buttons to the user at the bottom of the message. Interaction with these buttons will send a block action event to your app to receive the feedback. + +```python +def create_feedback_block() -> List[Block]: + blocks: List[Block] = [ + ContextActionsBlock( + elements=[ + FeedbackButtonsElement( + action_id="feedback", + positive_button=FeedbackButtonObject( + text="Good Response", + accessibility_label="Submit positive feedback on this response", + value="good-feedback", + ), + negative_button=FeedbackButtonObject( + text="Bad Response", + accessibility_label="Submit negative feedback on this response", + value="bad-feedback", + ), + ) + ] + ) + ] + return blocks +``` + +For information on calling the `chat_*Stream` API methods without the helper utility, see the [_Sending streaming messages_](/tools/python-slack-sdk/web#sending-streaming-messages) section of the Python Slack SDK docs. \ No newline at end of file diff --git a/docs/reference/app/app.html b/docs/reference/app/app.html index 3ee02b07c..c91d020ef 100644 --- a/docs/reference/app/app.html +++ b/docs/reference/app/app.html @@ -1195,7 +1195,9 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3018,7 +3020,9 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3028,7 +3032,8 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for +details.

    diff --git a/docs/reference/app/async_app.html b/docs/reference/app/async_app.html index 78959986b..9cbc801d0 100644 --- a/docs/reference/app/async_app.html +++ b/docs/reference/app/async_app.html @@ -1215,7 +1215,9 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3075,7 +3077,9 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3085,7 +3089,8 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for +details.

    def web_app(self, path: str = '/slack/events', port: int = 3000) ‑> aiohttp.web_app.Application diff --git a/docs/reference/app/index.html b/docs/reference/app/index.html index a46bc2e71..8821e5af9 100644 --- a/docs/reference/app/index.html +++ b/docs/reference/app/index.html @@ -1214,7 +1214,9 @@

    Classes

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3037,7 +3039,9 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3047,7 +3051,8 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for +details.

    diff --git a/docs/reference/async_app.html b/docs/reference/async_app.html index 707bfc3dd..8fd975be9 100644 --- a/docs/reference/async_app.html +++ b/docs/reference/async_app.html @@ -1306,7 +1306,9 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3166,7 +3168,9 @@

    Args

    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3176,7 +3180,8 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for +details.

    def web_app(self, path: str = '/slack/events', port: int = 3000) ‑> aiohttp.web_app.Application @@ -5158,6 +5163,7 @@

    Class variables

    icon_emoji: Optional[str] = None, icon_url: Optional[str] = None, username: Optional[str] = None, + markdown_text: Optional[str] = None, mrkdwn: Optional[bool] = None, link_names: Optional[bool] = None, parse: Optional[str] = None, # none, full @@ -5183,6 +5189,7 @@

    Class variables

    icon_emoji=icon_emoji, icon_url=icon_url, username=username, + markdown_text=markdown_text, mrkdwn=mrkdwn, link_names=link_names, parse=parse, @@ -5248,11 +5255,18 @@

    Class variables

    self.channel_id = channel_id self.thread_ts = thread_ts - async def __call__(self, status: str) -> AsyncSlackResponse: + async def __call__( + self, + status: str, + loading_messages: Optional[List[str]] = None, + **kwargs, + ) -> AsyncSlackResponse: return await self.client.assistant_threads_setStatus( - status=status, channel_id=self.channel_id, thread_ts=self.thread_ts, + status=status, + loading_messages=loading_messages, + **kwargs, )
    @@ -5298,7 +5312,7 @@

    Class variables

    async def __call__( self, - prompts: List[Union[str, Dict[str, str]]], + prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, ) -> AsyncSlackResponse: prompts_arg: List[Dict[str, str]] = [] diff --git a/docs/reference/context/say/async_say.html b/docs/reference/context/say/async_say.html index 8547a1188..e170251fe 100644 --- a/docs/reference/context/say/async_say.html +++ b/docs/reference/context/say/async_say.html @@ -87,6 +87,7 @@

    Classes

    icon_emoji: Optional[str] = None, icon_url: Optional[str] = None, username: Optional[str] = None, + markdown_text: Optional[str] = None, mrkdwn: Optional[bool] = None, link_names: Optional[bool] = None, parse: Optional[str] = None, # none, full @@ -112,6 +113,7 @@

    Classes

    icon_emoji=icon_emoji, icon_url=icon_url, username=username, + markdown_text=markdown_text, mrkdwn=mrkdwn, link_names=link_names, parse=parse, diff --git a/docs/reference/context/say/index.html b/docs/reference/context/say/index.html index 7a5850760..e2ed0d03f 100644 --- a/docs/reference/context/say/index.html +++ b/docs/reference/context/say/index.html @@ -105,6 +105,7 @@

    Classes

    icon_emoji: Optional[str] = None, icon_url: Optional[str] = None, username: Optional[str] = None, + markdown_text: Optional[str] = None, mrkdwn: Optional[bool] = None, link_names: Optional[bool] = None, parse: Optional[str] = None, # none, full @@ -130,6 +131,7 @@

    Classes

    icon_emoji=icon_emoji, icon_url=icon_url, username=username, + markdown_text=markdown_text, mrkdwn=mrkdwn, link_names=link_names, parse=parse, diff --git a/docs/reference/context/say/say.html b/docs/reference/context/say/say.html index 5db4f24ba..c66e2776f 100644 --- a/docs/reference/context/say/say.html +++ b/docs/reference/context/say/say.html @@ -90,6 +90,7 @@

    Classes

    icon_emoji: Optional[str] = None, icon_url: Optional[str] = None, username: Optional[str] = None, + markdown_text: Optional[str] = None, mrkdwn: Optional[bool] = None, link_names: Optional[bool] = None, parse: Optional[str] = None, # none, full @@ -115,6 +116,7 @@

    Classes

    icon_emoji=icon_emoji, icon_url=icon_url, username=username, + markdown_text=markdown_text, mrkdwn=mrkdwn, link_names=link_names, parse=parse, diff --git a/docs/reference/context/set_status/async_set_status.html b/docs/reference/context/set_status/async_set_status.html index 6a15d70ae..06efd6447 100644 --- a/docs/reference/context/set_status/async_set_status.html +++ b/docs/reference/context/set_status/async_set_status.html @@ -70,11 +70,18 @@

    Classes

    self.channel_id = channel_id self.thread_ts = thread_ts - async def __call__(self, status: str) -> AsyncSlackResponse: + async def __call__( + self, + status: str, + loading_messages: Optional[List[str]] = None, + **kwargs, + ) -> AsyncSlackResponse: return await self.client.assistant_threads_setStatus( - status=status, channel_id=self.channel_id, thread_ts=self.thread_ts, + status=status, + loading_messages=loading_messages, + **kwargs, )
    diff --git a/docs/reference/context/set_status/index.html b/docs/reference/context/set_status/index.html index 9e53da9a5..aa11815e3 100644 --- a/docs/reference/context/set_status/index.html +++ b/docs/reference/context/set_status/index.html @@ -81,11 +81,18 @@

    Classes

    self.channel_id = channel_id self.thread_ts = thread_ts - def __call__(self, status: str) -> SlackResponse: + def __call__( + self, + status: str, + loading_messages: Optional[List[str]] = None, + **kwargs, + ) -> SlackResponse: return self.client.assistant_threads_setStatus( - status=status, channel_id=self.channel_id, thread_ts=self.thread_ts, + status=status, + loading_messages=loading_messages, + **kwargs, )
    diff --git a/docs/reference/context/set_status/set_status.html b/docs/reference/context/set_status/set_status.html index 0ec8df5da..e4d839f64 100644 --- a/docs/reference/context/set_status/set_status.html +++ b/docs/reference/context/set_status/set_status.html @@ -70,11 +70,18 @@

    Classes

    self.channel_id = channel_id self.thread_ts = thread_ts - def __call__(self, status: str) -> SlackResponse: + def __call__( + self, + status: str, + loading_messages: Optional[List[str]] = None, + **kwargs, + ) -> SlackResponse: return self.client.assistant_threads_setStatus( - status=status, channel_id=self.channel_id, thread_ts=self.thread_ts, + status=status, + loading_messages=loading_messages, + **kwargs, )
    diff --git a/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html b/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html index 449a72117..4feda52ba 100644 --- a/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html +++ b/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html @@ -72,7 +72,7 @@

    Classes

    async def __call__( self, - prompts: List[Union[str, Dict[str, str]]], + prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, ) -> AsyncSlackResponse: prompts_arg: List[Dict[str, str]] = [] diff --git a/docs/reference/context/set_suggested_prompts/index.html b/docs/reference/context/set_suggested_prompts/index.html index ee5371cea..12d864dde 100644 --- a/docs/reference/context/set_suggested_prompts/index.html +++ b/docs/reference/context/set_suggested_prompts/index.html @@ -83,7 +83,7 @@

    Classes

    def __call__( self, - prompts: List[Union[str, Dict[str, str]]], + prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, ) -> SlackResponse: prompts_arg: List[Dict[str, str]] = [] diff --git a/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html b/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html index 133d3a55a..6c0385e57 100644 --- a/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html +++ b/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html @@ -72,7 +72,7 @@

    Classes

    def __call__( self, - prompts: List[Union[str, Dict[str, str]]], + prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, ) -> SlackResponse: prompts_arg: List[Dict[str, str]] = [] diff --git a/docs/reference/index.html b/docs/reference/index.html index 1ce8cd134..7bd6d117e 100644 --- a/docs/reference/index.html +++ b/docs/reference/index.html @@ -1335,7 +1335,9 @@

    Class variables

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3158,7 +3160,9 @@

    Args

    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new `view_submission` listener. - Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.""" + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) @@ -3168,7 +3172,8 @@

    Args

    return __call__

    Registers a new view_submission listener. -Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for details.

    +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for +details.

    @@ -5691,6 +5696,7 @@

    Class variables

    icon_emoji: Optional[str] = None, icon_url: Optional[str] = None, username: Optional[str] = None, + markdown_text: Optional[str] = None, mrkdwn: Optional[bool] = None, link_names: Optional[bool] = None, parse: Optional[str] = None, # none, full @@ -5716,6 +5722,7 @@

    Class variables

    icon_emoji=icon_emoji, icon_url=icon_url, username=username, + markdown_text=markdown_text, mrkdwn=mrkdwn, link_names=link_names, parse=parse, @@ -5786,11 +5793,18 @@

    Class variables

    self.channel_id = channel_id self.thread_ts = thread_ts - def __call__(self, status: str) -> SlackResponse: + def __call__( + self, + status: str, + loading_messages: Optional[List[str]] = None, + **kwargs, + ) -> SlackResponse: return self.client.assistant_threads_setStatus( - status=status, channel_id=self.channel_id, thread_ts=self.thread_ts, + status=status, + loading_messages=loading_messages, + **kwargs, )
    @@ -5836,7 +5850,7 @@

    Class variables

    def __call__( self, - prompts: List[Union[str, Dict[str, str]]], + prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, ) -> SlackResponse: prompts_arg: List[Dict[str, str]] = [] diff --git a/pyproject.toml b/pyproject.toml index 5a6523f35..5361ef1b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Operating System :: OS Independent", ] requires-python = ">=3.7" -dependencies = ["slack_sdk>=3.35.0,<4"] +dependencies = ["slack_sdk>=3.37.0,<4"] [project.urls] diff --git a/slack_bolt/context/say/async_say.py b/slack_bolt/context/say/async_say.py index b771529b0..c492e5d77 100644 --- a/slack_bolt/context/say/async_say.py +++ b/slack_bolt/context/say/async_say.py @@ -1,14 +1,14 @@ -from typing import Optional, Union, Dict, Sequence, Callable, Awaitable +from typing import Awaitable, Callable, Dict, Optional, Sequence, Union -from slack_sdk.models.metadata import Metadata - -from slack_bolt.context.say.internals import _can_say -from slack_bolt.util.utils import create_copy from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block +from slack_sdk.models.metadata import Metadata from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse +from slack_bolt.context.say.internals import _can_say +from slack_bolt.util.utils import create_copy + class AsyncSay: client: Optional[AsyncWebClient] @@ -42,6 +42,7 @@ async def __call__( icon_emoji: Optional[str] = None, icon_url: Optional[str] = None, username: Optional[str] = None, + markdown_text: Optional[str] = None, mrkdwn: Optional[bool] = None, link_names: Optional[bool] = None, parse: Optional[str] = None, # none, full @@ -67,6 +68,7 @@ async def __call__( icon_emoji=icon_emoji, icon_url=icon_url, username=username, + markdown_text=markdown_text, mrkdwn=mrkdwn, link_names=link_names, parse=parse, diff --git a/slack_bolt/context/say/say.py b/slack_bolt/context/say/say.py index 6cfbcd801..a6e5904e3 100644 --- a/slack_bolt/context/say/say.py +++ b/slack_bolt/context/say/say.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, Dict, Sequence, Callable +from typing import Callable, Dict, Optional, Sequence, Union from slack_sdk import WebClient from slack_sdk.models.attachments import Attachment @@ -45,6 +45,7 @@ def __call__( icon_emoji: Optional[str] = None, icon_url: Optional[str] = None, username: Optional[str] = None, + markdown_text: Optional[str] = None, mrkdwn: Optional[bool] = None, link_names: Optional[bool] = None, parse: Optional[str] = None, # none, full @@ -70,6 +71,7 @@ def __call__( icon_emoji=icon_emoji, icon_url=icon_url, username=username, + markdown_text=markdown_text, mrkdwn=mrkdwn, link_names=link_names, parse=parse, diff --git a/slack_bolt/context/set_status/async_set_status.py b/slack_bolt/context/set_status/async_set_status.py index 926ec6de8..e2c451f46 100644 --- a/slack_bolt/context/set_status/async_set_status.py +++ b/slack_bolt/context/set_status/async_set_status.py @@ -1,3 +1,5 @@ +from typing import List, Optional + from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse @@ -17,9 +19,16 @@ def __init__( self.channel_id = channel_id self.thread_ts = thread_ts - async def __call__(self, status: str) -> AsyncSlackResponse: + async def __call__( + self, + status: str, + loading_messages: Optional[List[str]] = None, + **kwargs, + ) -> AsyncSlackResponse: return await self.client.assistant_threads_setStatus( - status=status, channel_id=self.channel_id, thread_ts=self.thread_ts, + status=status, + loading_messages=loading_messages, + **kwargs, ) diff --git a/slack_bolt/context/set_status/set_status.py b/slack_bolt/context/set_status/set_status.py index 8df0d49a7..0ed612e16 100644 --- a/slack_bolt/context/set_status/set_status.py +++ b/slack_bolt/context/set_status/set_status.py @@ -1,3 +1,5 @@ +from typing import List, Optional + from slack_sdk import WebClient from slack_sdk.web import SlackResponse @@ -17,9 +19,16 @@ def __init__( self.channel_id = channel_id self.thread_ts = thread_ts - def __call__(self, status: str) -> SlackResponse: + def __call__( + self, + status: str, + loading_messages: Optional[List[str]] = None, + **kwargs, + ) -> SlackResponse: return self.client.assistant_threads_setStatus( - status=status, channel_id=self.channel_id, thread_ts=self.thread_ts, + status=status, + loading_messages=loading_messages, + **kwargs, ) diff --git a/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py b/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py index aeeb244d7..2079b6448 100644 --- a/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py +++ b/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py @@ -1,4 +1,4 @@ -from typing import List, Dict, Union, Optional +from typing import Dict, List, Optional, Sequence, Union from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse @@ -21,7 +21,7 @@ def __init__( async def __call__( self, - prompts: List[Union[str, Dict[str, str]]], + prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, ) -> AsyncSlackResponse: prompts_arg: List[Dict[str, str]] = [] diff --git a/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py b/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py index fc9304b17..21ff815e1 100644 --- a/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py +++ b/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py @@ -1,4 +1,4 @@ -from typing import List, Dict, Union, Optional +from typing import Dict, List, Optional, Sequence, Union from slack_sdk import WebClient from slack_sdk.web import SlackResponse @@ -21,7 +21,7 @@ def __init__( def __call__( self, - prompts: List[Union[str, Dict[str, str]]], + prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, ) -> SlackResponse: prompts_arg: List[Dict[str, str]] = [] diff --git a/tests/slack_bolt/context/test_say.py b/tests/slack_bolt/context/test_say.py index 9e465e5d5..6ca1fc96a 100644 --- a/tests/slack_bolt/context/test_say.py +++ b/tests/slack_bolt/context/test_say.py @@ -3,10 +3,7 @@ from slack_sdk.web import SlackResponse from slack_bolt import Say -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server class TestSay: @@ -24,6 +21,11 @@ def test_say(self): response: SlackResponse = say(text="Hi there!") assert response.status_code == 200 + def test_say_markdown_text(self): + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say(markdown_text="**Greetings!**") + assert response.status_code == 200 + def test_say_unfurl_options(self): say = Say(client=self.web_client, channel="C111") response: SlackResponse = say(text="Hi there!", unfurl_media=True, unfurl_links=True) diff --git a/tests/slack_bolt/context/test_set_status.py b/tests/slack_bolt/context/test_set_status.py new file mode 100644 index 000000000..fe998df5e --- /dev/null +++ b/tests/slack_bolt/context/test_set_status.py @@ -0,0 +1,38 @@ +import pytest +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt.context.set_status import SetStatus +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server + + +class TestSetStatus: + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_set_status(self): + set_status = SetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: SlackResponse = set_status("Thinking...") + assert response.status_code == 200 + + def test_set_status_loading_messages(self): + set_status = SetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: SlackResponse = set_status( + status="Thinking...", + loading_messages=[ + "Sitting...", + "Waiting...", + ], + ) + assert response.status_code == 200 + + def test_set_status_invalid(self): + set_status = SetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + with pytest.raises(TypeError): + set_status() diff --git a/tests/slack_bolt/context/test_set_suggested_prompts.py b/tests/slack_bolt/context/test_set_suggested_prompts.py new file mode 100644 index 000000000..792b974b5 --- /dev/null +++ b/tests/slack_bolt/context/test_set_suggested_prompts.py @@ -0,0 +1,37 @@ +import pytest +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt.context.set_suggested_prompts import SetSuggestedPrompts +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server + + +class TestSetSuggestedPrompts: + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_set_suggested_prompts(self): + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: SlackResponse = set_suggested_prompts(prompts=["One", "Two"]) + assert response.status_code == 200 + + def test_set_suggested_prompts_objects(self): + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: SlackResponse = set_suggested_prompts( + prompts=[ + "One", + {"title": "Two", "message": "What's before addition?"}, + ], + ) + assert response.status_code == 200 + + def test_set_suggested_prompts_invalid(self): + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + with pytest.raises(TypeError): + set_suggested_prompts() diff --git a/tests/slack_bolt_async/context/test_async_say.py b/tests/slack_bolt_async/context/test_async_say.py index 77ac0cc0e..efa90febc 100644 --- a/tests/slack_bolt_async/context/test_async_say.py +++ b/tests/slack_bolt_async/context/test_async_say.py @@ -2,12 +2,9 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse -from tests.utils import get_event_loop from slack_bolt.context.say.async_say import AsyncSay -from tests.mock_web_api_server import ( - cleanup_mock_web_api_server_async, - setup_mock_web_api_server_async, -) +from tests.mock_web_api_server import cleanup_mock_web_api_server_async, setup_mock_web_api_server_async +from tests.utils import get_event_loop class TestAsyncSay: @@ -29,6 +26,12 @@ async def test_say(self): response: AsyncSlackResponse = await say(text="Hi there!") assert response.status_code == 200 + @pytest.mark.asyncio + async def test_say_markdown_text(self): + say = AsyncSay(client=self.web_client, channel="C111") + response: AsyncSlackResponse = await say(markdown_text="**Greetings!**") + assert response.status_code == 200 + @pytest.mark.asyncio async def test_say_unfurl_options(self): say = AsyncSay(client=self.web_client, channel="C111") diff --git a/tests/slack_bolt_async/context/test_async_set_status.py b/tests/slack_bolt_async/context/test_async_set_status.py new file mode 100644 index 000000000..8df34171f --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_set_status.py @@ -0,0 +1,45 @@ +import pytest +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + +from slack_bolt.context.set_status.async_set_status import AsyncSetStatus +from tests.mock_web_api_server import cleanup_mock_web_api_server_async, setup_mock_web_api_server_async +from tests.utils import get_event_loop + + +class TestAsyncSetStatus: + @pytest.fixture + def event_loop(self): + setup_mock_web_api_server_async(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + + loop = get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server_async(self) + + @pytest.mark.asyncio + async def test_set_status(self): + set_status = AsyncSetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: AsyncSlackResponse = await set_status("Thinking...") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_set_status_loading_messages(self): + set_status = AsyncSetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: AsyncSlackResponse = await set_status( + status="Thinking...", + loading_messages=[ + "Sitting...", + "Waiting...", + ], + ) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_set_status_invalid(self): + set_status = AsyncSetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + with pytest.raises(TypeError): + await set_status() diff --git a/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py new file mode 100644 index 000000000..70a24efcb --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py @@ -0,0 +1,45 @@ +import asyncio + +import pytest +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + +from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server + + +class TestAsyncSetSuggestedPrompts: + @pytest.fixture + def event_loop(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + + @pytest.mark.asyncio + async def test_set_suggested_prompts(self): + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: AsyncSlackResponse = await set_suggested_prompts(prompts=["One", "Two"]) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_set_suggested_prompts_objects(self): + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: AsyncSlackResponse = await set_suggested_prompts( + prompts=[ + "One", + {"title": "Two", "message": "What's before addition?"}, + ], + ) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_set_suggested_prompts_invalid(self): + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + with pytest.raises(TypeError): + await set_suggested_prompts() From 5f6196f1570d348db5916aaafbb3d3c212e374dd Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 6 Oct 2025 16:40:52 -0700 Subject: [PATCH 805/865] version 1.26.0 (#1388) --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 7f9c19341..8cfd6f900 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,3 +1,3 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.25.0" +__version__ = "1.26.0" From 1d4e86bfc13118a4f44d8afe96d6cbeb0ebab88e Mon Sep 17 00:00:00 2001 From: Haley Elmendorf <31392893+haleychaas@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:44:35 -0500 Subject: [PATCH 806/865] docs: add AI to quickstart (#1389) --- docs/english/building-an-app.md | 6 +++--- docs/english/getting-started.md | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/english/building-an-app.md b/docs/english/building-an-app.md index 301cc52c6..ee0dac967 100644 --- a/docs/english/building-an-app.md +++ b/docs/english/building-an-app.md @@ -475,8 +475,8 @@ Now that you have a basic app up and running, you can start exploring how to mak * Read through the concepts pages to learn about the different methods and features your Bolt app has access to. -* Explore the different events your bot can listen to with the [`app.event()`](/tools/bolt-python/concepts/event-listening) method. All of the events are listed [on the API docs site](/reference/events). +* Explore the different events your bot can listen to with the [`app.event()`](/tools/bolt-python/concepts/event-listening) method. View the full events reference docs [here](/reference/events). -* Bolt allows you to [call Web API methods](/tools/bolt-python/concepts/web-api) with the client attached to your app. There are [over 200 methods](/reference/methods) on our API site. +* Bolt allows you to [call Web API methods](/tools/bolt-python/concepts/web-api) with the client attached to your app. There are over 200 methods; view them [here](/reference/methods). -* Learn more about the different token types [on the API docs site](/authentication/tokens). Your app may need different tokens depending on the actions you want it to perform. +* Learn more about the different token types in the [tokens guide](/authentication/tokens). Your app may need different tokens depending on the actions you want it to perform. \ No newline at end of file diff --git a/docs/english/getting-started.md b/docs/english/getting-started.md index 8b7438d65..cc428a93a 100644 --- a/docs/english/getting-started.md +++ b/docs/english/getting-started.md @@ -279,6 +279,41 @@ This will open the following page: On these pages you're free to make changes such as updating your app icon, configuring app features, and perhaps even distributing your app! +## Adding AI features {#ai-features} + +Now that you're familiar with a basic app setup, try it out again, this time using the AI agent template! + + + + +Get started with the agent template: + +```sh +$ slack create ai-app --template slack-samples/bolt-python-assistant-template +$ cd ai-app +``` + + + + +Get started with the agent template: + +```sh +$ git clone https://github.com/slack-samples/bolt-python-assistant-template ai-app +$ cd ai-app +``` + +Using this method, be sure to set the app and bot tokens as we did in the [Running the app](#running-the-app) section above. + + + + +Once the project is created, update the `.env.sample` file by setting the `OPENAI_API_KEY` with the value of your key and removing the `.sample` from the file name. + +In the `ai` folder of this app, you'll find default instructions for the LLM and an OpenAI client setup. + +The `listeners` include utilities intended for messaging with an LLM. Those are outlined in detail in the guide to [Using AI in apps](/tools/bolt-python/concepts/ai-apps) and [Sending messages](/tools/bolt-python/concepts/message-sending). + ## Next steps {#next-steps} Congrats once more on getting up and running with this quick start. From c8a50bee46fde24631a9e1746bdd8c81e5deea05 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 10 Oct 2025 16:08:06 -0400 Subject: [PATCH 807/865] feat: add has_been_called on `complete` & `fail` utility (#1390) --- slack_bolt/context/complete/async_complete.py | 11 +++++++++++ slack_bolt/context/complete/complete.py | 11 +++++++++++ slack_bolt/context/fail/async_fail.py | 11 +++++++++++ slack_bolt/context/fail/fail.py | 11 +++++++++++ tests/scenario_tests/test_function.py | 4 ++++ tests/scenario_tests_async/test_function.py | 4 ++++ tests/slack_bolt/context/test_complete.py | 9 +++++++++ tests/slack_bolt/context/test_fail.py | 9 +++++++++ tests/slack_bolt_async/context/test_async_complete.py | 11 +++++++++++ tests/slack_bolt_async/context/test_async_fail.py | 11 +++++++++++ 10 files changed, 92 insertions(+) diff --git a/slack_bolt/context/complete/async_complete.py b/slack_bolt/context/complete/async_complete.py index fe3d796d1..bb81c2d4a 100644 --- a/slack_bolt/context/complete/async_complete.py +++ b/slack_bolt/context/complete/async_complete.py @@ -7,6 +7,7 @@ class AsyncComplete: client: AsyncWebClient function_execution_id: Optional[str] + _called: bool def __init__( self, @@ -15,6 +16,7 @@ def __init__( ): self.client = client self.function_execution_id = function_execution_id + self._called = False async def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> AsyncSlackResponse: """Signal the successful completion of the custom function. @@ -31,6 +33,15 @@ async def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> AsyncSlack if self.function_execution_id is None: raise ValueError("complete is unsupported here as there is no function_execution_id") + self._called = True return await self.client.functions_completeSuccess( function_execution_id=self.function_execution_id, outputs=outputs or {} ) + + def has_been_called(self) -> bool: + """Check if this complete function has been called. + + Returns: + bool: True if the complete function has been called, False otherwise. + """ + return self._called diff --git a/slack_bolt/context/complete/complete.py b/slack_bolt/context/complete/complete.py index acba3a412..dc9382384 100644 --- a/slack_bolt/context/complete/complete.py +++ b/slack_bolt/context/complete/complete.py @@ -7,6 +7,7 @@ class Complete: client: WebClient function_execution_id: Optional[str] + _called: bool def __init__( self, @@ -15,6 +16,7 @@ def __init__( ): self.client = client self.function_execution_id = function_execution_id + self._called = False def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> SlackResponse: """Signal the successful completion of the custom function. @@ -31,4 +33,13 @@ def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> SlackResponse: if self.function_execution_id is None: raise ValueError("complete is unsupported here as there is no function_execution_id") + self._called = True return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs or {}) + + def has_been_called(self) -> bool: + """Check if this complete function has been called. + + Returns: + bool: True if the complete function has been called, False otherwise. + """ + return self._called diff --git a/slack_bolt/context/fail/async_fail.py b/slack_bolt/context/fail/async_fail.py index 10a39f735..da01067ba 100644 --- a/slack_bolt/context/fail/async_fail.py +++ b/slack_bolt/context/fail/async_fail.py @@ -7,6 +7,7 @@ class AsyncFail: client: AsyncWebClient function_execution_id: Optional[str] + _called: bool def __init__( self, @@ -15,6 +16,7 @@ def __init__( ): self.client = client self.function_execution_id = function_execution_id + self._called = False async def __call__(self, error: str) -> AsyncSlackResponse: """Signal that the custom function failed to complete. @@ -31,4 +33,13 @@ async def __call__(self, error: str) -> AsyncSlackResponse: if self.function_execution_id is None: raise ValueError("fail is unsupported here as there is no function_execution_id") + self._called = True return await self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error) + + def has_been_called(self) -> bool: + """Check if this fail function has been called. + + Returns: + bool: True if the fail function has been called, False otherwise. + """ + return self._called diff --git a/slack_bolt/context/fail/fail.py b/slack_bolt/context/fail/fail.py index 483bcebc3..9b04f6118 100644 --- a/slack_bolt/context/fail/fail.py +++ b/slack_bolt/context/fail/fail.py @@ -7,6 +7,7 @@ class Fail: client: WebClient function_execution_id: Optional[str] + _called: bool def __init__( self, @@ -15,6 +16,7 @@ def __init__( ): self.client = client self.function_execution_id = function_execution_id + self._called = False def __call__(self, error: str) -> SlackResponse: """Signal that the custom function failed to complete. @@ -31,4 +33,13 @@ def __call__(self, error: str) -> SlackResponse: if self.function_execution_id is None: raise ValueError("fail is unsupported here as there is no function_execution_id") + self._called = True return self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error) + + def has_been_called(self) -> bool: + """Check if this fail function has been called. + + Returns: + bool: True if the fail function has been called, False otherwise. + """ + return self._called diff --git a/tests/scenario_tests/test_function.py b/tests/scenario_tests/test_function.py index 0a2152892..5a4fc2685 100644 --- a/tests/scenario_tests/test_function.py +++ b/tests/scenario_tests/test_function.py @@ -300,16 +300,20 @@ def reverse(body, event, context, client, complete, inputs): assert context.client.token == "xwfp-valid" assert client.token == "xwfp-valid" assert complete.client.token == "xwfp-valid" + assert complete.has_been_called() is False complete( outputs={"reverseString": "olleh"}, ) + assert complete.has_been_called() is True def reverse_error(body, event, fail): assert body == function_body assert event == function_body["event"] assert fail.function_execution_id == "Fx111" + assert fail.has_been_called() is False fail(error="there was an error") + assert fail.has_been_called() is True def complete_it(body, event, complete): diff --git a/tests/scenario_tests_async/test_function.py b/tests/scenario_tests_async/test_function.py index 3f8b7a722..142cc1d6c 100644 --- a/tests/scenario_tests_async/test_function.py +++ b/tests/scenario_tests_async/test_function.py @@ -312,18 +312,22 @@ async def reverse(body, event, client, context, complete, inputs): assert context.client.token == "xwfp-valid" assert client.token == "xwfp-valid" assert complete.client.token == "xwfp-valid" + assert complete.has_been_called() is False await complete( outputs={"reverseString": "olleh"}, ) + assert complete.has_been_called() is True async def reverse_error(body, event, fail): assert body == function_body assert event == function_body["event"] assert fail.function_execution_id == "Fx111" + assert fail.has_been_called() is False await fail( error="there was an error", ) + assert fail.has_been_called() is True async def complete_it(body, event, complete): diff --git a/tests/slack_bolt/context/test_complete.py b/tests/slack_bolt/context/test_complete.py index a920c41eb..63a1d9f04 100644 --- a/tests/slack_bolt/context/test_complete.py +++ b/tests/slack_bolt/context/test_complete.py @@ -30,3 +30,12 @@ def test_complete_no_function_execution_id(self): with pytest.raises(ValueError): complete(outputs={"key": "value"}) + + def test_has_been_called_false_initially(self): + complete = Complete(client=self.web_client, function_execution_id="fn1111") + assert complete.has_been_called() is False + + def test_has_been_called_true_after_complete(self): + complete = Complete(client=self.web_client, function_execution_id="fn1111") + complete(outputs={"key": "value"}) + assert complete.has_been_called() is True diff --git a/tests/slack_bolt/context/test_fail.py b/tests/slack_bolt/context/test_fail.py index e4704d376..14348281f 100644 --- a/tests/slack_bolt/context/test_fail.py +++ b/tests/slack_bolt/context/test_fail.py @@ -30,3 +30,12 @@ def test_fail_no_function_execution_id(self): with pytest.raises(ValueError): fail(error="there was an error") + + def test_has_been_called_false_initially(self): + fail = Fail(client=self.web_client, function_execution_id="fn1111") + assert fail.has_been_called() is False + + def test_has_been_called_true_after_fail(self): + fail = Fail(client=self.web_client, function_execution_id="fn1111") + fail(error="there was an error") + assert fail.has_been_called() is True diff --git a/tests/slack_bolt_async/context/test_async_complete.py b/tests/slack_bolt_async/context/test_async_complete.py index f2fd115ec..b2a464f83 100644 --- a/tests/slack_bolt_async/context/test_async_complete.py +++ b/tests/slack_bolt_async/context/test_async_complete.py @@ -36,3 +36,14 @@ async def test_complete_no_function_execution_id(self): with pytest.raises(ValueError): await complete(outputs={"key": "value"}) + + @pytest.mark.asyncio + async def test_has_been_called_false_initially(self): + complete = AsyncComplete(client=self.web_client, function_execution_id="fn1111") + assert complete.has_been_called() is False + + @pytest.mark.asyncio + async def test_has_been_called_true_after_complete(self): + complete = AsyncComplete(client=self.web_client, function_execution_id="fn1111") + await complete(outputs={"key": "value"}) + assert complete.has_been_called() is True diff --git a/tests/slack_bolt_async/context/test_async_fail.py b/tests/slack_bolt_async/context/test_async_fail.py index 854bc7521..d4708927f 100644 --- a/tests/slack_bolt_async/context/test_async_fail.py +++ b/tests/slack_bolt_async/context/test_async_fail.py @@ -36,3 +36,14 @@ async def test_fail_no_function_execution_id(self): with pytest.raises(ValueError): await fail(error="there was an error") + + @pytest.mark.asyncio + async def test_has_been_called_false_initially(self): + fail = AsyncFail(client=self.web_client, function_execution_id="fn1111") + assert fail.has_been_called() is False + + @pytest.mark.asyncio + async def test_has_been_called_true_after_fail(self): + fail = AsyncFail(client=self.web_client, function_execution_id="fn1111") + await fail(error="there was an error") + assert fail.has_been_called() is True From ad2da997112bd917eb14d73c582066d7ca24b936 Mon Sep 17 00:00:00 2001 From: Haley Elmendorf <31392893+haleychaas@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:29:26 -0500 Subject: [PATCH 808/865] docs: order confirmation tutorial (#1381) --- docs/english/_sidebar.json | 1 + .../order-confirmation/order-confirmation.md | 553 ++++++++++++++++++ docs/img/delivery-tracker-main.png | Bin 0 -> 65799 bytes 3 files changed, 554 insertions(+) create mode 100644 docs/english/tutorial/order-confirmation/order-confirmation.md create mode 100644 docs/img/delivery-tracker-main.png diff --git a/docs/english/_sidebar.json b/docs/english/_sidebar.json index d42868543..859c4b52f 100644 --- a/docs/english/_sidebar.json +++ b/docs/english/_sidebar.json @@ -96,6 +96,7 @@ "label": "Tutorials", "items": [ "tools/bolt-python/tutorial/ai-chatbot/ai-chatbot", + "tools/bolt-python/tutorial/order-confirmation/order-confirmation", "tools/bolt-python/tutorial/custom-steps", "tools/bolt-python/tutorial/custom-steps-for-jira/custom-steps-for-jira", "tools/bolt-python/tutorial/custom-steps-workflow-builder-new/custom-steps-workflow-builder-new", diff --git a/docs/english/tutorial/order-confirmation/order-confirmation.md b/docs/english/tutorial/order-confirmation/order-confirmation.md new file mode 100644 index 000000000..695d6965a --- /dev/null +++ b/docs/english/tutorial/order-confirmation/order-confirmation.md @@ -0,0 +1,553 @@ +--- +title: Create a Salesforce order confirmation app +--- + +In this tutorial, you'll use the [Bolt for Python](/tools/bolt-python/) framework and [Block Kit Builder](https://app.slack.com/block-kit-builder) to create an order confirmation app that links to a system of record, like Salesforce. + +The Slack app will: +* allow users to enter order numbers from within Slack, along with some additional order information, +* post that information to a Slack channel, and +* send the information to the system of record. + +End users will be able to enter information across devices, as many will likely be using a mobile device. + +Along the way, you'll learn how to use the Bolt for Python starter app template as a jumping off point for your own custom apps. Let's begin! + +:::warning[Consider the following] + +This tutorial was created for educational purposes within a Slack workshop. As a result, it has not been tested quite as rigorously as our sample apps. Proceed carefully if you'd like to use a similar app in production. + +::: + +## Getting started + +### Installing the Slack CLI + +If you don't already have the Slack CLI, install it from your terminal: navigate to the installation guide ([for Mac and Linux](/tools/slack-cli/guides/installing-the-slack-cli-for-mac-and-linux) or [for Windows](/tools/slack-cli/guides/installing-the-slack-cli-for-windows)) and follow the steps. + +### Cloning the starter app + +Once installed, use the command `slack create` to get started with the Bolt for Python [starter template](https://github.com/slack-samples/bolt-python-starter-template). Alternatively, you can clone the template using Git. + +You can remove the portions from the template that are not used within this tutorial to make things a bit cleaner for yourself. To do this, open your project in VS Code (you can do this from the terminal with the `code .` command) and delete the `commands`, `events`, and `shortcuts` folders from the `/listeners` folder. You can also do the same to the corresponding folders within the `/listeners/tests` folder as well. Finally, remove the imports of these files from the `/listeners/__init__.py` file. + +## Creating your app + +We’ll use the contents of the `manifest.json` file below. This file describes the metadata associated with your app, like its name and permissions that it requests. + +These values are used to create an app in one of two ways: + +- **With the Slack CLI**: Save the contents of the file to your project's `manifest.json` file then skip ahead to [starting your app](#starting-your-app). +- **With app settings**: Copy the contents of the file and [create a new app](https://api.slack.com/apps/new). Next, choose **From a manifest** and follow the prompts, pasting the manifest file contents you copied. + +```json +{ + "_metadata": { + "major_version": 1, + "minor_version": 1 + }, + "display_information": { + "name": "Delivery Tracker App" + }, + "features": { + "bot_user": { + "display_name": "Delivery Tracker App", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "channels:history", + "chat:write" + ] + } + }, + "settings": { + "event_subscriptions": { + "bot_events": [ + "message.channels" + ] + }, + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false + } +} +``` + +### Tokens + +Once your app has been created, scroll down to **App-Level Tokens** on the **Basic Information** page and create a token that requests the [`connections:write`](/reference/scopes/connections.write) scope. This token will allow you to use [Socket Mode](/apis/events-api/using-socket-mode), which is a secure way to develop on Slack through the use of WebSockets. Save the value of your app token and store it in a safe place (we’ll use it in the next step). + +### Install app + +Still in the app settings, navigate to the **Install App** page in the left sidebar. Install your app. When you press **Allow**, this means you’re agreeing to install your app with the permissions that it’s requesting. Copy the bot token that you receive as well and store this in a safe place as well for subsequent steps. + +## Saving credentials + +Within a terminal of your choice, set the two tokens from the previous step as environment variables using the commands below. Make sure not to mix these two up, `SLACK_APP_TOKEN` will start with “xapp-“ and `SLACK_BOT_TOKEN` will start with “xoxb-“. + +For macOS: + +```bash +export SLACK_APP_TOKEN= +export SLACK_BOT_TOKEN= +``` + +For Windows Command Prompt: + +```cmd +set SLACK_APP_TOKEN= +set SLACK_BOT_TOKEN= +``` + +For Windows PowerShell: + +```powershell +$env:SLACK_APP_TOKEN="YOUR-APP-TOKEN-HERE" +$env:SLACK_BOT_TOKEN="YOUR-BOT-TOKEN-HERE" +``` + +## Starting your app {#starting-your-app} + +Run the following commands to activate a virtual environment for your Python packages to be installed, install the dependencies, and start your app. + +```bash +# Setup your python virtual environment +python -m venv .venv +source .venv/bin/activate + +# Install the dependencies +pip install -r requirements.txt + +# Start your local server +slack run +``` + +If you're not using the Slack CLI, a different `python` command can be used to start your app instead: + +```sh +python app.py +``` + +Now that your app is running, you should be able to see it within Slack. In Slack, create a channel that you can test in and try inviting your bot to it using the `/invite @Your-app-name-here` command. Check that your app works by saying “hi” in the channel where your app is, and you should receive a message back from it. If you don’t, ensure you completed all the steps above. + +## Coding the app + +We'll make four changes to the app: + +* Update the “hi” message to something more interesting and interactive +* Handle when the wrong delivery ID button is pressed +* Handle when the correct delivery IDs are sent and bring up a modal for more information +* Send the information to all of the places needed when the form is submitted (including third-party locations) + +For all of these steps, we will use [Block Kit Builder](https://app.slack.com/block-kit-builder), a tool that helps you create messages, modals and other surfaces within Slack. Open [Block Kit Builder](https://app.slack.com/block-kit-builder), take a look, and play around! We’ll create some views next. + +### Updating the "hi" message + +The first thing we want to do is change the “hi, how are you?” message from our app into something more useful. Here’s a `blocks` object built with Block Kit Builder: + +```json + + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Confirm *{delivery_id}* is correct?" + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Correct", + "emoji": true + }, + "style": "primary", + "action_id": "approve_delivery" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Not correct", + "emoji": true + }, + "style": "danger", + "action_id": "deny_delivery" + } + ] + } + ] + +``` + +Take the function below and place your blocks within the blocks dictionary `[]`. + +```python +def delivery_message_callback(context: BoltContext, say: Say, logger: Logger): + try: + delivery_id = context["matches"][0] + say( + blocks=[] # insert your blocks here + ) + except Exception as e: + logger.error(e) +``` + +Update the payload: +* Remove the initial blocks key and convert any boolean true values to `True` to fit with Python conventions. +* If you see variables within `{}` brackets, this is part of an f-string, which allows you to insert variables within strings in a clean manner. Place the `f` character before these strings like this: + +```python +{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"Confirm *{delivery_id}* is correct?", # place the "f" character here at the beginning of the string + }, +}, +``` + +Place all of this in the `sample_message.py` file. + +Next, you’ll need to register this listener to respond when a message is sent in the channel with your app. Head to `messages/__init__.py` and overwrite the function there with the one below, which registers the function. Don’t forget to add the import to the callback function as well! + +```python +from .sample_message import delivery_message_callback # import the function to this file + +def register(app: App): + # This regex will capture any number letters followed by dash + # and then any number of digits, our "confirmation number" e.g. ASDF-1234 + app.message(re.compile(r"[A-Za-z]+-\d+"))(delivery_message_callback) ## add this line! +``` + +Now, restart your server to bring in the new code and test that your function works by sending an order confirmation ID, like `HWOA-1524`, in your testing channel. Your app should respond with the message you created within Block Kit Builder. + +### Handling an incorrect delivery ID + +Notice that if you try to click on either of the buttons within your message, nothing will happen. This is because we have yet to create a function to handle the button click. Let’s start with the `Not correct` button first. + +1. Head to Block Kit Builder once again. We want to build a message that lets the user know that the wrong order ID has been submitted. Here's a [section](/reference/block-kit/blocks/section-block) block to get you started: + +```json + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Delivery *{delivery_id}* was incorrect ❌" + } + } + ] +``` + +View this block in Block Kit Builder [here](https://app.slack.com/block-kit-builder/#%7B%22blocks%22:%5B%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22Delivery%20*%7Bdelivery_id%7D*%20was%20incorrect%20%E2%9D%8C%22%7D%7D%5D%7D). + +2. Once you have something that you like, add it to the function below and place the function within the `actions/sample_action.py` file. Remember to make any strings with variables into f-strings! + +```python +def deny_delivery_callback(ack, body, client, logger: Logger): + try: + ack() + delivery_id = body["message"]["text"].split("*")[1] + + # Calls the chat.update function to replace the message, + # preventing it from being pressed more than once. + client.chat_update( + channel=body["container"]["channel_id"], + ts=body["container"]["message_ts"], + blocks=[], # Add your blocks here! + ) + + logger.info(f"Delivery denied by user {body['user']['id']}") + except Exception as e: + logger.error(e) +``` + +This function will call the [`chat.update`](/reference/methods/chat.update) Web API method, which will update the original message with buttons, to the one that we created previously. This will also prevent the message from being pressed more than once. + +3. Make the connection to this function again within the `actions/__init__.py` folder with the following code: + +```python +from slack_bolt import App +from .sample_action import sample_action_callback # This can be deleted +from .sample_action import deny_delivery_callback + +def register(app: App): + app.action("sample_action_id")(sample_action_callback) # This can be deleted + app.action("deny_delivery")(deny_delivery_callback) # Add this line +``` + +Test out your app by sending in a confirmation number into your channel and clicking the `Not correct` button. If the message is updated, then you’re good to go onto the next step. + +### Handling a correct delivery ID + +The next step is to handle the `Confirm` button. In this case, we’re going to pull up a modal instead of just a message. + +1. Using the following modal as a base; create a modal that captures the kind of information that you need. + +```json +{ + "title": { + "type": "plain_text", + "text": "Approve Delivery" + }, + "submit": { + "type": "plain_text", + "text": "Approve" + }, + "type": "modal", + "callback_id": "approve_delivery_view", + "private_metadata": "{delivery_id}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Approving delivery *{delivery_id}*" + } + }, + { + "type": "input", + "block_id": "notes", + "label": { + "type": "plain_text", + "text": "Additional delivery notes" + }, + "element": { + "type": "plain_text_input", + "action_id": "notes_input", + "multiline": true, + "placeholder": { + "type": "plain_text", + "text": "Add notes..." + } + }, + "optional": true + }, + { + "type": "input", + "block_id": "location", + "label": { + "type": "plain_text", + "text": "Delivery Location" + }, + "element": { + "type": "plain_text_input", + "action_id": "location_input", + "placeholder": { + "type": "plain_text", + "text": "Enter the location details..." + } + }, + "optional": true + }, + { + "type": "input", + "block_id": "channel", + "label": { + "type": "plain_text", + "text": "Notification Channel" + }, + "element": { + "type": "channels_select", + "action_id": "channel_select", + "placeholder": { + "type": "plain_text", + "text": "Select channel for notifications" + } + }, + "optional": false + } + ] +} +``` + +View this modal in Block Kit Builder [here](https://app.slack.com/block-kit-builder/#%7B%22type%22:%22modal%22,%22callback_id%22:%22approve_delivery_view%22,%22title%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Approve%20Delivery%22%7D,%22private_metadata%22:%22%7Bdelivery_id%7D%22,%22blocks%22:%5B%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22Approving%20delivery%20*%7Bdelivery_id%7D*%22%7D%7D,%7B%22type%22:%22input%22,%22block_id%22:%22notes%22,%22label%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Additional%20delivery%20notes%22%7D,%22element%22:%7B%22type%22:%22plain_text_input%22,%22action_id%22:%22notes_input%22,%22multiline%22:true,%22placeholder%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Add%20notes...%22%7D%7D,%22optional%22:true%7D,%7B%22type%22:%22input%22,%22block_id%22:%22location%22,%22label%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Delivery%20Location%22%7D,%22element%22:%7B%22type%22:%22plain_text_input%22,%22action_id%22:%22location_input%22,%22placeholder%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Enter%20the%20location%20details...%22%7D%7D,%22optional%22:true%7D,%7B%22type%22:%22input%22,%22block_id%22:%22channel%22,%22label%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Notification%20Channel%22%7D,%22element%22:%7B%22type%22:%22channels_select%22,%22action_id%22:%22channel_select%22,%22placeholder%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Select%20channel%20for%20notifications%22%7D%7D,%22optional%22:false%7D%5D,%22submit%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Approve%22%7D%7D). + +2. Within the `actions/sample_action.py` file, add the following function, replacing the view with the one you created above. Again, any strings with variables will be updated to f-strings and also any booleans will need to be capitalized. + +```python +def approve_delivery_callback(ack, body, client, logger: Logger): + try: + ack() + + delivery_id = body["message"]["text"].split("*")[1] + # Updates the original message so you can't press it twice + client.chat_update( + channel=body["container"]["channel_id"], + ts=body["container"]["message_ts"], + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"Processed delivery *{delivery_id}*...", + }, + } + ], + ) + + # Open a modal to gather information from the user + client.views_open( + trigger_id=body["trigger_id"], + view={} # Add your view here + ) + + logger.info(f"Approval modal opened by user {body['user']['id']}") + except Exception as e: + logger.error(e) +``` + +Similar to the `deny` button, we need to hook up all the connections. Your `actions/__init__.py` should look something like this: + +```python +from slack_bolt import App +from .sample_action import deny_delivery_callback +from .sample_action import approve_delivery_callback + + +def register(app: App): + app.action("approve_delivery")(approve_delivery_callback) + app.action("deny_delivery")(deny_delivery_callback) +``` + +Test your app by typing in a confirmation number in channel, click the confirm button and see if the modal comes up and you are able to capture information from the user. + +### Submitting the form + +Lastly, we’ll handle the submission of the form, which will trigger two things. We want to send the information into the specified channel, which will let the user know that the form was successful, as well as send the information into our system of record, Salesforce. + +1. Here’s a simple example of a message that you can use to present the information in channel. + +```json + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "✅ Delivery *{delivery_id}* approved:" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Delivery Notes:*\n{notes or 'None'}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Delivery Location:*\n{loc or 'None'}" + } + } + ] +``` + +View this in Block Kit Builder [here](https://app.slack.com/block-kit-builder/?1#%7B%22blocks%22:%5B%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22%E2%9C%85%20Delivery%20*%7Bdelivery_id%7D*%20approved:%22%7D%7D,%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22*Delivery%20Notes:*%5Cn%7Bnotes%20or%20'None'%7D%22%7D%7D,%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22*Delivery%20Location:*%5Cn%7Bloc%20or%20'None'%7D%22%7D%7D%5D%7D). Modify it however you like and then place it within the code below in the `/views/sample_views.py` file. + +```python +def handle_approve_delivery_view(ack, client, view, logger: Logger): + try: + ack() + + delivery_id = view["private_metadata"] + values = view["state"]["values"] + notes = values["notes"]["notes_input"]["value"] + loc = values["location"]["location_input"]["value"] + channel = values["channel"]["channel_select"]["selected_channel"] + + client.chat_postMessage( + channel=channel, + blocks=[], ## Add your message here + ) + + except Exception as e: + logger.error(f"Error in approve_delivery_view: {e}") +``` + +2. Making the connections in the `/views/__init__.py `file, we can test that this works by sending a message once again in our test channel. + +```python +from slack_bolt import App +from .sample_view import handle_approve_delivery_view + +def register(app: App): + app.view("sample_view_id")(sample_view_callback) # This can be deleted + app.view("approve_delivery_view")(handle_approve_delivery_view) ## Add this line +``` + +3. Let’s also send the information to Salesforce. There are [several ways](https://github.com/simple-salesforce/simple-salesforce?tab=readme-ov-file#examples) for you to access Salesforce through its API, but in this example, we’ve utilized `username`, `password` and `token` parameters. If you need help with getting your API token for Salesforce, take a look at [this article](https://help.salesforce.com/s/articleView?id=xcloud.user_security_token.htm&type=5). You’ll need to add these values as environment variables like we did earlier with our Slack tokens. You can use the following commands: + +```bash +export SF_USERNAME= +export SF_PASSWORD= +export SF_TOKEN= +``` + +4. We’re going to use assume that order information is stored in the Order object and that the confirmation IDs map to the 8-digit Order numbers within Salesforce. Given that assumption, we need to make a query to find the correct object, add the inputted information, and we’re done. Place this functionality before the last excerpt within the `/views/sample_views.py` file. + +```python +# Extract just the numeric portion from delivery_id + delivery_number = "".join(filter(str.isdigit, delivery_id)) + + # Update Salesforce order object + try: + sf = Salesforce( + username=os.environ.get("SF_USERNAME"), + password=os.environ.get("SF_PASSWORD"), + security_token=os.environ.get("SF_TOKEN"), + ) + + # Assuming delivery_id maps to Salesforce Order number + order = sf.query(f"SELECT Id FROM Order WHERE OrderNumber = '{delivery_number}'") # noqa: E501 + if order["records"]: + order_id = order["records"][0]["Id"] + sf.Order.update( + order_id, + { + "Status": "Delivered", + "Description": notes, + "Shipping_Location__c": loc, + }, + ) + logger.info(f"Updated order {delivery_id}") + else: + logger.warning(f"No order found for {delivery_id}") + + except Exception as sf_error: + logger.error(f"Update failed for order {delivery_id}: {sf_error}") + # Continue execution even if Salesforce update fails +``` + +You’ll also need to add the two imports that are found within this code to the top of the file. + +```python +import os +from simple_salesforce import Salesforce +``` + +With these imports, add `simple_salesforce` to your `requirements.txt` file, then install that package with the following command once again. + +```bash +pip install -r requirements.txt +``` + +![Image of delivery tracker app](/img/bolt-python/delivery-tracker-main.png) + +## Testing your app + +Test your app one last time, and you’re done! + +Congratulations! You’ve built an app using [Bolt for Python](/tools/bolt-python/) that allows you to send information into Slack, as well as into a third-party service. While there are more features you can add to make this a more robust app, we hope that this serves as a good introduction into connecting services like Salesforce using Slack as a conduit. \ No newline at end of file diff --git a/docs/img/delivery-tracker-main.png b/docs/img/delivery-tracker-main.png new file mode 100644 index 0000000000000000000000000000000000000000..b8d2e885c9ed00d1537203192beb5f7228c6ebef GIT binary patch literal 65799 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%0A3K0U$bGe{Ox5WC%Nw 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%0D9_<|{L1-${Mz}&0e3f!dsLgTY1)_> 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 Awg3PC literal 0 HcmV?d00001 From 0c1442669114693dbceb12fdae725b4b31a5eb87 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:23:44 -0500 Subject: [PATCH 809/865] chore(deps): bump actions/stale from 10.0.0 to 10.1.0 (#1393) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/triage-issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index 5cb75bf93..85ccb72aa 100644 --- a/.github/workflows/triage-issues.yml +++ b/.github/workflows/triage-issues.yml @@ -16,7 +16,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: days-before-issue-stale: 30 days-before-issue-close: 10 From 1264ee1bff50bcf5b2e2d00dd69f595f936d0186 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 5 Nov 2025 11:42:22 -0500 Subject: [PATCH 810/865] chore: automate release process (#1394) --- .github/maintainers_guide.md | 200 +++++++++--------- .github/release.yml | 24 +++ .github/workflows/pypi-release.yml | 87 ++++++++ scripts/deploy_to_pypi_org.sh | 12 -- ...est_pypi_org.sh => deploy_to_test_pypi.sh} | 0 5 files changed, 212 insertions(+), 111 deletions(-) create mode 100644 .github/release.yml create mode 100644 .github/workflows/pypi-release.yml delete mode 100755 scripts/deploy_to_pypi_org.sh rename scripts/{deploy_to_test_pypi_org.sh => deploy_to_test_pypi.sh} (100%) diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index 352398072..8cd600271 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -10,14 +10,14 @@ this project. If you use this package within your own software as is but don't p We recommend using [pyenv](https://github.com/pyenv/pyenv) for Python runtime management. If you use macOS, follow the following steps: -```bash -$ brew update -$ brew install pyenv +```sh +brew update +brew install pyenv ``` Install necessary Python runtimes for development/testing. You can rely on GitHub Actions workflows for testing with various major versions. -```bash +```sh $ pyenv install -l | grep -v "-e[conda|stackless|pypy]" $ pyenv install 3.8.5 # select the latest patch version @@ -34,9 +34,9 @@ $ pyenv rehash Then, you can create a new Virtual Environment this way: -```bash -$ python -m venv env_3.8.5 -$ source env_3.8.5/bin/activate +```sh +python -m venv env_3.8.5 +source env_3.8.5/bin/activate ``` ## Tasks @@ -49,27 +49,27 @@ If you make some changes to this SDK, please write corresponding unit tests as m If this is your first time to run tests, although it may take a bit long time, running the following script is the easiest. -```bash -$ ./scripts/install_all_and_run_tests.sh +```sh +./scripts/install_all_and_run_tests.sh ``` Once you installed all the required dependencies, you can use the following one. -```bash -$ ./scripts/run_tests.sh +```sh +./scripts/run_tests.sh ``` Also, you can run a single test this way. -```bash -$ ./scripts/run_tests.sh tests/scenario_tests/test_app.py +```sh +./scripts/run_tests.sh tests/scenario_tests/test_app.py ``` #### Run the Samples If you make changes to `slack_bolt/adapter/*`, please verify if it surely works by running the apps under `examples` directory. -```bash +```sh # Install all optional dependencies $ pip install -r requirements/adapter.txt $ pip install -r requirements/adapter_testing.txt @@ -97,121 +97,123 @@ If you want to test the package locally you can. 1. Build the package locally - Run - ```bash + ```sh scripts/build_pypi_package.sh ``` - This will create a `.whl` file in the `./dist` folder 2. Use the built package - Example `/dist/slack_bolt-1.2.3-py2.py3-none-any.whl` was created - From anywhere on your machine you can install this package to a project with - ```bash + ```sh pip install /dist/slack_bolt-1.2.3-py2.py3-none-any.whl ``` - It is also possible to include `slack_bolt @ file:////dist/slack_bolt-1.2.3-py2.py3-none-any.whl` in a [requirements.txt](https://pip.pypa.io/en/stable/user_guide/#requirements-files) file -### Releasing - -#### Generate API reference documents +### Generate API reference documents -```bash +```sh ./scripts/generate_api_docs.sh ``` +### Releasing + #### test.pypi.org deployment -##### $HOME/.pypirc +[TestPyPI](https://test.pypi.org/) is a separate instance of the Python Package +Index that allows you to try distribution tools and processes without affecting +the real index. This is particularly useful when making changes related to the +package configuration itself, for example, modifications to the `pyproject.toml` file. + +You can deploy this project to TestPyPI using GitHub Actions. -```toml -[testpypi] -username: {your username} -password: {your password} +To deploy using GitHub Actions: + +1. Push your changes to a branch or tag +2. Navigate to +3. Click on "Run workflow" +4. Select your branch or tag from the dropdown +5. Click "Run workflow" to build and deploy your branch to TestPyPI + +Alternatively, you can deploy from your local machine with: + +```sh +./scripts/deploy_to_test_pypi.sh ``` #### Development Deployment -1. Create a branch in which the development release will live: - - Bump the version number in adherence to [Semantic Versioning](http://semver.org/) and [Developmental Release](https://peps.python.org/pep-0440/#developmental-releases) in `slack_bolt/version.py` - - Example the current version is `1.2.3` a proper development bump would be `1.3.0.dev0` +Deploying a new version of this library to PyPI is triggered by publishing a GitHub Release. +Before creating a new release, ensure that everything on a stable branch has +landed, then [run the tests](#run-all-the-unit-tests). + +1. Create the commit for the release + 1. In `slack_bolt/version.py` bump the version number in adherence to [Semantic Versioning](http://semver.org/) and [Developmental Release](https://peps.python.org/pep-0440/#developmental-releases). + - Example: if the current version is `1.2.3`, a proper development bump would be `1.2.4.dev0` - `.dev` will indicate to pip that this is a [Development Release](https://peps.python.org/pep-0440/#developmental-releases) - - Note that the `dev` version can be bumped in development releases: `1.3.0.dev0` -> `1.3.0.dev1` - - Commit with a message including the new version number. For example `1.3.0.dev0` & Push the commit to a branch where the development release will live (create it if it does not exist) - - `git checkout -b future-release` - - `git commit -m 'version 1.3.0.dev0'` - - `git push future-release` - - Create a git tag for the release. For example `git tag v1.3.0.dev0`. - - Push the tag up to github with `git push origin --tags` - -2. Distribute the release - - Use the latest stable Python runtime - - `python -m venv .venv` - - `./scripts/deploy_to_pypi_org.sh` - - You do not need to create a GitHub release - -3. (Slack Internal) Communicate the release internally + - Note that the `dev` version can be bumped in development releases: `1.2.4.dev0` -> `1.2.4.dev1` + 2. Build the docs with `./scripts/generate_api_docs.sh`. + 3. Commit with a message including the new version number. For example `1.2.4.dev0` & push the commit to a branch where the development release will live (create it if it does not exist) + 1. `git checkout -b future-release` + 2. `git commit -m 'chore(release): version 1.2.4.dev0'` + 3. `git push -u origin future-release` +2. Create a new GitHub Release + 1. Navigate to the [Releases page](https://github.com/slackapi/bolt-python/releases). + 2. Click the "Draft a new release" button. + 3. Set the "Target" to the feature branch with the development changes. + 4. Click "Tag: Select tag" + 5. Input a new tag name manually. The tag name must match the version in `slack_bolt/version.py` prefixed with "v" (e.g., if version is `1.2.4.dev0`, enter `v1.2.4.dev0`) + 6. Click the "Create a new tag" button. This won't create your tag immediately. + 7. Click the "Generate release notes" button. + 8. The release name should match the tag name! + 9. Edit the resulting notes to ensure they have decent messaging that is understandable by non-contributors, but each commit should still have its own line. + 10. Set this release as a pre-release. + 11. Publish the release by clicking the "Publish release" button! +3. Navigate to the [release workflow run](https://github.com/slackapi/bolt-python/actions/workflows/pypi-release.yml). You will need to approve the deployment! +4. After a few minutes, the corresponding version will be available on . +5. (Slack Internal) Communicate the release internally #### Production Deployment -1. Create the commit for the release: - - Bump the version number in adherence to [Semantic Versioning](http://semver.org/) in `slack_bolt/version.py` - - Build the docs with `./scripts/generate_api_docs.sh`. - - Commit with a message including the new version number. For example `1.2.3` & Push the commit to a branch and create a PR to sanity check. - - `git checkout -b v1.2.3` - - `git commit -a -m 'version 1.2.3'` - - `git push -u origin HEAD` - - Open a PR and merge after receiving at least one approval from other maintainers. - -2. Distribute the release - - Use the latest stable Python runtime - - `git checkout main && git pull` - - `python --version` - - `python -m venv .venv` - - `./scripts/deploy_to_pypi_org.sh` - - Create a new GitHub Release from the [Releases page](https://github.com/slackapi/bolt-python/releases) by clicking the "Draft a new release" button. - - Enter the new version number updated from the commit (e.g. `v1.2.3`) into the "Choose a tag" input. - - Ensure the tag `Target` branch is `main` (e.g `Target:main`). - - Click the "Create a new tag: x.x.x on publish" button. This won't create your tag immediately. - - Name the release after the version number updated from the commit (e.g. `version 1.2.3`) - - Auto-generate the release notes by clicking the "Auto-generate release - notes" button. This will pull in changes that will be included in your - release. - - Edit the resulting notes to ensure they have decent messaging that are - understandable by non-contributors, but each commit should still have it's - own line. - - Ensure that this version adheres to [semantic versioning](http://semver.org/). See - [Versioning](#versioning-and-tags) for correct version format. Version tags - should match the following pattern: `v2.5.0`. - - ```markdown - ## New Features - - ### Awesome Feature 1 - - Description here. - - ### Awesome Feature 2 - - Description here. - - ## Changes - - * #123 Make it better - thanks @SlackHQ - * #123 Fix something wrong - thanks @seratch - ``` - -3. (Slack Internal) Communicate the release internally - - Include a link to the GitHub release - -4. Make announcements - - #tools-bolt in community.slack.com - -5. (Slack Internal) Tweet by @SlackAPI - - Not necessary for patch updates, might be needed for minor updates, definitely needed for major updates. Include a link to the GitHub release +Deploying a new version of this library to PyPI is triggered by publishing a GitHub Release. +Before creating a new release, ensure that everything on the `main` branch since +the last tag is in a releasable state! At a minimum, [run the tests](#run-all-the-unit-tests). + +1. Create the commit for the release + 1. In `slack_bolt/version.py` bump the version number in adherence to [Semantic Versioning](http://semver.org/) and the [Versioning](#versioning-and-tags) section. + 2. Build the docs with `./scripts/generate_api_docs.sh`. + 3. Commit with a message including the new version number. For example `1.2.3` & push the commit to a branch and create a PR to sanity check. + 1. `git checkout -b 1.2.3-release` + 2. `git commit -m 'chore(release): version 1.2.3'` + 3. `git push -u origin 1.2.3-release` + 4. Add relevant labels to the PR and add the PR to a GitHub Milestone. + 5. Merge in release PR after getting an approval from at least one maintainer. +2. Create a new GitHub Release + 1. Navigate to the [Releases page](https://github.com/slackapi/bolt-python/releases). + 2. Click the "Draft a new release" button. + 3. Set the "Target" to the `main` branch. + 4. Click "Tag: Select tag" + 5. Input a new tag name manually. The tag name must match the version in `slack_bolt/version.py` prefixed with "v" (e.g., if version is `1.2.3`, enter `v1.2.3`) + 6. Click the "Create a new tag" button. This won't create your tag immediately. + 7. Click the "Generate release notes" button. + 8. The release name should match the tag name! + 9. Edit the resulting notes to ensure they have decent messaging that is understandable by non-contributors, but each commit should still have its own line. + 10. Include a link to the current GitHub Milestone. + 11. Ensure the "latest release" checkbox is checked to mark this as the latest stable release. + 12. Publish the release by clicking the "Publish release" button! +3. Navigate to the [release workflow run](https://github.com/slackapi/bolt-python/actions/workflows/pypi-release.yml). You will need to approve the deployment! +4. After a few minutes, the corresponding version will be available on . +5. Close the current GitHub Milestone and create one for the next patch version. +6. (Slack Internal) Communicate the release internally + - Include a link to the GitHub release +7. (Slack Internal) Tweet by @SlackAPI + - Not necessary for patch updates, might be needed for minor updates, + definitely needed for major updates. Include a link to the GitHub release ## Workflow ### Versioning and Tags -This project uses semantic versioning, expressed through the numbering scheme of +This project uses [Semantic Versioning](http://semver.org/), expressed through the numbering scheme of [PEP-0440](https://www.python.org/dev/peps/pep-0440/). ### Branches diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..b2574b7cc --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,24 @@ +# https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes#configuring-automatically-generated-release-notes +changelog: + categories: + - title: 🚀 Enhancements + labels: + - enhancement + - title: 🐛 Bug Fixes + labels: + - bug + - title: 📚 Documentation + labels: + - docs + - title: 🤖 Build + labels: + - build + - title: 🧪 Testing/Code Health + labels: + - code health + - title: 🔒 Security + labels: + - security + - title: 📦 Other changes + labels: + - "*" diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml new file mode 100644 index 000000000..21b472247 --- /dev/null +++ b/.github/workflows/pypi-release.yml @@ -0,0 +1,87 @@ +name: Upload A Release to pypi.org or test.pypi.org + +on: + release: + types: + - published + workflow_dispatch: + inputs: + dry_run: + description: "Dry run (build only, do not publish)" + required: false + type: boolean + +jobs: + release-build: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.release.tag_name || github.ref }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + with: + python-version: "3.x" + + - name: Build release distributions + run: | + scripts/build_pypi_package.sh + + - name: Persist dist folder + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: release-dist + path: dist/ + + test-pypi-publish: + runs-on: ubuntu-latest + needs: + - release-build + # Run this job for workflow_dispatch events when dry_run input is not 'true' + # Note: The comparison is against a string value 'true' since GitHub Actions inputs are strings + if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true' + environment: + name: testpypi + permissions: + id-token: write + + steps: + - name: Retrieve dist folder + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: release-dist + path: dist/ + + - name: Publish release distributions to test.pypi.org + # Using OIDC for PyPI publishing (no API tokens needed) + # See: https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-pypi + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + repository-url: https://test.pypi.org/legacy/ + + pypi-publish: + runs-on: ubuntu-latest + needs: + - release-build + if: github.event_name == 'release' + environment: + name: pypi + permissions: + id-token: write + + steps: + - name: Retrieve dist folder + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: release-dist + path: dist/ + + - name: Publish release distributions to pypi.org + # Using OIDC for PyPI publishing (no API tokens needed) + # See: https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-pypi + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/scripts/deploy_to_pypi_org.sh b/scripts/deploy_to_pypi_org.sh deleted file mode 100755 index 8c5234902..000000000 --- a/scripts/deploy_to_pypi_org.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -script_dir=`dirname $0` -cd ${script_dir}/.. -rm -rf ./slack_bolt.egg-info - -pip install -U pip && \ - pip install -U twine build && \ - rm -rf dist/ build/ slack_bolt.egg-info/ && \ - python -m build --sdist --wheel && \ - twine check dist/* && \ - twine upload dist/* diff --git a/scripts/deploy_to_test_pypi_org.sh b/scripts/deploy_to_test_pypi.sh similarity index 100% rename from scripts/deploy_to_test_pypi_org.sh rename to scripts/deploy_to_test_pypi.sh From 2086c7a4f5e4c010ef04f5537668d7b228f1bf59 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 11 Nov 2025 08:54:26 -0800 Subject: [PATCH 811/865] ci: upload test results using the recommended codecov action (#1396) --- .github/workflows/codecov.yml | 3 ++- .github/workflows/tests.yml | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 7381117bb..b402079c1 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -39,5 +39,6 @@ jobs: uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: fail_ci_if_error: true - verbose: true + report_type: coverage token: ${{ secrets.CODECOV_TOKEN }} + verbose: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 167dc2ce4..20262a857 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -76,10 +76,12 @@ jobs: pytest tests/scenario_tests_async/ --junitxml=reports/test_scenario_async.xml - name: Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: directory: ./reports/ + fail_ci_if_error: true flags: ${{ matrix.python-version }} + report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} verbose: true notifications: From fe28b14d6fc78cae56cb6fad28d3357cd639c6a8 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 13 Nov 2025 14:42:49 -0500 Subject: [PATCH 812/865] feat: add support for python 3.14 (#1397) --- .github/workflows/codecov.yml | 2 +- .github/workflows/flake8.yml | 2 +- .github/workflows/mypy.yml | 2 +- .github/workflows/tests.yml | 1 + pyproject.toml | 3 ++- slack_bolt/listener/async_listener.py | 6 ------ slack_bolt/listener_matcher/async_listener_matcher.py | 2 -- 7 files changed, 6 insertions(+), 12 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index b402079c1..02c318a20 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -12,7 +12,7 @@ jobs: timeout-minutes: 10 strategy: matrix: - python-version: ["3.13"] + python-version: ["3.14"] permissions: contents: read env: diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index bd4e3dfd8..f777996b4 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -12,7 +12,7 @@ jobs: timeout-minutes: 20 strategy: matrix: - python-version: ["3.13"] + python-version: ["3.14"] permissions: contents: read steps: diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 1bf4abf0d..52d59c830 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -12,7 +12,7 @@ jobs: timeout-minutes: 20 strategy: matrix: - python-version: ["3.13"] + python-version: ["3.14"] permissions: contents: read steps: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 20262a857..42fd58ef6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,6 +24,7 @@ jobs: - "3.11" - "3.12" - "3.13" + - "3.14" permissions: contents: read steps: diff --git a/pyproject.toml b/pyproject.toml index 5361ef1b4..a5c12548b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,12 +15,13 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] requires-python = ">=3.7" -dependencies = ["slack_sdk>=3.37.0,<4"] +dependencies = ["slack_sdk>=3.38.0,<4"] [project.urls] diff --git a/slack_bolt/listener/async_listener.py b/slack_bolt/listener/async_listener.py index 0810b91a7..1717b1a8d 100644 --- a/slack_bolt/listener/async_listener.py +++ b/slack_bolt/listener/async_listener.py @@ -72,13 +72,7 @@ async def run_ack_function(self, *, request: AsyncBoltRequest, response: BoltRes from logging import Logger -from typing import Callable, Awaitable - -from slack_bolt.listener_matcher.async_listener_matcher import AsyncListenerMatcher from slack_bolt.logger import get_bolt_app_logger -from slack_bolt.middleware.async_middleware import AsyncMiddleware -from slack_bolt.request.async_request import AsyncBoltRequest -from slack_bolt.response import BoltResponse class AsyncCustomListener(AsyncListener): diff --git a/slack_bolt/listener_matcher/async_listener_matcher.py b/slack_bolt/listener_matcher/async_listener_matcher.py index 83e04e478..3230bb342 100644 --- a/slack_bolt/listener_matcher/async_listener_matcher.py +++ b/slack_bolt/listener_matcher/async_listener_matcher.py @@ -25,8 +25,6 @@ async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs from slack_bolt.logger import get_bolt_app_logger -from slack_bolt.request.async_request import AsyncBoltRequest -from slack_bolt.response import BoltResponse class AsyncCustomListenerMatcher(AsyncListenerMatcher): From 87e342515d104e151cb849f50a0068712ae9a76c Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Thu, 13 Nov 2025 11:46:33 -0800 Subject: [PATCH 813/865] chore: Add .github/CODEOWNERS file (#1398) --- .github/CODEOWNERS | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..4a08579c2 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# Salesforce Open Source project configuration +# Learn more: https://github.com/salesforce/oss-template +#ECCN:Open Source +#GUSINFO:Open Source,Open Source Workflow + +# @slackapi/slack-platform-python +# are code reviewers for all changes in this repo. +* @slackapi/slack-platform-python + +# @slackapi/developer-education +# are code reviewers for changes in the `/docs` directory. +/docs/ @slackapi/developer-education From 9cba291465237eeac6416730fc6b3838e1078b30 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 13 Nov 2025 15:13:10 -0500 Subject: [PATCH 814/865] chore(release): version 1.27.0 (#1399) --- .../authorization/authorize_result.html | 4 +- docs/reference/authorization/index.html | 4 +- .../thread_context_store/file/index.html | 2 +- .../context/complete/async_complete.html | 40 ++++++++- docs/reference/context/complete/complete.html | 40 ++++++++- docs/reference/context/complete/index.html | 40 ++++++++- docs/reference/context/fail/async_fail.html | 40 ++++++++- docs/reference/context/fail/fail.html | 40 ++++++++- docs/reference/context/fail/index.html | 40 ++++++++- docs/reference/error/index.html | 2 +- docs/reference/index.html | 82 ++++++++++++++++++- docs/reference/logger/messages.html | 4 +- .../reference/oauth/async_oauth_settings.html | 2 +- docs/reference/oauth/oauth_settings.html | 2 +- slack_bolt/version.py | 2 +- 15 files changed, 324 insertions(+), 20 deletions(-) diff --git a/docs/reference/authorization/authorize_result.html b/docs/reference/authorization/authorize_result.html index 6eac3724d..d53c5cd5c 100644 --- a/docs/reference/authorization/authorize_result.html +++ b/docs/reference/authorization/authorize_result.html @@ -48,7 +48,7 @@

    Classes

    class AuthorizeResult -(*,
    enterprise_id: str | None,
    team_id: str | None,
    team: str | None = None,
    url: str | None = None,
    bot_user_id: str | None = None,
    bot_id: str | None = None,
    bot_token: str | None = None,
    bot_scopes: str | Sequence[str] | None = None,
    user_id: str | None = None,
    user: str | None = None,
    user_token: str | None = None,
    user_scopes: str | Sequence[str] | None = None)
    +(*,
    enterprise_id: str | None,
    team_id: str | None,
    team: str | None = None,
    url: str | None = None,
    bot_user_id: str | None = None,
    bot_id: str | None = None,
    bot_token: str | None = None,
    bot_scopes: Sequence[str] | str | None = None,
    user_id: str | None = None,
    user: str | None = None,
    user_token: str | None = None,
    user_scopes: Sequence[str] | str | None = None)
    @@ -246,7 +246,7 @@

    Class variables

    Static methods

    -def from_auth_test_response(*,
    bot_token: str | None = None,
    user_token: str | None = None,
    bot_scopes: str | Sequence[str] | None = None,
    user_scopes: str | Sequence[str] | None = None,
    auth_test_response: slack_sdk.web.slack_response.SlackResponse | ForwardRef('AsyncSlackResponse'),
    user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | ForwardRef('AsyncSlackResponse') | None = None)
    +def from_auth_test_response(*,
    bot_token: str | None = None,
    user_token: str | None = None,
    bot_scopes: Sequence[str] | str | None = None,
    user_scopes: Sequence[str] | str | None = None,
    auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse,
    user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse | None = None)
    diff --git a/docs/reference/authorization/index.html b/docs/reference/authorization/index.html index 19de311df..2fdd1f916 100644 --- a/docs/reference/authorization/index.html +++ b/docs/reference/authorization/index.html @@ -75,7 +75,7 @@

    Classes

    class AuthorizeResult -(*,
    enterprise_id: str | None,
    team_id: str | None,
    team: str | None = None,
    url: str | None = None,
    bot_user_id: str | None = None,
    bot_id: str | None = None,
    bot_token: str | None = None,
    bot_scopes: str | Sequence[str] | None = None,
    user_id: str | None = None,
    user: str | None = None,
    user_token: str | None = None,
    user_scopes: str | Sequence[str] | None = None)
    +(*,
    enterprise_id: str | None,
    team_id: str | None,
    team: str | None = None,
    url: str | None = None,
    bot_user_id: str | None = None,
    bot_id: str | None = None,
    bot_token: str | None = None,
    bot_scopes: Sequence[str] | str | None = None,
    user_id: str | None = None,
    user: str | None = None,
    user_token: str | None = None,
    user_scopes: Sequence[str] | str | None = None)
    @@ -273,7 +273,7 @@

    Class variables

    Static methods

    -def from_auth_test_response(*,
    bot_token: str | None = None,
    user_token: str | None = None,
    bot_scopes: str | Sequence[str] | None = None,
    user_scopes: str | Sequence[str] | None = None,
    auth_test_response: slack_sdk.web.slack_response.SlackResponse | ForwardRef('AsyncSlackResponse'),
    user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | ForwardRef('AsyncSlackResponse') | None = None)
    +def from_auth_test_response(*,
    bot_token: str | None = None,
    user_token: str | None = None,
    bot_scopes: Sequence[str] | str | None = None,
    user_scopes: Sequence[str] | str | None = None,
    auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse,
    user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse | None = None)
    diff --git a/docs/reference/context/assistant/thread_context_store/file/index.html b/docs/reference/context/assistant/thread_context_store/file/index.html index cbb4e4db6..4a5d944e1 100644 --- a/docs/reference/context/assistant/thread_context_store/file/index.html +++ b/docs/reference/context/assistant/thread_context_store/file/index.html @@ -48,7 +48,7 @@

    Classes

    class FileAssistantThreadContextStore -(base_dir: str = '/Users/eden.zimbelman/.bolt-app-assistant-thread-contexts') +(base_dir: str = '/Users/wbergamin/.bolt-app-assistant-thread-contexts')
    diff --git a/docs/reference/context/complete/async_complete.html b/docs/reference/context/complete/async_complete.html index 36cf1f92f..f0546a950 100644 --- a/docs/reference/context/complete/async_complete.html +++ b/docs/reference/context/complete/async_complete.html @@ -58,6 +58,7 @@

    Classes

    class AsyncComplete:
         client: AsyncWebClient
         function_execution_id: Optional[str]
    +    _called: bool
     
         def __init__(
             self,
    @@ -66,6 +67,7 @@ 

    Classes

    ): self.client = client self.function_execution_id = function_execution_id + self._called = False async def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> AsyncSlackResponse: """Signal the successful completion of the custom function. @@ -82,9 +84,18 @@

    Classes

    if self.function_execution_id is None: raise ValueError("complete is unsupported here as there is no function_execution_id") + self._called = True return await self.client.functions_completeSuccess( function_execution_id=self.function_execution_id, outputs=outputs or {} - )
    + ) + + def has_been_called(self) -> bool: + """Check if this complete function has been called. + + Returns: + bool: True if the complete function has been called, False otherwise. + """ + return self._called

    Class variables

    @@ -98,6 +109,32 @@

    Class variables

    The type of the None singleton.

    +

    Methods

    +
    +
    +def has_been_called(self) ‑> bool +
    +
    +
    + +Expand source code + +
    def has_been_called(self) -> bool:
    +    """Check if this complete function has been called.
    +
    +    Returns:
    +        bool: True if the complete function has been called, False otherwise.
    +    """
    +    return self._called
    +
    +

    Check if this complete function has been called.

    +

    Returns

    +
    +
    bool
    +
    True if the complete function has been called, False otherwise.
    +
    +
    +
    @@ -119,6 +156,7 @@

  • client
  • function_execution_id
  • +
  • has_been_called
  • diff --git a/docs/reference/context/complete/complete.html b/docs/reference/context/complete/complete.html index b1f01ea1a..b8c1b083b 100644 --- a/docs/reference/context/complete/complete.html +++ b/docs/reference/context/complete/complete.html @@ -58,6 +58,7 @@

    Classes

    class Complete:
         client: WebClient
         function_execution_id: Optional[str]
    +    _called: bool
     
         def __init__(
             self,
    @@ -66,6 +67,7 @@ 

    Classes

    ): self.client = client self.function_execution_id = function_execution_id + self._called = False def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> SlackResponse: """Signal the successful completion of the custom function. @@ -82,7 +84,16 @@

    Classes

    if self.function_execution_id is None: raise ValueError("complete is unsupported here as there is no function_execution_id") - return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs or {})
    + self._called = True + return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs or {}) + + def has_been_called(self) -> bool: + """Check if this complete function has been called. + + Returns: + bool: True if the complete function has been called, False otherwise. + """ + return self._called

    Class variables

    @@ -96,6 +107,32 @@

    Class variables

    The type of the None singleton.

    +

    Methods

    +
    +
    +def has_been_called(self) ‑> bool +
    +
    +
    + +Expand source code + +
    def has_been_called(self) -> bool:
    +    """Check if this complete function has been called.
    +
    +    Returns:
    +        bool: True if the complete function has been called, False otherwise.
    +    """
    +    return self._called
    +
    +

    Check if this complete function has been called.

    +

    Returns

    +
    +
    bool
    +
    True if the complete function has been called, False otherwise.
    +
    +
    +
    @@ -117,6 +154,7 @@

  • client
  • function_execution_id
  • +
  • has_been_called
  • diff --git a/docs/reference/context/complete/index.html b/docs/reference/context/complete/index.html index 7665622b6..dddd26a84 100644 --- a/docs/reference/context/complete/index.html +++ b/docs/reference/context/complete/index.html @@ -69,6 +69,7 @@

    Classes

    class Complete:
         client: WebClient
         function_execution_id: Optional[str]
    +    _called: bool
     
         def __init__(
             self,
    @@ -77,6 +78,7 @@ 

    Classes

    ): self.client = client self.function_execution_id = function_execution_id + self._called = False def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> SlackResponse: """Signal the successful completion of the custom function. @@ -93,7 +95,16 @@

    Classes

    if self.function_execution_id is None: raise ValueError("complete is unsupported here as there is no function_execution_id") - return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs or {})
    + self._called = True + return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs or {}) + + def has_been_called(self) -> bool: + """Check if this complete function has been called. + + Returns: + bool: True if the complete function has been called, False otherwise. + """ + return self._called

    Class variables

    @@ -107,6 +118,32 @@

    Class variables

    The type of the None singleton.

    +

    Methods

    +
    +
    +def has_been_called(self) ‑> bool +
    +
    +
    + +Expand source code + +
    def has_been_called(self) -> bool:
    +    """Check if this complete function has been called.
    +
    +    Returns:
    +        bool: True if the complete function has been called, False otherwise.
    +    """
    +    return self._called
    +
    +

    Check if this complete function has been called.

    +

    Returns

    +
    +
    bool
    +
    True if the complete function has been called, False otherwise.
    +
    +
    +
    @@ -134,6 +171,7 @@

  • client
  • function_execution_id
  • +
  • has_been_called
  • diff --git a/docs/reference/context/fail/async_fail.html b/docs/reference/context/fail/async_fail.html index 6b3e4f1df..80f19d18c 100644 --- a/docs/reference/context/fail/async_fail.html +++ b/docs/reference/context/fail/async_fail.html @@ -58,6 +58,7 @@

    Classes

    class AsyncFail:
         client: AsyncWebClient
         function_execution_id: Optional[str]
    +    _called: bool
     
         def __init__(
             self,
    @@ -66,6 +67,7 @@ 

    Classes

    ): self.client = client self.function_execution_id = function_execution_id + self._called = False async def __call__(self, error: str) -> AsyncSlackResponse: """Signal that the custom function failed to complete. @@ -82,7 +84,16 @@

    Classes

    if self.function_execution_id is None: raise ValueError("fail is unsupported here as there is no function_execution_id") - return await self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error)
    + self._called = True + return await self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error) + + def has_been_called(self) -> bool: + """Check if this fail function has been called. + + Returns: + bool: True if the fail function has been called, False otherwise. + """ + return self._called

    Class variables

    @@ -96,6 +107,32 @@

    Class variables

    The type of the None singleton.

    +

    Methods

    +
    +
    +def has_been_called(self) ‑> bool +
    +
    +
    + +Expand source code + +
    def has_been_called(self) -> bool:
    +    """Check if this fail function has been called.
    +
    +    Returns:
    +        bool: True if the fail function has been called, False otherwise.
    +    """
    +    return self._called
    +
    +

    Check if this fail function has been called.

    +

    Returns

    +
    +
    bool
    +
    True if the fail function has been called, False otherwise.
    +
    +
    +
    @@ -117,6 +154,7 @@

  • client
  • function_execution_id
  • +
  • has_been_called
  • diff --git a/docs/reference/context/fail/fail.html b/docs/reference/context/fail/fail.html index 0152561d8..51f4896a4 100644 --- a/docs/reference/context/fail/fail.html +++ b/docs/reference/context/fail/fail.html @@ -58,6 +58,7 @@

    Classes

    class Fail:
         client: WebClient
         function_execution_id: Optional[str]
    +    _called: bool
     
         def __init__(
             self,
    @@ -66,6 +67,7 @@ 

    Classes

    ): self.client = client self.function_execution_id = function_execution_id + self._called = False def __call__(self, error: str) -> SlackResponse: """Signal that the custom function failed to complete. @@ -82,7 +84,16 @@

    Classes

    if self.function_execution_id is None: raise ValueError("fail is unsupported here as there is no function_execution_id") - return self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error)
    + self._called = True + return self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error) + + def has_been_called(self) -> bool: + """Check if this fail function has been called. + + Returns: + bool: True if the fail function has been called, False otherwise. + """ + return self._called

    Class variables

    @@ -96,6 +107,32 @@

    Class variables

    The type of the None singleton.

    +

    Methods

    +
    +
    +def has_been_called(self) ‑> bool +
    +
    +
    + +Expand source code + +
    def has_been_called(self) -> bool:
    +    """Check if this fail function has been called.
    +
    +    Returns:
    +        bool: True if the fail function has been called, False otherwise.
    +    """
    +    return self._called
    +
    +

    Check if this fail function has been called.

    +

    Returns

    +
    +
    bool
    +
    True if the fail function has been called, False otherwise.
    +
    +
    +
    @@ -117,6 +154,7 @@

  • client
  • function_execution_id
  • +
  • has_been_called
  • diff --git a/docs/reference/context/fail/index.html b/docs/reference/context/fail/index.html index eb2653106..3b35dd6aa 100644 --- a/docs/reference/context/fail/index.html +++ b/docs/reference/context/fail/index.html @@ -69,6 +69,7 @@

    Classes

    class Fail:
         client: WebClient
         function_execution_id: Optional[str]
    +    _called: bool
     
         def __init__(
             self,
    @@ -77,6 +78,7 @@ 

    Classes

    ): self.client = client self.function_execution_id = function_execution_id + self._called = False def __call__(self, error: str) -> SlackResponse: """Signal that the custom function failed to complete. @@ -93,7 +95,16 @@

    Classes

    if self.function_execution_id is None: raise ValueError("fail is unsupported here as there is no function_execution_id") - return self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error)
    + self._called = True + return self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error) + + def has_been_called(self) -> bool: + """Check if this fail function has been called. + + Returns: + bool: True if the fail function has been called, False otherwise. + """ + return self._called

    Class variables

    @@ -107,6 +118,32 @@

    Class variables

    The type of the None singleton.

    +

    Methods

    +
    +
    +def has_been_called(self) ‑> bool +
    +
    +
    + +Expand source code + +
    def has_been_called(self) -> bool:
    +    """Check if this fail function has been called.
    +
    +    Returns:
    +        bool: True if the fail function has been called, False otherwise.
    +    """
    +    return self._called
    +
    +

    Check if this fail function has been called.

    +

    Returns

    +
    +
    bool
    +
    True if the fail function has been called, False otherwise.
    +
    +
    +
    @@ -134,6 +171,7 @@

  • client
  • function_execution_id
  • +
  • has_been_called
  • diff --git a/docs/reference/error/index.html b/docs/reference/error/index.html index f57d690e9..9a9998e63 100644 --- a/docs/reference/error/index.html +++ b/docs/reference/error/index.html @@ -72,7 +72,7 @@

    Subclasses

    class BoltUnhandledRequestError -(*,
    request: ForwardRef('BoltRequest') | ForwardRef('AsyncBoltRequest'),
    current_response: ForwardRef('BoltResponse') | None,
    last_global_middleware_name: str | None = None)
    +(*,
    request: BoltRequest | AsyncBoltRequest,
    current_response: BoltResponse | None,
    last_global_middleware_name: str | None = None)
    diff --git a/docs/reference/index.html b/docs/reference/index.html index 7bd6d117e..1c02a8aeb 100644 --- a/docs/reference/index.html +++ b/docs/reference/index.html @@ -5073,6 +5073,7 @@

    Methods

    class Complete:
         client: WebClient
         function_execution_id: Optional[str]
    +    _called: bool
     
         def __init__(
             self,
    @@ -5081,6 +5082,7 @@ 

    Methods

    ): self.client = client self.function_execution_id = function_execution_id + self._called = False def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> SlackResponse: """Signal the successful completion of the custom function. @@ -5097,7 +5099,16 @@

    Methods

    if self.function_execution_id is None: raise ValueError("complete is unsupported here as there is no function_execution_id") - return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs or {})
    + self._called = True + return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs or {}) + + def has_been_called(self) -> bool: + """Check if this complete function has been called. + + Returns: + bool: True if the complete function has been called, False otherwise. + """ + return self._called

    Class variables

    @@ -5111,6 +5122,32 @@

    Class variables

    The type of the None singleton.

    +

    Methods

    +
    +
    +def has_been_called(self) ‑> bool +
    +
    +
    + +Expand source code + +
    def has_been_called(self) -> bool:
    +    """Check if this complete function has been called.
    +
    +    Returns:
    +        bool: True if the complete function has been called, False otherwise.
    +    """
    +    return self._called
    +
    +

    Check if this complete function has been called.

    +

    Returns

    +
    +
    bool
    +
    True if the complete function has been called, False otherwise.
    +
    +
    +
    class CustomListenerMatcher @@ -5189,6 +5226,7 @@

    Inherited members

    class Fail:
         client: WebClient
         function_execution_id: Optional[str]
    +    _called: bool
     
         def __init__(
             self,
    @@ -5197,6 +5235,7 @@ 

    Inherited members

    ): self.client = client self.function_execution_id = function_execution_id + self._called = False def __call__(self, error: str) -> SlackResponse: """Signal that the custom function failed to complete. @@ -5213,7 +5252,16 @@

    Inherited members

    if self.function_execution_id is None: raise ValueError("fail is unsupported here as there is no function_execution_id") - return self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error)
    + self._called = True + return self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error) + + def has_been_called(self) -> bool: + """Check if this fail function has been called. + + Returns: + bool: True if the fail function has been called, False otherwise. + """ + return self._called

    Class variables

    @@ -5227,10 +5275,36 @@

    Class variables

    The type of the None singleton.

    +

    Methods

    +
    +
    +def has_been_called(self) ‑> bool +
    +
    +
    + +Expand source code + +
    def has_been_called(self) -> bool:
    +    """Check if this fail function has been called.
    +
    +    Returns:
    +        bool: True if the fail function has been called, False otherwise.
    +    """
    +    return self._called
    +
    +

    Check if this fail function has been called.

    +

    Returns

    +
    +
    bool
    +
    True if the fail function has been called, False otherwise.
    +
    +
    +
    class FileAssistantThreadContextStore -(base_dir: str = '/Users/eden.zimbelman/.bolt-app-assistant-thread-contexts') +(base_dir: str = '/Users/wbergamin/.bolt-app-assistant-thread-contexts')
    @@ -6120,6 +6194,7 @@

    Complete
  • client
  • function_execution_id
  • +
  • has_been_called
  • @@ -6136,6 +6211,7 @@

    Fail

  • client
  • function_execution_id
  • +
  • has_been_called
  • diff --git a/docs/reference/logger/messages.html b/docs/reference/logger/messages.html index 1072e6479..e69b45fc9 100644 --- a/docs/reference/logger/messages.html +++ b/docs/reference/logger/messages.html @@ -409,7 +409,7 @@

    Functions

  • -def warning_unhandled_by_global_middleware(name: str,
    req: BoltRequest | ForwardRef('AsyncBoltRequest')) ‑> str
    +def warning_unhandled_by_global_middleware(name: str,
    req: BoltRequest | AsyncBoltRequest) ‑> str
    @@ -427,7 +427,7 @@

    Functions

    -def warning_unhandled_request(req: BoltRequest | ForwardRef('AsyncBoltRequest')) ‑> str +def warning_unhandled_request(req: BoltRequest | AsyncBoltRequest) ‑> str
    diff --git a/docs/reference/oauth/async_oauth_settings.html b/docs/reference/oauth/async_oauth_settings.html index 5e6a543c4..3b8c04edb 100644 --- a/docs/reference/oauth/async_oauth_settings.html +++ b/docs/reference/oauth/async_oauth_settings.html @@ -48,7 +48,7 @@

    Classes

    class AsyncOAuthSettings -(*,
    client_id: str | None = None,
    client_secret: str | None = None,
    scopes: str | Sequence[str] | None = None,
    user_scopes: str | Sequence[str] | None = None,
    redirect_uri: str | None = None,
    install_path: str = '/slack/install',
    install_page_rendering_enabled: bool = True,
    redirect_uri_path: str = '/slack/oauth_redirect',
    callback_options: AsyncCallbackOptions | None = None,
    success_url: str | None = None,
    failure_url: str | None = None,
    authorization_url: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None = None,
    installation_store_bot_only: bool = False,
    token_rotation_expiration_minutes: int = 120,
    user_token_resolution: str = 'authed_user',
    state_validation_enabled: bool = True,
    state_store: slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore | None = None,
    state_cookie_name: str = 'slack-app-oauth-state',
    state_expiration_seconds: int = 600,
    logger: logging.Logger = <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>)
    +(*,
    client_id: str | None = None,
    client_secret: str | None = None,
    scopes: Sequence[str] | str | None = None,
    user_scopes: Sequence[str] | str | None = None,
    redirect_uri: str | None = None,
    install_path: str = '/slack/install',
    install_page_rendering_enabled: bool = True,
    redirect_uri_path: str = '/slack/oauth_redirect',
    callback_options: AsyncCallbackOptions | None = None,
    success_url: str | None = None,
    failure_url: str | None = None,
    authorization_url: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None = None,
    installation_store_bot_only: bool = False,
    token_rotation_expiration_minutes: int = 120,
    user_token_resolution: str = 'authed_user',
    state_validation_enabled: bool = True,
    state_store: slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore | None = None,
    state_cookie_name: str = 'slack-app-oauth-state',
    state_expiration_seconds: int = 600,
    logger: logging.Logger = <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>)
    diff --git a/docs/reference/oauth/oauth_settings.html b/docs/reference/oauth/oauth_settings.html index 1eb2ab7dd..cd8def497 100644 --- a/docs/reference/oauth/oauth_settings.html +++ b/docs/reference/oauth/oauth_settings.html @@ -48,7 +48,7 @@

    Classes

    class OAuthSettings -(*,
    client_id: str | None = None,
    client_secret: str | None = None,
    scopes: str | Sequence[str] | None = None,
    user_scopes: str | Sequence[str] | None = None,
    redirect_uri: str | None = None,
    install_path: str = '/slack/install',
    install_page_rendering_enabled: bool = True,
    redirect_uri_path: str = '/slack/oauth_redirect',
    callback_options: CallbackOptions | None = None,
    success_url: str | None = None,
    failure_url: str | None = None,
    authorization_url: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None = None,
    installation_store_bot_only: bool = False,
    token_rotation_expiration_minutes: int = 120,
    user_token_resolution: str = 'authed_user',
    state_validation_enabled: bool = True,
    state_store: slack_sdk.oauth.state_store.state_store.OAuthStateStore | None = None,
    state_cookie_name: str = 'slack-app-oauth-state',
    state_expiration_seconds: int = 600,
    logger: logging.Logger = <Logger slack_bolt.oauth.oauth_settings (WARNING)>)
    +(*,
    client_id: str | None = None,
    client_secret: str | None = None,
    scopes: Sequence[str] | str | None = None,
    user_scopes: Sequence[str] | str | None = None,
    redirect_uri: str | None = None,
    install_path: str = '/slack/install',
    install_page_rendering_enabled: bool = True,
    redirect_uri_path: str = '/slack/oauth_redirect',
    callback_options: CallbackOptions | None = None,
    success_url: str | None = None,
    failure_url: str | None = None,
    authorization_url: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None = None,
    installation_store_bot_only: bool = False,
    token_rotation_expiration_minutes: int = 120,
    user_token_resolution: str = 'authed_user',
    state_validation_enabled: bool = True,
    state_store: slack_sdk.oauth.state_store.state_store.OAuthStateStore | None = None,
    state_cookie_name: str = 'slack-app-oauth-state',
    state_expiration_seconds: int = 600,
    logger: logging.Logger = <Logger slack_bolt.oauth.oauth_settings (WARNING)>)
    diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 8cfd6f900..9b1349aea 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,3 +1,3 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.26.0" +__version__ = "1.27.0" From 8a8f285b6d89ab951628ac47591a1e22f99a6288 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 14 Nov 2025 09:48:03 -0500 Subject: [PATCH 815/865] fix: update the release instructions (#1400) --- .github/maintainers_guide.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index 8cd600271..f8edeeabd 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -147,15 +147,17 @@ Before creating a new release, ensure that everything on a stable branch has landed, then [run the tests](#run-all-the-unit-tests). 1. Create the commit for the release - 1. In `slack_bolt/version.py` bump the version number in adherence to [Semantic Versioning](http://semver.org/) and [Developmental Release](https://peps.python.org/pep-0440/#developmental-releases). + 1. Use the latest supported Python version. Using a [virtual environment](#python-and-friends) is recommended. + 2. In `slack_bolt/version.py` bump the version number in adherence to [Semantic Versioning](http://semver.org/) and [Developmental Release](https://peps.python.org/pep-0440/#developmental-releases). - Example: if the current version is `1.2.3`, a proper development bump would be `1.2.4.dev0` - `.dev` will indicate to pip that this is a [Development Release](https://peps.python.org/pep-0440/#developmental-releases) - Note that the `dev` version can be bumped in development releases: `1.2.4.dev0` -> `1.2.4.dev1` - 2. Build the docs with `./scripts/generate_api_docs.sh`. - 3. Commit with a message including the new version number. For example `1.2.4.dev0` & push the commit to a branch where the development release will live (create it if it does not exist) + 3. Build the docs with `./scripts/generate_api_docs.sh`. + 4. Commit with a message including the new version number. For example `1.2.4.dev0` & push the commit to a branch where the development release will live (create it if it does not exist) 1. `git checkout -b future-release` - 2. `git commit -m 'chore(release): version 1.2.4.dev0'` - 3. `git push -u origin future-release` + 2. `git add --all` (review files with `git status` before committing) + 3. `git commit -m 'chore(release): version 1.2.4.dev0'` + 4. `git push -u origin future-release` 2. Create a new GitHub Release 1. Navigate to the [Releases page](https://github.com/slackapi/bolt-python/releases). 2. Click the "Draft a new release" button. @@ -179,14 +181,16 @@ Before creating a new release, ensure that everything on the `main` branch since the last tag is in a releasable state! At a minimum, [run the tests](#run-all-the-unit-tests). 1. Create the commit for the release - 1. In `slack_bolt/version.py` bump the version number in adherence to [Semantic Versioning](http://semver.org/) and the [Versioning](#versioning-and-tags) section. - 2. Build the docs with `./scripts/generate_api_docs.sh`. - 3. Commit with a message including the new version number. For example `1.2.3` & push the commit to a branch and create a PR to sanity check. + 1. Use the latest supported Python version. Using a [virtual environment](#python-and-friends) is recommended. + 2. In `slack_bolt/version.py` bump the version number in adherence to [Semantic Versioning](http://semver.org/) and the [Versioning](#versioning-and-tags) section. + 3. Build the docs with `./scripts/generate_api_docs.sh`. + 4. Commit with a message including the new version number. For example `1.2.3` & push the commit to a branch and create a PR to sanity check. 1. `git checkout -b 1.2.3-release` - 2. `git commit -m 'chore(release): version 1.2.3'` - 3. `git push -u origin 1.2.3-release` - 4. Add relevant labels to the PR and add the PR to a GitHub Milestone. - 5. Merge in release PR after getting an approval from at least one maintainer. + 2. `git add --all` (review files with `git status` before committing) + 3. `git commit -m 'chore(release): version 1.2.3'` + 4. `git push -u origin 1.2.3-release` + 5. Add relevant labels to the PR and add the PR to a GitHub Milestone. + 6. Merge in release PR after getting an approval from at least one maintainer. 2. Create a new GitHub Release 1. Navigate to the [Releases page](https://github.com/slackapi/bolt-python/releases). 2. Click the "Draft a new release" button. From 9ea1a3bea9ce4f0a7813cf22b714535535c56b04 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 22:00:05 -0500 Subject: [PATCH 816/865] chore(deps): bump actions/setup-python from 6.0.0 to 6.1.0 (#1405) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codecov.yml | 2 +- .github/workflows/flake8.yml | 2 +- .github/workflows/mypy.yml | 2 +- .github/workflows/pypi-release.yml | 2 +- .github/workflows/tests.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 02c318a20..ff9669a8c 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -22,7 +22,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index f777996b4..fda0be853 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -20,7 +20,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ matrix.python-version }} - name: Run flake8 verification diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 52d59c830..64e600fe2 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -20,7 +20,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ matrix.python-version }} - name: Run mypy verification diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 21b472247..11413545a 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.x" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 42fd58ef6..5de9f35d1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,7 +32,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ matrix.python-version }} - name: Install synchronous dependencies From db6a5f679926f6e3d6451ee47bcc36af6ea5dbe6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 09:49:09 -0500 Subject: [PATCH 817/865] chore(deps): bump mypy from 1.18.2 to 1.19.0 (#1403) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: William Bergamin --- requirements/tools.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/tools.txt b/requirements/tools.txt index 73a342ca6..f9fbcdac1 100644 --- a/requirements/tools.txt +++ b/requirements/tools.txt @@ -1,3 +1,3 @@ -mypy==1.18.2 +mypy==1.19.0 flake8==7.3.0 black==25.1.0 From 91512bf6f8d6a34d0a84463b54fba3bdd08f207c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 09:53:43 -0500 Subject: [PATCH 818/865] chore(deps): update pytest-asyncio requirement from <1 to <2 (#1329) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: William Bergamin Co-authored-by: William Bergamin --- requirements/testing.txt | 2 +- .../socket_mode/mock_socket_mode_server.py | 2 +- .../socket_mode/test_async_aiohttp.py | 14 ++++++------- .../socket_mode/test_async_lazy_listeners.py | 14 ++++++------- .../socket_mode/test_async_websockets.py | 14 ++++++------- tests/adapter_tests_async/test_async_sanic.py | 14 ++++++------- tests/scenario_tests_async/test_app.py | 14 ++++++------- .../test_app_actor_user_token.py | 14 ++++++------- .../scenario_tests_async/test_app_bot_only.py | 14 ++++++------- .../test_app_custom_authorize.py | 14 ++++++------- .../scenario_tests_async/test_app_dispatch.py | 14 ++++++------- .../test_app_installation_store.py | 14 ++++++------- .../test_app_using_methods_in_class.py | 14 ++++++------- .../test_attachment_actions.py | 14 ++++++------- tests/scenario_tests_async/test_authorize.py | 14 ++++++------- .../test_block_actions.py | 14 ++++++------- .../test_block_actions_respond.py | 14 ++++++------- .../test_block_suggestion.py | 14 ++++++------- tests/scenario_tests_async/test_dialogs.py | 14 ++++++------- .../test_error_handler.py | 14 ++++++------- tests/scenario_tests_async/test_events.py | 14 ++++++------- .../test_events_assistant.py | 14 ++++++------- .../test_events_ignore_self.py | 14 ++++++------- .../test_events_org_apps.py | 14 ++++++------- .../test_events_request_verification.py | 14 ++++++------- .../test_events_shared_channels.py | 14 ++++++------- .../test_events_socket_mode.py | 14 ++++++------- .../test_events_token_revocations.py | 14 ++++++------- .../test_events_url_verification.py | 14 ++++++------- tests/scenario_tests_async/test_function.py | 12 +++++------ .../test_installation_store_authorize.py | 14 ++++++------- tests/scenario_tests_async/test_lazy.py | 14 ++++++------- .../test_listener_middleware.py | 14 ++++++------- tests/scenario_tests_async/test_message.py | 14 ++++++------- .../scenario_tests_async/test_message_bot.py | 14 ++++++------- .../test_message_changed.py | 14 ++++++------- .../test_message_deleted.py | 14 ++++++------- .../test_message_file_share.py | 14 ++++++------- .../test_message_thread_broadcast.py | 14 ++++++------- tests/scenario_tests_async/test_middleware.py | 14 ++++++------- tests/scenario_tests_async/test_shortcut.py | 14 ++++++------- .../test_slash_command.py | 14 ++++++------- tests/scenario_tests_async/test_ssl_check.py | 14 ++++++------- .../scenario_tests_async/test_view_closed.py | 14 ++++++------- .../test_view_submission.py | 14 ++++++------- .../test_web_client_customization.py | 14 ++++++------- .../test_workflow_steps.py | 14 ++++++------- .../test_workflow_steps_decorator_simple.py | 15 ++++++-------- ...test_workflow_steps_decorator_with_args.py | 15 ++++++-------- .../authorization/test_async_authorize.py | 14 ++++++------- .../context/test_async_complete.py | 19 ++++++++++-------- .../context/test_async_fail.py | 18 +++++++++-------- .../context/test_async_respond.py | 17 +++++++++------- .../context/test_async_say.py | 20 ++++++++++--------- .../context/test_async_set_status.py | 20 ++++++++++--------- .../test_async_set_suggested_prompts.py | 19 ++++++++++-------- .../test_single_team_authorization.py | 16 +++++++-------- .../test_request_verification.py | 7 ------- .../oauth/test_async_oauth_flow.py | 18 +++++++++-------- .../oauth/test_async_oauth_flow_sqlite3.py | 15 +++++++------- tests/utils.py | 10 ---------- 61 files changed, 377 insertions(+), 478 deletions(-) diff --git a/requirements/testing.txt b/requirements/testing.txt index 7cd7d353a..62fdcca2d 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -1,4 +1,4 @@ # pip install -r requirements/testing.txt -r testing_without_asyncio.txt -r async.txt -pytest-asyncio<1; +pytest-asyncio<2; diff --git a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py index 997657368..f59999192 100644 --- a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py +++ b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py @@ -28,8 +28,8 @@ def reset_server_state(): async def health(request: web.Request): wr = web.Response() - await wr.prepare(request) wr.set_status(200) + await wr.prepare(request) return wr async def link(request: web.Request): diff --git a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py index 1720f7ec6..e8077f10c 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py +++ b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py @@ -9,7 +9,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +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, stop_socket_mode_server, @@ -24,16 +24,14 @@ class TestSocketModeAiohttp: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) try: - setup_mock_web_api_server(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + yield # run the test here finally: + cleanup_mock_web_api_server(self) restore_os_env(old_os_env) @pytest.mark.asyncio diff --git a/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py index 11268c6a1..9144bd239 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py +++ b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py @@ -9,7 +9,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +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, stop_socket_mode_server, @@ -24,16 +24,14 @@ class TestSocketModeAiohttp: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) try: - setup_mock_web_api_server(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + yield # run the test here finally: + cleanup_mock_web_api_server(self) restore_os_env(old_os_env) @pytest.mark.asyncio diff --git a/tests/adapter_tests_async/socket_mode/test_async_websockets.py b/tests/adapter_tests_async/socket_mode/test_async_websockets.py index db2680fc6..84d20b2f9 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_websockets.py +++ b/tests/adapter_tests_async/socket_mode/test_async_websockets.py @@ -9,7 +9,7 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +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, stop_socket_mode_server, @@ -24,16 +24,14 @@ class TestSocketModeWebsockets: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) try: - setup_mock_web_api_server(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + yield # run the test here finally: + cleanup_mock_web_api_server(self) restore_os_env(old_os_env) @pytest.mark.asyncio diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py index 316110a87..9a948e3a6 100644 --- a/tests/adapter_tests_async/test_async_sanic.py +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -17,7 +17,7 @@ cleanup_mock_web_api_server, assert_auth_test_count, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestSanic: @@ -34,16 +34,14 @@ class TestSanic: def unique_sanic_app_name() -> str: return f"awesome-slack-app-{str(time()).replace('.', '-')}" - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) try: - setup_mock_web_api_server(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + yield # run the test here finally: + cleanup_mock_web_api_server(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_app.py b/tests/scenario_tests_async/test_app.py index 8a9512f74..e27dbd3b3 100644 --- a/tests/scenario_tests_async/test_app.py +++ b/tests/scenario_tests_async/test_app.py @@ -19,7 +19,7 @@ cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncApp: @@ -27,16 +27,14 @@ class TestAsyncApp: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def setup_method(self): diff --git a/tests/scenario_tests_async/test_app_actor_user_token.py b/tests/scenario_tests_async/test_app_actor_user_token.py index 35bda0798..0028096be 100644 --- a/tests/scenario_tests_async/test_app_actor_user_token.py +++ b/tests/scenario_tests_async/test_app_actor_user_token.py @@ -23,7 +23,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestApp: @@ -36,16 +36,14 @@ class TestApp: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_app_bot_only.py b/tests/scenario_tests_async/test_app_bot_only.py index 58e705bac..b350aeb2d 100644 --- a/tests/scenario_tests_async/test_app_bot_only.py +++ b/tests/scenario_tests_async/test_app_bot_only.py @@ -22,7 +22,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAppBotOnly: @@ -35,16 +35,14 @@ class TestAppBotOnly: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_app_custom_authorize.py b/tests/scenario_tests_async/test_app_custom_authorize.py index f1e435f63..2b0252645 100644 --- a/tests/scenario_tests_async/test_app_custom_authorize.py +++ b/tests/scenario_tests_async/test_app_custom_authorize.py @@ -26,7 +26,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAppCustomAuthorize: @@ -39,16 +39,14 @@ class TestAppCustomAuthorize: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_app_dispatch.py b/tests/scenario_tests_async/test_app_dispatch.py index 814d1897a..c483bed19 100644 --- a/tests/scenario_tests_async/test_app_dispatch.py +++ b/tests/scenario_tests_async/test_app_dispatch.py @@ -7,7 +7,7 @@ cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncAppDispatch: @@ -16,16 +16,14 @@ class TestAsyncAppDispatch: 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): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) @pytest.mark.asyncio diff --git a/tests/scenario_tests_async/test_app_installation_store.py b/tests/scenario_tests_async/test_app_installation_store.py index 053adf52f..16670f1b2 100644 --- a/tests/scenario_tests_async/test_app_installation_store.py +++ b/tests/scenario_tests_async/test_app_installation_store.py @@ -23,7 +23,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestApp: @@ -36,16 +36,14 @@ class TestApp: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): 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 a24fe9528..989de511e 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 @@ -18,7 +18,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAppUsingMethodsInClass: @@ -31,16 +31,14 @@ class TestAppUsingMethodsInClass: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def test_inspect_behaviors(self): diff --git a/tests/scenario_tests_async/test_attachment_actions.py b/tests/scenario_tests_async/test_attachment_actions.py index c9817dcd7..ea07e6ed0 100644 --- a/tests/scenario_tests_async/test_attachment_actions.py +++ b/tests/scenario_tests_async/test_attachment_actions.py @@ -13,7 +13,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncAttachmentActions: @@ -26,16 +26,14 @@ class TestAsyncAttachmentActions: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_authorize.py b/tests/scenario_tests_async/test_authorize.py index 2cd18531b..9d6e3d4af 100644 --- a/tests/scenario_tests_async/test_authorize.py +++ b/tests/scenario_tests_async/test_authorize.py @@ -16,7 +16,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env valid_token = "xoxb-valid" valid_user_token = "xoxp-valid" @@ -60,16 +60,14 @@ class TestAsyncAuthorize: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_block_actions.py b/tests/scenario_tests_async/test_block_actions.py index 716a5a80b..6441b2e4b 100644 --- a/tests/scenario_tests_async/test_block_actions.py +++ b/tests/scenario_tests_async/test_block_actions.py @@ -16,7 +16,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncBlockActions: @@ -29,16 +29,14 @@ class TestAsyncBlockActions: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_block_actions_respond.py b/tests/scenario_tests_async/test_block_actions_respond.py index 6d8f80884..b95e1bbf3 100644 --- a/tests/scenario_tests_async/test_block_actions_respond.py +++ b/tests/scenario_tests_async/test_block_actions_respond.py @@ -8,7 +8,7 @@ cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncBlockActionsRespond: @@ -20,16 +20,14 @@ class TestAsyncBlockActionsRespond: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) @pytest.mark.asyncio diff --git a/tests/scenario_tests_async/test_block_suggestion.py b/tests/scenario_tests_async/test_block_suggestion.py index fab3b48ec..2450957f4 100644 --- a/tests/scenario_tests_async/test_block_suggestion.py +++ b/tests/scenario_tests_async/test_block_suggestion.py @@ -14,7 +14,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncBlockSuggestion: @@ -27,16 +27,14 @@ class TestAsyncBlockSuggestion: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_dialogs.py b/tests/scenario_tests_async/test_dialogs.py index 1a3573d00..110fca45a 100644 --- a/tests/scenario_tests_async/test_dialogs.py +++ b/tests/scenario_tests_async/test_dialogs.py @@ -13,7 +13,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncAttachmentActions: @@ -26,16 +26,14 @@ class TestAsyncAttachmentActions: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_error_handler.py b/tests/scenario_tests_async/test_error_handler.py index 00cf5c07d..fb0b9ddae 100644 --- a/tests/scenario_tests_async/test_error_handler.py +++ b/tests/scenario_tests_async/test_error_handler.py @@ -16,7 +16,7 @@ cleanup_mock_web_api_server, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncErrorHandler: @@ -29,16 +29,14 @@ class TestAsyncErrorHandler: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) # ---------------- diff --git a/tests/scenario_tests_async/test_events.py b/tests/scenario_tests_async/test_events.py index 774d526ee..0cdaa0fac 100644 --- a/tests/scenario_tests_async/test_events.py +++ b/tests/scenario_tests_async/test_events.py @@ -19,7 +19,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncEvents: @@ -32,16 +32,14 @@ class TestAsyncEvents: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_events_assistant.py b/tests/scenario_tests_async/test_events_assistant.py index ac2c734c5..b131b4e38 100644 --- a/tests/scenario_tests_async/test_events_assistant.py +++ b/tests/scenario_tests_async/test_events_assistant.py @@ -14,7 +14,7 @@ cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncEventsAssistant: @@ -25,16 +25,14 @@ class TestAsyncEventsAssistant: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) @pytest.mark.asyncio diff --git a/tests/scenario_tests_async/test_events_ignore_self.py b/tests/scenario_tests_async/test_events_ignore_self.py index 14fcb509a..7ec9d0cce 100644 --- a/tests/scenario_tests_async/test_events_ignore_self.py +++ b/tests/scenario_tests_async/test_events_ignore_self.py @@ -11,7 +11,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncEventsIgnoreSelf: @@ -22,16 +22,14 @@ class TestAsyncEventsIgnoreSelf: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) @pytest.mark.asyncio diff --git a/tests/scenario_tests_async/test_events_org_apps.py b/tests/scenario_tests_async/test_events_org_apps.py index 187c59b77..e3706d9c6 100644 --- a/tests/scenario_tests_async/test_events_org_apps.py +++ b/tests/scenario_tests_async/test_events_org_apps.py @@ -20,7 +20,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env valid_token = "xoxb-valid" @@ -57,16 +57,14 @@ class TestAsyncOrgApps: signature_verifier = SignatureVerifier(signing_secret) web_client = AsyncWebClient(token=None, base_url=mock_api_server_base_url) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_events_request_verification.py b/tests/scenario_tests_async/test_events_request_verification.py index f314852b0..51ccfcd98 100644 --- a/tests/scenario_tests_async/test_events_request_verification.py +++ b/tests/scenario_tests_async/test_events_request_verification.py @@ -13,7 +13,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncEventsRequestVerification: @@ -23,16 +23,14 @@ class TestAsyncEventsRequestVerification: signature_verifier = SignatureVerifier(signing_secret) web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_events_shared_channels.py b/tests/scenario_tests_async/test_events_shared_channels.py index 0112de808..ca43f979a 100644 --- a/tests/scenario_tests_async/test_events_shared_channels.py +++ b/tests/scenario_tests_async/test_events_shared_channels.py @@ -17,7 +17,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env valid_token = "xoxb-valid" @@ -39,16 +39,14 @@ class TestAsyncEventsSharedChannels: signature_verifier = SignatureVerifier(signing_secret) web_client = AsyncWebClient(token=None, base_url=mock_api_server_base_url) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_events_socket_mode.py b/tests/scenario_tests_async/test_events_socket_mode.py index 75ab349ad..e3d3fc98f 100644 --- a/tests/scenario_tests_async/test_events_socket_mode.py +++ b/tests/scenario_tests_async/test_events_socket_mode.py @@ -13,7 +13,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncEvents: @@ -24,16 +24,14 @@ class TestAsyncEvents: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def build_valid_app_mention_request(self) -> AsyncBoltRequest: diff --git a/tests/scenario_tests_async/test_events_token_revocations.py b/tests/scenario_tests_async/test_events_token_revocations.py index ecadbc53f..0c079eede 100644 --- a/tests/scenario_tests_async/test_events_token_revocations.py +++ b/tests/scenario_tests_async/test_events_token_revocations.py @@ -18,7 +18,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env valid_token = "xoxb-valid" @@ -49,16 +49,14 @@ class TestEventsTokenRevocations: signature_verifier = SignatureVerifier(signing_secret) web_client = AsyncWebClient(token=None, base_url=mock_api_server_base_url) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_events_url_verification.py b/tests/scenario_tests_async/test_events_url_verification.py index c095791cd..123fd3cce 100644 --- a/tests/scenario_tests_async/test_events_url_verification.py +++ b/tests/scenario_tests_async/test_events_url_verification.py @@ -12,7 +12,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncEventsUrlVerification: @@ -22,16 +22,14 @@ class TestAsyncEventsUrlVerification: signature_verifier = SignatureVerifier(signing_secret) web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_function.py b/tests/scenario_tests_async/test_function.py index 142cc1d6c..abf3ffb48 100644 --- a/tests/scenario_tests_async/test_function.py +++ b/tests/scenario_tests_async/test_function.py @@ -33,16 +33,14 @@ class TestAsyncFunction: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_installation_store_authorize.py b/tests/scenario_tests_async/test_installation_store_authorize.py index ad0b24250..bef7d39e0 100644 --- a/tests/scenario_tests_async/test_installation_store_authorize.py +++ b/tests/scenario_tests_async/test_installation_store_authorize.py @@ -18,7 +18,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env valid_token = "xoxb-valid" valid_user_token = "xoxp-valid" @@ -63,16 +63,14 @@ class TestAsyncInstallationStoreAuthorize: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_lazy.py b/tests/scenario_tests_async/test_lazy.py index 02a2bd0fa..7bf780e08 100644 --- a/tests/scenario_tests_async/test_lazy.py +++ b/tests/scenario_tests_async/test_lazy.py @@ -14,7 +14,7 @@ cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncLazy: @@ -27,16 +27,14 @@ class TestAsyncLazy: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) # ---------------- diff --git a/tests/scenario_tests_async/test_listener_middleware.py b/tests/scenario_tests_async/test_listener_middleware.py index 4e6419e96..1b3d9b17d 100644 --- a/tests/scenario_tests_async/test_listener_middleware.py +++ b/tests/scenario_tests_async/test_listener_middleware.py @@ -12,7 +12,7 @@ cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncListenerMiddleware: @@ -25,16 +25,14 @@ class TestAsyncListenerMiddleware: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) body = { diff --git a/tests/scenario_tests_async/test_message.py b/tests/scenario_tests_async/test_message.py index cc0fbb8ec..374760323 100644 --- a/tests/scenario_tests_async/test_message.py +++ b/tests/scenario_tests_async/test_message.py @@ -16,7 +16,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncMessage: @@ -29,16 +29,14 @@ class TestAsyncMessage: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_message_bot.py b/tests/scenario_tests_async/test_message_bot.py index 8e5e28c87..50f29271c 100644 --- a/tests/scenario_tests_async/test_message_bot.py +++ b/tests/scenario_tests_async/test_message_bot.py @@ -13,7 +13,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncMessage: @@ -26,16 +26,14 @@ class TestAsyncMessage: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_message_changed.py b/tests/scenario_tests_async/test_message_changed.py index 15658d636..66468f14a 100644 --- a/tests/scenario_tests_async/test_message_changed.py +++ b/tests/scenario_tests_async/test_message_changed.py @@ -11,7 +11,7 @@ cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncMessageChanged: @@ -24,16 +24,14 @@ class TestAsyncMessageChanged: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_message_deleted.py b/tests/scenario_tests_async/test_message_deleted.py index 09f669d48..d5b6ba80c 100644 --- a/tests/scenario_tests_async/test_message_deleted.py +++ b/tests/scenario_tests_async/test_message_deleted.py @@ -11,7 +11,7 @@ cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncMessageDeleted: @@ -24,16 +24,14 @@ class TestAsyncMessageDeleted: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_message_file_share.py b/tests/scenario_tests_async/test_message_file_share.py index 6f55957ac..f156d9286 100644 --- a/tests/scenario_tests_async/test_message_file_share.py +++ b/tests/scenario_tests_async/test_message_file_share.py @@ -13,7 +13,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncMessageFileShare: @@ -26,16 +26,14 @@ class TestAsyncMessageFileShare: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_message_thread_broadcast.py b/tests/scenario_tests_async/test_message_thread_broadcast.py index c3ac6dd01..c15bbdc99 100644 --- a/tests/scenario_tests_async/test_message_thread_broadcast.py +++ b/tests/scenario_tests_async/test_message_thread_broadcast.py @@ -13,7 +13,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncMessageThreadBroadcast: @@ -26,16 +26,14 @@ class TestAsyncMessageThreadBroadcast: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_middleware.py b/tests/scenario_tests_async/test_middleware.py index 6272f17e4..f8dfe9623 100644 --- a/tests/scenario_tests_async/test_middleware.py +++ b/tests/scenario_tests_async/test_middleware.py @@ -20,7 +20,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env # Note that async middleware system does not support instance methods n a class. @@ -34,16 +34,14 @@ class TestAsyncMiddleware: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def build_request(self) -> AsyncBoltRequest: diff --git a/tests/scenario_tests_async/test_shortcut.py b/tests/scenario_tests_async/test_shortcut.py index 9ad4b2b03..bd3c595eb 100644 --- a/tests/scenario_tests_async/test_shortcut.py +++ b/tests/scenario_tests_async/test_shortcut.py @@ -13,7 +13,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncShortcut: @@ -26,16 +26,14 @@ class TestAsyncShortcut: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_slash_command.py b/tests/scenario_tests_async/test_slash_command.py index 6c6d9ef88..1ac02bce7 100644 --- a/tests/scenario_tests_async/test_slash_command.py +++ b/tests/scenario_tests_async/test_slash_command.py @@ -12,7 +12,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncSlashCommand: @@ -25,16 +25,14 @@ class TestAsyncSlashCommand: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_ssl_check.py b/tests/scenario_tests_async/test_ssl_check.py index 894bf82c2..ef32bc0dd 100644 --- a/tests/scenario_tests_async/test_ssl_check.py +++ b/tests/scenario_tests_async/test_ssl_check.py @@ -10,7 +10,7 @@ cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncSSLCheck: @@ -23,16 +23,14 @@ class TestAsyncSSLCheck: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_view_closed.py b/tests/scenario_tests_async/test_view_closed.py index a633b0d3b..1b86d22db 100644 --- a/tests/scenario_tests_async/test_view_closed.py +++ b/tests/scenario_tests_async/test_view_closed.py @@ -13,7 +13,7 @@ cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncViewClosed: @@ -26,16 +26,14 @@ class TestAsyncViewClosed: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_view_submission.py b/tests/scenario_tests_async/test_view_submission.py index efb1c25f5..49a6e8fc5 100644 --- a/tests/scenario_tests_async/test_view_submission.py +++ b/tests/scenario_tests_async/test_view_submission.py @@ -13,7 +13,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env body = { @@ -198,16 +198,14 @@ class TestAsyncViewSubmission: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_web_client_customization.py b/tests/scenario_tests_async/test_web_client_customization.py index c9b42a617..8ed78b2c3 100644 --- a/tests/scenario_tests_async/test_web_client_customization.py +++ b/tests/scenario_tests_async/test_web_client_customization.py @@ -16,7 +16,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestWebClientCustomization: @@ -30,16 +30,14 @@ class TestWebClientCustomization: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_workflow_steps.py b/tests/scenario_tests_async/test_workflow_steps.py index ea9766361..a99dedbfe 100644 --- a/tests/scenario_tests_async/test_workflow_steps.py +++ b/tests/scenario_tests_async/test_workflow_steps.py @@ -21,7 +21,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncWorkflowSteps: @@ -34,16 +34,14 @@ class TestAsyncWorkflowSteps: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py b/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py index f404bf947..1224949ae 100644 --- a/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py +++ b/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py @@ -21,7 +21,7 @@ cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncWorkflowStepsDecorator: @@ -34,19 +34,16 @@ class TestAsyncWorkflowStepsDecorator: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) self.app.step(copy_review_step) - - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py b/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py index 02d0ad8c7..53bec512d 100644 --- a/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py +++ b/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py @@ -22,7 +22,7 @@ cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncWorkflowStepsDecorator: @@ -35,19 +35,16 @@ class TestAsyncWorkflowStepsDecorator: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) self.app.step(copy_review_step) - - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): diff --git a/tests/slack_bolt_async/authorization/test_async_authorize.py b/tests/slack_bolt_async/authorization/test_async_authorize.py index f978ebfa7..d98a1062f 100644 --- a/tests/slack_bolt_async/authorization/test_async_authorize.py +++ b/tests/slack_bolt_async/authorization/test_async_authorize.py @@ -21,7 +21,7 @@ assert_auth_test_count_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncAuthorize: @@ -30,16 +30,14 @@ class TestAsyncAuthorize: base_url=mock_api_server_base_url, ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) @pytest.mark.asyncio diff --git a/tests/slack_bolt_async/context/test_async_complete.py b/tests/slack_bolt_async/context/test_async_complete.py index b2a464f83..4277d4218 100644 --- a/tests/slack_bolt_async/context/test_async_complete.py +++ b/tests/slack_bolt_async/context/test_async_complete.py @@ -7,20 +7,23 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncComplete: - @pytest.fixture - def event_loop(self): + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() setup_mock_web_api_server(self) valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" - self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) - - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server(self) + restore_os_env(old_os_env) @pytest.mark.asyncio async def test_complete(self): diff --git a/tests/slack_bolt_async/context/test_async_fail.py b/tests/slack_bolt_async/context/test_async_fail.py index d4708927f..d344a6c95 100644 --- a/tests/slack_bolt_async/context/test_async_fail.py +++ b/tests/slack_bolt_async/context/test_async_fail.py @@ -7,20 +7,22 @@ setup_mock_web_api_server, cleanup_mock_web_api_server, ) +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncFail: - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() setup_mock_web_api_server(self) valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" - self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) - - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server(self) + restore_os_env(old_os_env) @pytest.mark.asyncio async def test_fail(self): diff --git a/tests/slack_bolt_async/context/test_async_respond.py b/tests/slack_bolt_async/context/test_async_respond.py index eba44d6a0..b47ef1056 100644 --- a/tests/slack_bolt_async/context/test_async_respond.py +++ b/tests/slack_bolt_async/context/test_async_respond.py @@ -1,6 +1,6 @@ import pytest -from tests.utils import get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env from slack_bolt.context.respond.async_respond import AsyncRespond from tests.mock_web_api_server import ( cleanup_mock_web_api_server_async, @@ -9,13 +9,16 @@ class TestAsyncRespond: - @pytest.fixture - def event_loop(self): + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) @pytest.mark.asyncio async def test_respond(self): diff --git a/tests/slack_bolt_async/context/test_async_say.py b/tests/slack_bolt_async/context/test_async_say.py index efa90febc..d8d63ae8a 100644 --- a/tests/slack_bolt_async/context/test_async_say.py +++ b/tests/slack_bolt_async/context/test_async_say.py @@ -4,21 +4,23 @@ from slack_bolt.context.say.async_say import AsyncSay from tests.mock_web_api_server import cleanup_mock_web_api_server_async, setup_mock_web_api_server_async -from tests.utils import get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncSay: - @pytest.fixture - def event_loop(self): + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() setup_mock_web_api_server_async(self) valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" - self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) - - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) @pytest.mark.asyncio async def test_say(self): diff --git a/tests/slack_bolt_async/context/test_async_set_status.py b/tests/slack_bolt_async/context/test_async_set_status.py index 8df34171f..e785ff89e 100644 --- a/tests/slack_bolt_async/context/test_async_set_status.py +++ b/tests/slack_bolt_async/context/test_async_set_status.py @@ -4,21 +4,23 @@ from slack_bolt.context.set_status.async_set_status import AsyncSetStatus from tests.mock_web_api_server import cleanup_mock_web_api_server_async, setup_mock_web_api_server_async -from tests.utils import get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncSetStatus: - @pytest.fixture - def event_loop(self): + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() setup_mock_web_api_server_async(self) valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" - self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) - - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) @pytest.mark.asyncio async def test_set_status(self): diff --git a/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py index 70a24efcb..2a09434a8 100644 --- a/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py +++ b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py @@ -6,20 +6,23 @@ from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncSetSuggestedPrompts: - @pytest.fixture - def event_loop(self): + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() setup_mock_web_api_server(self) valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" - self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) - - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server(self) + restore_os_env(old_os_env) @pytest.mark.asyncio async def test_set_suggested_prompts(self): diff --git a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py index e90eae5c8..0ddb6281d 100644 --- a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py +++ b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py @@ -11,7 +11,7 @@ cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, ) -from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env async def next(): @@ -19,18 +19,16 @@ async def next(): class TestSingleTeamAuthorization: - mock_api_server_base_url = "http://localhost:8888" - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + self.mock_api_server_base_url = "http://localhost:8888" + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) @pytest.mark.asyncio diff --git a/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py b/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py index 0b05079a9..c097dd146 100644 --- a/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py +++ b/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py @@ -1,7 +1,6 @@ from time import time import pytest -from tests.utils import get_event_loop from slack_sdk.signature import SignatureVerifier from slack_bolt.middleware.request_verification.async_request_verification import ( @@ -32,12 +31,6 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - @pytest.fixture - def event_loop(self): - loop = get_event_loop() - yield loop - loop.close() - @pytest.mark.asyncio async def test_valid(self): middleware = AsyncRequestVerification(signing_secret="secret") diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index 1a7b89552..5714e1a6a 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -3,7 +3,7 @@ from urllib.parse import quote import pytest -from tests.utils import get_event_loop +from tests.utils import remove_os_env_temporarily, restore_os_env from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore @@ -30,15 +30,17 @@ class TestAsyncOAuthFlow: - mock_api_server_base_url = "http://localhost:8888" - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + try: + self.mock_api_server_base_url = "http://localhost:8888" + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) def next(self): pass diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py index 238ce8873..00300929f 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py @@ -1,7 +1,6 @@ import pytest from slack_sdk.web.async_client import AsyncWebClient -from tests.utils import get_event_loop from slack_bolt import BoltResponse from slack_bolt.oauth.async_callback_options import ( AsyncFailureArgs, @@ -17,15 +16,15 @@ class TestAsyncOAuthFlowSQLite3: - mock_api_server_base_url = "http://localhost:8888" - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): setup_mock_web_api_server_async(self) - loop = get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server_async(self) + try: + self.mock_api_server_base_url = "http://localhost:8888" + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) def next(self): pass diff --git a/tests/utils.py b/tests/utils.py index eb9759c5d..e06d0f861 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -13,13 +13,3 @@ def remove_os_env_temporarily() -> dict: def restore_os_env(old_env: dict) -> None: os.environ.update(old_env) - - -def get_event_loop(): - try: - return asyncio.get_event_loop() - except RuntimeError as ex: - if "There is no current event loop in thread" in str(ex): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - return loop From 2e042283dd1fac79a797a9e274fbea29c25be4c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:12:40 -0500 Subject: [PATCH 819/865] chore(deps): bump actions/checkout from 5.0.0 to 6.0.0 (#1404) --- .github/workflows/codecov.yml | 2 +- .github/workflows/flake8.yml | 2 +- .github/workflows/mypy.yml | 2 +- .github/workflows/pypi-release.yml | 2 +- .github/workflows/tests.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index ff9669a8c..f93864817 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -18,7 +18,7 @@ jobs: env: BOLT_PYTHON_CODECOV_RUNNING: "1" steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index fda0be853..b0602d60a 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -16,7 +16,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 64e600fe2..650c8f9bd 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -16,7 +16,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 11413545a..80fac6d8c 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -18,7 +18,7 @@ jobs: contents: read steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: ref: ${{ github.event.release.tag_name || github.ref }} persist-credentials: false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5de9f35d1..6de00d7da 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,7 +28,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} From 46f0b9506685c347b475c6ceb19ccee9520ed810 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:02:51 -0500 Subject: [PATCH 820/865] chore(deps): update cheroot requirement from <11 to <12 (#1380) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Michael Brooks Co-authored-by: William Bergamin --- requirements/adapter.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/adapter.txt b/requirements/adapter.txt index 3cefd621d..b2097bcdb 100644 --- a/requirements/adapter.txt +++ b/requirements/adapter.txt @@ -4,7 +4,7 @@ boto3<=2 bottle>=0.12,<1 chalice>=1.28,<2; -cheroot<11 # https://github.com/slackapi/bolt-python/issues/1374 +cheroot<12 CherryPy>=18,<19 Django>=3,<6 falcon>=2,<5; python_version<"3.11" From 91de836bd920cb392ca289af0c0923c859433e39 Mon Sep 17 00:00:00 2001 From: Luke Russell <31357343+lukegalbraithrussell@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:31:52 -0800 Subject: [PATCH 821/865] docs: updates old links throughout (#1409) --- docs/english/concepts/ai-apps.md | 6 +++--- docs/english/concepts/message-sending.md | 6 +++--- docs/japanese/concepts/select-menu-options.md | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/english/concepts/ai-apps.md b/docs/english/concepts/ai-apps.md index 44bd08df1..3b057bc7e 100644 --- a/docs/english/concepts/ai-apps.md +++ b/docs/english/concepts/ai-apps.md @@ -337,9 +337,9 @@ See the [_Adding and handling feedback_](#adding-and-handling-feedback) section Three Web API methods work together to provide users a text streaming experience: -* the [`chat.startStream`](/reference/methods/chat.startstream) method starts the text stream, -* the [`chat.appendStream`](/reference/methods/chat.appendstream) method appends text to the stream, and -* the [`chat.stopStream`](/reference/methods/chat.stopstream) method stops it. +* the [`chat.startStream`](/reference/methods/chat.startStream) method starts the text stream, +* the [`chat.appendStream`](/reference/methods/chat.appendStream) method appends text to the stream, and +* the [`chat.stopStream`](/reference/methods/chat.stopStream) method stops it. Since you're using Bolt for Python, built upon the Python Slack SDK, you can use the [`chat_stream()`](https://docs.slack.dev/tools/python-slack-sdk/reference/web/client.html#slack_sdk.web.client.WebClient.chat_stream) utility to streamline all three aspects of streaming in your app's messages. diff --git a/docs/english/concepts/message-sending.md b/docs/english/concepts/message-sending.md index 9741bb396..87c433129 100644 --- a/docs/english/concepts/message-sending.md +++ b/docs/english/concepts/message-sending.md @@ -45,9 +45,9 @@ def show_datepicker(event, say): You can have your app's messages stream in to replicate conventional AI chatbot behavior. This is done through three Web API methods: -* [`chat_startStream`](/reference/methods/chat.startstream) -* [`chat_appendStream`](/reference/methods/chat.appendstream) -* [`chat_stopStream`](/reference/methods/chat.stopstream) +* [`chat_startStream`](/reference/methods/chat.startStream) +* [`chat_appendStream`](/reference/methods/chat.appendStream) +* [`chat_stopStream`](/reference/methods/chat.stopStream) The Python Slack SDK provides a [`chat_stream()`](https://docs.slack.dev/tools/python-slack-sdk/reference/web/client.html#slack_sdk.web.client.WebClient.chat_stream) helper utility to streamline calling these methods. Here's an excerpt from our [Assistant template app](https://github.com/slack-samples/bolt-python-assistant-template): diff --git a/docs/japanese/concepts/select-menu-options.md b/docs/japanese/concepts/select-menu-options.md index 1c2d41c58..2c12af623 100644 --- a/docs/japanese/concepts/select-menu-options.md +++ b/docs/japanese/concepts/select-menu-options.md @@ -2,7 +2,7 @@ `options()` メソッドは、Slack からのオプション(セレクトメニュー内の動的な選択肢)をリクエストするペイロードをリッスンします。 [`action()` と同様に](/tools/bolt-python/concepts/actions)、文字列型の `action_id` または制約付きオブジェクトが必要です。 -外部データソースを使って選択メニューをロードするためには、末部に `/slack/events` が付加された URL を Options Load URL として予め設定しておく必要があります。 +外部データソースを使って選択メニューをロードするためには、末部に `/slack/events` が付加された URL を Options Load URL として予め設定しておく必要があります。 `external_select` メニューでは `action_id` を指定することをおすすめしています。ただし、ダイアログを利用している場合、ダイアログが Block Kit に対応していないため、`callback_id` をフィルタリングするための制約オブジェクトを使用する必要があります。 From f02f8c6330e70e0afd9253a59ab8410237b9aab7 Mon Sep 17 00:00:00 2001 From: Luke Russell <31357343+lukegalbraithrussell@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:55:49 -0800 Subject: [PATCH 822/865] docs: updates outmoded links and standardizes markdown links (#1410) --- docs/english/building-an-app.md | 4 ++-- docs/english/getting-started.md | 2 +- docs/english/legacy/steps-from-apps.md | 10 +++------ docs/japanese/concepts/acknowledge.md | 2 +- docs/japanese/concepts/actions.md | 6 ++++-- docs/japanese/concepts/adapters.md | 6 +++--- docs/japanese/concepts/assistant.md | 2 +- docs/japanese/concepts/async.md | 4 ++-- docs/japanese/concepts/commands.md | 2 +- docs/japanese/concepts/event-listening.md | 2 +- docs/japanese/concepts/global-middleware.md | 2 +- docs/japanese/concepts/listener-middleware.md | 2 +- docs/japanese/concepts/message-listening.md | 2 +- docs/japanese/concepts/message-sending.md | 2 +- docs/japanese/concepts/opening-modals.md | 6 +++--- docs/japanese/concepts/select-menu-options.md | 2 +- docs/japanese/concepts/shortcuts.md | 2 +- .../concepts/updating-pushing-views.md | 8 +++---- docs/japanese/concepts/view-submissions.md | 10 ++++----- docs/japanese/concepts/web-api.md | 2 +- docs/japanese/getting-started.md | 4 ++-- docs/japanese/legacy/steps-from-apps.md | 21 +++++++------------ 22 files changed, 48 insertions(+), 55 deletions(-) diff --git a/docs/english/building-an-app.md b/docs/english/building-an-app.md index ee0dac967..bde340961 100644 --- a/docs/english/building-an-app.md +++ b/docs/english/building-an-app.md @@ -55,7 +55,7 @@ We're going to use bot and app-level tokens for this guide. :::tip[Not sharing is sometimes caring] -Treat your tokens like passwords and [keep them safe](/authentication/best-practices-for-security). Your app uses tokens to post and retrieve information from Slack workspaces. +Treat your tokens like passwords and [keep them safe](/security). Your app uses tokens to post and retrieve information from Slack workspaces. ::: @@ -103,7 +103,7 @@ $ export SLACK_APP_TOKEN= :::warning[Keep it secret. Keep it safe.] -Remember to keep your tokens secure. At a minimum, you should avoid checking them into public version control, and access them via environment variables as we've done above. Check out the API documentation for more on [best practices for app security](/authentication/best-practices-for-security). +Remember to keep your tokens secure. At a minimum, you should avoid checking them into public version control, and access them via environment variables as we've done above. Check out the API documentation for more on [best practices for app security](/security). ::: diff --git a/docs/english/getting-started.md b/docs/english/getting-started.md index cc428a93a..934dd3bae 100644 --- a/docs/english/getting-started.md +++ b/docs/english/getting-started.md @@ -147,7 +147,7 @@ The above command works on Linux and macOS but [similar commands are available o :::warning[Keep it secret. Keep it safe.] -Treat your tokens like a password and [keep it safe](/authentication/best-practices-for-security). Your app uses these to retrieve and send information to Slack. +Treat your tokens like a password and [keep it safe](/security). Your app uses these to retrieve and send information to Slack. ::: diff --git a/docs/english/legacy/steps-from-apps.md b/docs/english/legacy/steps-from-apps.md index 03b9fa8ff..bced20f9e 100644 --- a/docs/english/legacy/steps-from-apps.md +++ b/docs/english/legacy/steps-from-apps.md @@ -68,14 +68,12 @@ app.step(ws) ## Adding or editing steps from apps -When a builder adds (or later edits) your step in their workflow, your app will receive a [`workflow_step_edit` event](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step_edit-payload). The `edit` callback in your `WorkflowStep` configuration will be run when this event is received. +When a builder adds (or later edits) your step in their workflow, your app will receive a `workflow_step_edit` event. The `edit` callback in your `WorkflowStep` configuration will be run when this event is received. -Whether a builder is adding or editing a step, you need to send them a [step from app configuration modal](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-configuration-view-object). This modal is where step-specific settings are chosen, and it has more restrictions than typical modals—most notably, it cannot include `title`, `submit`, or `close` properties. By default, the configuration modal's `callback_id` will be the same as the step from app. +Whether a builder is adding or editing a step, you need to send them a step from app configuration modal. This modal is where step-specific settings are chosen, and it has more restrictions than typical modals—most notably, it cannot include `title`, `submit`, or `close` properties. By default, the configuration modal's `callback_id` will be the same as the step from app. Within the `edit` callback, the `configure()` utility can be used to easily open your step's configuration modal by passing in the view's blocks with the corresponding `blocks` argument. To disable saving the configuration before certain conditions are met, you can also pass in `submit_disabled` with a value of `True`. -To learn more about opening configuration modals, [read the documentation](/legacy/legacy-steps-from-apps/). - Refer to the module documents ([common](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [step-specific](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) to learn the available arguments. ```python @@ -126,8 +124,6 @@ Within the `save` callback, the `update()` method can be used to save the builde - `step_name` overrides the default Step name - `step_image_url` overrides the default Step image -To learn more about how to structure these parameters, [read the documentation](/legacy/legacy-steps-from-apps/). - Refer to the module documents ([common](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [step-specific](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) to learn the available arguments. ```python @@ -167,7 +163,7 @@ app.step(ws) ## Executing steps from apps -When your step from app is executed by an end user, your app will receive a [`workflow_step_execute` event](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object). The `execute` callback in your `WorkflowStep` configuration will be run when this event is received. +When your step from app is executed by an end user, your app will receive a `workflow_step_execute` event. The `execute` callback in your `WorkflowStep` configuration will be run when this event is received. Using the `inputs` from the `save` callback, this is where you can make third-party API calls, save information to a database, update the user's Home tab, or decide the outputs that will be available to subsequent steps from apps by mapping values to the `outputs` object. diff --git a/docs/japanese/concepts/acknowledge.md b/docs/japanese/concepts/acknowledge.md index 2b3756009..36ba6cba4 100644 --- a/docs/japanese/concepts/acknowledge.md +++ b/docs/japanese/concepts/acknowledge.md @@ -8,7 +8,7 @@ FaaS / serverless 環境を使う場合、 `ack()` するタイミングが異なります。 これに関する詳細は [Lazy listeners (FaaS)](/tools/bolt-python/concepts/lazy-listeners) を参照してください。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ```python # 外部データを使用する選択メニューオプションに応答するサンプル @app.options("menu_selection") diff --git a/docs/japanese/concepts/actions.md b/docs/japanese/concepts/actions.md index 60019ebb7..8f1d1180e 100644 --- a/docs/japanese/concepts/actions.md +++ b/docs/japanese/concepts/actions.md @@ -8,7 +8,8 @@ Bolt アプリは `action` メソッドを用いて、ボタンのクリック `action()` を使ったすべての例で `ack()` が使用されていることに注目してください。アクションのリスナー内では、Slack からのリクエストを受信したことを確認するために、`ack()` 関数を呼び出す必要があります。これについては、[リクエストの確認](/tools/bolt-python/concepts/acknowledge)セクションで説明しています。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 + ```python # 'approve_button' という action_id のブロックエレメントがトリガーされるたびに、このリスナーが呼び出させれる @app.action("approve_button") @@ -45,7 +46,8 @@ def update_message(ack, body, client): 2 つ目は、`respond()` を使用する方法です。これは、アクションに関連づけられた `response_url` を使ったメッセージ送信を行うためのユーティリティです。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 + ```python # 'approve_button' という action_id のインタラクティブコンポーネントがトリガーされると、このリスナーが呼ばれる @app.action("approve_button") diff --git a/docs/japanese/concepts/adapters.md b/docs/japanese/concepts/adapters.md index a58ed34a2..6ed804c26 100644 --- a/docs/japanese/concepts/adapters.md +++ b/docs/japanese/concepts/adapters.md @@ -1,12 +1,12 @@ # アダプター -アダプターは Slack から届く受信リクエストの受付とパーズを担当し、それらのリクエストを `BoltRequest` の形式に変換して Bolt アプリに引き渡します。 +アダプターは Slack から届く受信リクエストの受付とパーズを担当し、それらのリクエストを [`BoltRequest`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/request/request.py) の形式に変換して Bolt アプリに引き渡します。 -デフォルトでは、Bolt の組み込みの `HTTPServer` アダプターが使われます。このアダプターは、ローカルで開発するのには問題がありませんが、本番環境での利用は推奨されていません。Bolt for Python には複数の組み込みのアダプターが用意されており、必要に応じてインポートしてアプリで使用することができます。組み込みのアダプターは Flask、Django、Starlette をはじめとする様々な人気の Python フレームワークをサポートしています。これらのアダプターは、あなたが選択した本番環境で利用可能な Webサーバーとともに利用することができます。 +デフォルトでは、Bolt の組み込みの [`HTTPServer`](https://docs.python.org/3/library/http.server.html) アダプターが使われます。このアダプターは、ローカルで開発するのには問題がありませんが、**本番環境での利用は推奨されていません**。Bolt for Python には複数の組み込みのアダプターが用意されており、必要に応じてインポートしてアプリで使用することができます。組み込みのアダプターは Flask、Django、Starlette をはじめとする様々な人気の Python フレームワークをサポートしています。これらのアダプターは、あなたが選択した本番環境で利用可能な Webサーバーとともに利用することができます。 アダプターを使用するには、任意のフレームワークを使ってアプリを開発し、そのコードに対応するアダプターをインポートします。その後、アダプターのインスタンスを初期化して、受信リクエストの受付とパーズを行う関数を呼び出します。 -すべてのアダプターの一覧と、設定や使い方のサンプルは、リポジトリの `examples` フォルダをご覧ください。 +すべてのアダプターの一覧と、設定や使い方のサンプルは、リポジトリの [`examples` フォルダ](https://github.com/slackapi/bolt-python/tree/main/examples)をご覧ください。 ```python from slack_bolt import App diff --git a/docs/japanese/concepts/assistant.md b/docs/japanese/concepts/assistant.md index e819f5361..664108607 100644 --- a/docs/japanese/concepts/assistant.md +++ b/docs/japanese/concepts/assistant.md @@ -68,7 +68,7 @@ def respond_in_assistant_thread( app.use(assistant) ``` -リスナーに指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +リスナーに指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ユーザーがチャンネルの横でアシスタントスレッドを開いた場合、そのチャンネルの情報は、そのスレッドの `AssistantThreadContext` データとして保持され、 `get_thread_context` ユーティリティを使ってアクセスすることができます。Bolt がこのユーティリティを提供している理由は、後続のユーザーメッセージ投稿のイベントペイロードに最新のスレッドのコンテキスト情報は含まれないためです。そのため、アプリはコンテキスト情報が変更されたタイミングでそれを何らかの方法で保存し、後続のメッセージイベントのリスナーコードから参照できるようにする必要があります。 diff --git a/docs/japanese/concepts/async.md b/docs/japanese/concepts/async.md index cc38886d4..6687dcff5 100644 --- a/docs/japanese/concepts/async.md +++ b/docs/japanese/concepts/async.md @@ -1,8 +1,8 @@ # Async(asyncio)の使用 -非同期バージョンの Bolt を使用する場合は、`App` の代わりに `AsyncApp` インスタンスをインポートして初期化します。`AsyncApp` では AIOHTTP を使って API リクエストを行うため、`aiohttp` をインストールする必要があります(`requirements.txt` に追記するか、`pip install aiohttp` を実行します)。 +非同期バージョンの Bolt を使用する場合は、`App` の代わりに `AsyncApp` インスタンスをインポートして初期化します。`AsyncApp` では [AIOHTTP](https://docs.aiohttp.org/) を使って API リクエストを行うため、`aiohttp` をインストールする必要があります(`requirements.txt` に追記するか、`pip install aiohttp` を実行します)。 -非同期バージョンのプロジェクトのサンプルは、リポジトリの `examples` フォルダにあります。 +非同期バージョンのプロジェクトのサンプルは、リポジトリの [`examples` フォルダ](https://github.com/slackapi/bolt-python/tree/main/examples)にあります。 ```python # aiohttp のインストールが必要です diff --git a/docs/japanese/concepts/commands.md b/docs/japanese/concepts/commands.md index c89568dbe..ebb43c4d3 100644 --- a/docs/japanese/concepts/commands.md +++ b/docs/japanese/concepts/commands.md @@ -8,7 +8,7 @@ アプリの設定でコマンドを登録するときは、リクエスト URL の末尾に `/slack/events` をつけます。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ```python # echoコマンドは受け取ったコマンドをそのまま返す @app.command("/echo") diff --git a/docs/japanese/concepts/event-listening.md b/docs/japanese/concepts/event-listening.md index c13638226..7b21f1fa4 100644 --- a/docs/japanese/concepts/event-listening.md +++ b/docs/japanese/concepts/event-listening.md @@ -4,7 +4,7 @@ `event()` メソッドには `str` 型の `eventType` を指定する必要があります。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ```python # ユーザーがワークスペースに参加した際に、自己紹介を促すメッセージを指定のチャンネルに送信 @app.event("team_join") diff --git a/docs/japanese/concepts/global-middleware.md b/docs/japanese/concepts/global-middleware.md index 884008090..01ca417ac 100644 --- a/docs/japanese/concepts/global-middleware.md +++ b/docs/japanese/concepts/global-middleware.md @@ -4,7 +4,7 @@ グローバルミドルウェアでもリスナーミドルウェアでも、次のミドルウェアに実行チェーンの制御をリレーするために、`next()` を呼び出す必要があります。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ```python @app.use diff --git a/docs/japanese/concepts/listener-middleware.md b/docs/japanese/concepts/listener-middleware.md index 2b3ea9323..425ae4ea7 100644 --- a/docs/japanese/concepts/listener-middleware.md +++ b/docs/japanese/concepts/listener-middleware.md @@ -4,7 +4,7 @@ 非常にシンプルなリスナーミドルウェアの場合であれば、`next()` メソッドを呼び出す代わりに `bool` 値(処理を継続したい場合は `True`)を返すだけで済む「リスナーマッチャー」を使うとよいでしょう。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ```python # ボットからのメッセージをフィルタリングするリスナーミドルウェア diff --git a/docs/japanese/concepts/message-listening.md b/docs/japanese/concepts/message-listening.md index 824ac67c8..dae729b51 100644 --- a/docs/japanese/concepts/message-listening.md +++ b/docs/japanese/concepts/message-listening.md @@ -4,7 +4,7 @@ `message()` の引数には `str` 型または `re.Pattern` オブジェクトを指定できます。この条件のパターンに一致しないメッセージは除外されます。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ```python # '👋' が含まれるすべてのメッセージに一致 @app.message(":wave:") diff --git a/docs/japanese/concepts/message-sending.md b/docs/japanese/concepts/message-sending.md index a299144b6..ace67051b 100644 --- a/docs/japanese/concepts/message-sending.md +++ b/docs/japanese/concepts/message-sending.md @@ -4,7 +4,7 @@ リスナー関数の外でメッセージを送信したい場合や、より高度な処理(特定のエラーの処理など)を実行したい場合は、[Bolt インスタンスにアタッチされたクライアント](/tools/bolt-python/concepts/web-api)の `client.chat_postMessage` を呼び出します。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ```python # 'knock knock' が含まれるメッセージをリッスンし、イタリック体で 'Who's there?' と返信 @app.message("knock knock") diff --git a/docs/japanese/concepts/opening-modals.md b/docs/japanese/concepts/opening-modals.md index 68e3b947c..65342afb1 100644 --- a/docs/japanese/concepts/opening-modals.md +++ b/docs/japanese/concepts/opening-modals.md @@ -1,12 +1,12 @@ # モーダルの開始 -モーダルは、ユーザーからのデータの入力を受け付けたり、動的な情報を表示したりするためのインターフェイスです。組み込みの APIクライアントの `views.open` メソッドに、有効な `trigger_id` とビューのペイロードを指定してモーダルを開始します。 +[モーダル](/surfaces/modals)は、ユーザーからのデータの入力を受け付けたり、動的な情報を表示したりするためのインターフェイスです。組み込みの APIクライアントの [`views.open`](/reference/methods/views.open/) メソッドに、有効な `trigger_id` と[ビューのペイロード](/reference/interaction-payloads/view-interactions-payload/#view_submission)を指定してモーダルを開始します。 ショートカットの実行、ボタンを押下、選択メニューの操作などの操作の場合、Request URL に送信されるペイロードには `trigger_id` が含まれます。 -モーダルの生成方法についての詳細は、API ドキュメントを参照してください。 +モーダルの生成方法についての詳細は、[API ドキュメント](/surfaces/modals#composing_views)を参照してください。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ```python # ショートカットの呼び出しをリッスン diff --git a/docs/japanese/concepts/select-menu-options.md b/docs/japanese/concepts/select-menu-options.md index 2c12af623..4f3a5f357 100644 --- a/docs/japanese/concepts/select-menu-options.md +++ b/docs/japanese/concepts/select-menu-options.md @@ -10,7 +10,7 @@ さらに、ユーザーが入力したキーワードに基づいたオプションを返すようフィルタリングロジックを適用することもできます。 これは `payload` という引数の ` value` の値に基づいて、それぞれのパターンで異なるオプションの一覧を返すように実装することができます。 Bolt for Python のすべてのリスナーやミドルウェアでは、[多くの有用な引数](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)にアクセスすることができますので、チェックしてみてください。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ```python # 外部データを使用する選択メニューオプションに応答するサンプル例 @app.options("external_action") diff --git a/docs/japanese/concepts/shortcuts.md b/docs/japanese/concepts/shortcuts.md index d9a8ba050..39fb10ba8 100644 --- a/docs/japanese/concepts/shortcuts.md +++ b/docs/japanese/concepts/shortcuts.md @@ -12,7 +12,7 @@ ⚠️ グローバルショートカットのペイロードにはチャンネル ID が **含まれません**。アプリでチャンネル ID を取得する必要がある場合は、モーダル内に [`conversations_select`](/reference/block-kit/block-elements/multi-select-menu-element#conversation_multi_select) エレメントを配置します。メッセージショートカットにはチャンネル ID が含まれます。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ```python # 'open_modal' という callback_id のショートカットをリッスン @app.shortcut("open_modal") diff --git a/docs/japanese/concepts/updating-pushing-views.md b/docs/japanese/concepts/updating-pushing-views.md index cc32f5b69..2bbaf5ae5 100644 --- a/docs/japanese/concepts/updating-pushing-views.md +++ b/docs/japanese/concepts/updating-pushing-views.md @@ -1,6 +1,6 @@ # モーダルの更新と多重表示 -モーダル内では、複数のモーダルをスタックのように重ねることができます。`views_open` という APIを呼び出すと、親となるとなるモーダルビューが追加されます。この最初の呼び出しの後、`views_update` を呼び出すことでそのビューを更新することができます。また、`views_push` を呼び出すと、親のモーダルの上にさらに新しいモーダルビューを重ねることもできます。 +モーダル内では、複数のモーダルをスタックのように重ねることができます。[`views_open`](/reference/methods/views.open/) という APIを呼び出すと、親となるとなるモーダルビューが追加されます。この最初の呼び出しの後、[`views_update`](/reference/methods/views.update/) を呼び出すことでそのビューを更新することができます。また、[`views_push`](/reference/methods/views.push) を呼び出すと、親のモーダルの上にさらに新しいモーダルビューを重ねることもできます。 **`views_update`** @@ -8,11 +8,11 @@ **`views_push`** -既存のモーダルの上に新しいモーダルをスタックのように追加する場合は、組み込みのクライアントで `views_push` API を呼び出します。この API 呼び出しでは、有効な `trigger_id` と新しいビューのペイロードを指定します。`views_push` の引数は モーダルの開始 と同じです。モーダルを開いた後、このモーダルのスタックに追加できるモーダルビューは 2 つまでです。 +既存のモーダルの上に新しいモーダルをスタックのように追加する場合は、組み込みのクライアントで `views_push` API を呼び出します。この API 呼び出しでは、有効な `trigger_id` と新しい[ビューのペイロード](/reference/interaction-payloads/view-interactions-payload/#view_submission)を指定します。`views_push` の引数は [モーダルの開始](#creating-modals) と同じです。モーダルを開いた後、このモーダルのスタックに追加できるモーダルビューは 2 つまでです。 -モーダルの更新と多重表示に関する詳細は、API ドキュメントを参照してください。 +モーダルの更新と多重表示に関する詳細は、[API ドキュメント](/surfaces/modals)を参照してください。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ```python # モーダルに含まれる、`button_abc` という action_id のボタンの呼び出しをリッスン diff --git a/docs/japanese/concepts/view-submissions.md b/docs/japanese/concepts/view-submissions.md index 5ae78f173..f82922004 100644 --- a/docs/japanese/concepts/view-submissions.md +++ b/docs/japanese/concepts/view-submissions.md @@ -1,6 +1,6 @@ # モーダルの送信のリスニング -モーダルのペイロードに `input` ブロックを含める場合、その入力値を受け取るために`view_submission` リクエストをリッスンする必要があります。`view_submission` リクエストのリッスンには、組み込みの`view()` メソッドを利用することができます。`view()` の引数には、`str` 型または `re.Pattern` 型の `callback_id` を指定します。 +[モーダルのペイロード](/reference/interaction-payloads/view-interactions-payload/#view_submission)に `input` ブロックを含める場合、その入力値を受け取るために`view_submission` リクエストをリッスンする必要があります。`view_submission` リクエストのリッスンには、組み込みの`view()` メソッドを利用することができます。`view()` の引数には、`str` 型または `re.Pattern` 型の `callback_id` を指定します。 `input` ブロックの値にアクセスするには `state` オブジェクトを参照します。`state` 内には `values` というオブジェクトがあり、`block_id` と一意の `action_id` に紐づける形で入力値を保持しています。 @@ -19,9 +19,9 @@ def handle_submission(ack, body): # https://app.slack.com/block-kit-builder/#%7B%22type%22:%22modal%22,%22callback_id%22:%22view_1%22,%22title%22:%7B%22type%22:%22plain_text%22,%22text%22:%22My%20App%22,%22emoji%22:true%7D,%22blocks%22:%5B%5D%7D ack(response_action="update", view=build_new_view(body)) ``` -この例と同様に、モーダルでの送信リクエストに対して、エラーを表示するためのオプションもあります。 +この例と同様に、モーダルでの送信リクエストに対して、[エラーを表示する](/surfaces/modals#displaying_errors)ためのオプションもあります。 -モーダルの送信について詳しくは、API ドキュメントを参照してください。 +モーダルの送信について詳しくは、[API ドキュメント](/surfaces/modals#interactions)を参照してください。 --- @@ -29,7 +29,7 @@ def handle_submission(ack, body): `view_closed` リクエストをリッスンするためには `callback_id` を指定して、かつ `notify_on_close` 属性をモーダルのビューに設定する必要があります。以下のコード例をご覧ください。 -よく詳しい情報は、API ドキュメントを参照してください。 +よく詳しい情報は、[API ドキュメント](/surfaces/modals#interactions)を参照してください。 ```python client.views_open( @@ -56,7 +56,7 @@ def handle_view_closed(ack, body, logger): logger.info(body) ``` -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ```python # view_submission リクエストを処理 diff --git a/docs/japanese/concepts/web-api.md b/docs/japanese/concepts/web-api.md index 7a674b9b2..abb8e4121 100644 --- a/docs/japanese/concepts/web-api.md +++ b/docs/japanese/concepts/web-api.md @@ -4,7 +4,7 @@ Bolt の初期化に使用するトークンは `context` オブジェクトに設定されます。このトークンは、多くの Web API メソッドを呼び出す際に必要となります。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください。 +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 ```python @app.message("wake me up") def say_hello(client, message): diff --git a/docs/japanese/getting-started.md b/docs/japanese/getting-started.md index 30538bf94..41e6ae5cd 100644 --- a/docs/japanese/getting-started.md +++ b/docs/japanese/getting-started.md @@ -48,7 +48,7 @@ Slack アプリで使用できるトークンには、ユーザートークン 6. 左サイドメニューの「**Socket Mode**」を有効にします。 -:::tip[トークンはパスワードと同様に取り扱い、[安全な方法で保管してください](/authentication/best-practices-for-security)。アプリはこのトークンを使って Slack ワークスペースで投稿をしたり、情報の取得をしたりします。] +:::tip[トークンはパスワードと同様に取り扱い、[安全な方法で保管してください](/security)。アプリはこのトークンを使って Slack ワークスペースで投稿をしたり、情報の取得をしたりします。] ::: @@ -91,7 +91,7 @@ export SLACK_APP_TOKEN=<アプリレベルトークン> ``` :::warning[🔒 全てのトークンは安全に保管してください。] -少なくともパブリックなバージョン管理にチェックインするようなことは避けるべきでしょう。また、上にあった例のように環境変数を介してアクセスするようにしてください。詳細な情報は [アプリのセキュリティのベストプラクティス](/authentication/best-practices-for-security)のドキュメントを参照してください。 +少なくともパブリックなバージョン管理にチェックインするようなことは避けるべきでしょう。また、上にあった例のように環境変数を介してアクセスするようにしてください。詳細な情報は [アプリのセキュリティのベストプラクティス](/security)のドキュメントを参照してください。 ::: diff --git a/docs/japanese/legacy/steps-from-apps.md b/docs/japanese/legacy/steps-from-apps.md index a7ef2e04a..802802ab3 100644 --- a/docs/japanese/legacy/steps-from-apps.md +++ b/docs/japanese/legacy/steps-from-apps.md @@ -10,8 +10,6 @@ ワークフローステップを機能させるためには、これら 3 つのイベントすべてに対応する必要があります。 -アプリを使ったワークフローステップに関する詳細は、[API ドキュメント](/legacy/legacy-steps-from-apps/)を参照してください。 - ## ステップの定義 ワークフローステップの作成には、Bolt が提供する `WorkflowStep` クラスを利用します。 @@ -24,7 +22,7 @@ また、デコレーターとして利用できる `WorkflowStepBuilder` クラスを使ってワークフローステップを定義することもできます。 詳細は、[こちらのドキュメント](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/step.html#slack_bolt.workflows.step.step.WorkflowStepBuilder)のコード例などを参考にしてください。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください([共通](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [ステップ用](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) ```python import os @@ -59,15 +57,13 @@ app.step(ws) ## ステップの追加・編集 -作成したワークフローステップがワークフローに追加またはその設定を変更されるタイミングで、[`workflow_step_edit` イベントがアプリに送信されます](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step_edit-payload)。このイベントがアプリに届くと、`WorkflowStep` で設定した `edit` コールバックが実行されます。 +作成したワークフローステップがワークフローに追加またはその設定を変更されるタイミングで、`workflow_step_edit` イベントがアプリに送信されます。このイベントがアプリに届くと、`WorkflowStep` で設定した `edit` コールバックが実行されます。 -ステップの追加と編集のどちらが行われるときも、[ワークフローステップの設定モーダル](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-configuration-view-object)をビルダーに送信する必要があります。このモーダルは、そのステップ独自の設定を選択するための場所です。通常のモーダルより制限が強く、例えば `title`、`submit`、`close` のプロパティを含めることができません。設定モーダルの `callback_id` は、デフォルトではワークフローステップと同じものになります。 +ステップの追加と編集のどちらが行われるときも、ワークフローステップの設定モーダルをビルダーに送信する必要があります。このモーダルは、そのステップ独自の設定を選択するための場所です。通常のモーダルより制限が強く、例えば `title`、`submit`、`close` のプロパティを含めることができません。設定モーダルの `callback_id` は、デフォルトではワークフローステップと同じものになります。 `edit` コールバック内で `configure()` ユーティリティを使用すると、対応する `blocks` 引数にビューのblocks 部分だけを渡して、ステップの設定モーダルを簡単に表示させることができます。必要な入力内容が揃うまで設定の保存を無効にするには、`True` の値をセットした `submit_disabled` を渡します。 -設定モーダルの開き方に関する詳細は、[こちらのドキュメント](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-configuration-view-object)を参照してください。 - -指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください([共通](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [ステップ用](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) ```python def edit(ack, step, configure): @@ -117,9 +113,7 @@ app.step(ws) - `step_name` : ステップのデフォルトの名前をオーバーライドします。 - `step_image_url` : ステップのデフォルトの画像をオーバーライドします。 -これらのパラメータの構成方法に関する詳細は、[こちらのドキュメント](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object)を参照してください。 - -指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください([共通](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [ステップ用](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) ```python def save(ack, view, update): @@ -158,13 +152,14 @@ app.step(ws) ## ステップの実行 -エンドユーザーがワークフローステップを実行すると、アプリに [`workflow_step_execute` イベントが送信されます](/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object)。このイベントがアプリに届くと、`WorkflowStep` で設定した `execute` コールバックが実行されます。 +エンドユーザーがワークフローステップを実行すると、アプリに `workflow_step_execute` イベントが送信されます。このイベントがアプリに届くと、`WorkflowStep` で設定した `execute` コールバックが実行されます。 `save` コールバックで取り出した `inputs` を使って、サードパーティの API を呼び出す、情報をデータベースに保存する、ユーザーのホームタブを更新するといった処理を実行することができます。また、ワークフローの後続のステップで利用する出力値を `outputs` オブジェクトに設定します。 `execute` コールバック内では、`complete()` を呼び出してステップの実行が成功したことを示すか、`fail()` を呼び出してステップの実行が失敗したことを示す必要があります。 -指定可能な引数の一覧はモジュールドキュメントを参考にしてください(共通 / ステップ用 +指定可能な引数の一覧はモジュールドキュメントを参考にしてください([共通](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [ステップ用](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) + ```python def execute(step, complete, fail): inputs = step["inputs"] From 64e09c5e303af06b1c5643b14b459b9794f21756 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 08:41:42 -0800 Subject: [PATCH 823/865] chore(deps): bump actions/checkout from 6.0.0 to 6.0.1 (#1420) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codecov.yml | 2 +- .github/workflows/flake8.yml | 2 +- .github/workflows/mypy.yml | 2 +- .github/workflows/pypi-release.yml | 2 +- .github/workflows/tests.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index f93864817..3340efe8c 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -18,7 +18,7 @@ jobs: env: BOLT_PYTHON_CODECOV_RUNNING: "1" steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index b0602d60a..8305fe645 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -16,7 +16,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 650c8f9bd..353bad38b 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -16,7 +16,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 80fac6d8c..9afa946aa 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -18,7 +18,7 @@ jobs: contents: read steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.release.tag_name || github.ref }} persist-credentials: false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6de00d7da..0f4f0c4b9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,7 +28,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} From 7d95b8ebeffa3e9ebee8890302aba7f0d5e68520 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:48:16 +0000 Subject: [PATCH 824/865] chore(deps): bump actions/stale from 10.1.0 to 10.1.1 (#1419) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: William Bergamin --- .github/workflows/triage-issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index 85ccb72aa..cf13d3afc 100644 --- a/.github/workflows/triage-issues.yml +++ b/.github/workflows/triage-issues.yml @@ -16,7 +16,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 + - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 with: days-before-issue-stale: 30 days-before-issue-close: 10 From a1aa7139c4126ab3fbc23eba5f8f900ae73ecffd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:54:36 +0000 Subject: [PATCH 825/865] chore(deps): bump mypy from 1.19.0 to 1.19.1 (#1418) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: William Bergamin --- requirements/tools.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/tools.txt b/requirements/tools.txt index f9fbcdac1..7609eb52e 100644 --- a/requirements/tools.txt +++ b/requirements/tools.txt @@ -1,3 +1,3 @@ -mypy==1.19.0 +mypy==1.19.1 flake8==7.3.0 black==25.1.0 From 252bf23b05cf82a515aa6dbad28b0cd0d5000f4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:46:03 +0000 Subject: [PATCH 826/865] chore(deps): bump codecov/codecov-action from 5.5.1 to 5.5.2 (#1417) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codecov.yml | 2 +- .github/workflows/tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 3340efe8c..4485c27ae 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -36,7 +36,7 @@ jobs: run: | pytest --cov=./slack_bolt/ --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: fail_ci_if_error: true report_type: coverage diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f4f0c4b9..3ba2a17f2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -77,7 +77,7 @@ jobs: pytest tests/scenario_tests_async/ --junitxml=reports/test_scenario_async.xml - name: Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: directory: ./reports/ fail_ci_if_error: true From 13e4a8e44b50b9a80072f4bb232136c9bc8fd7cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:50:08 +0000 Subject: [PATCH 827/865] chore(deps): bump actions/download-artifact from 6.0.0 to 7.0.0 (#1416) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pypi-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 9afa946aa..7f97da9ed 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -52,7 +52,7 @@ jobs: steps: - name: Retrieve dist folder - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: release-dist path: dist/ @@ -76,7 +76,7 @@ jobs: steps: - name: Retrieve dist folder - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: release-dist path: dist/ From c8511da19b6d7bcd4e733ac1e50bf244e02247ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:54:30 +0000 Subject: [PATCH 828/865] chore(deps): bump actions/upload-artifact from 5.0.0 to 6.0.0 (#1415) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pypi-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 7f97da9ed..89a18c827 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -33,7 +33,7 @@ jobs: scripts/build_pypi_package.sh - name: Persist dist folder - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: release-dist path: dist/ From 69181155b266e88331f9e0d17844a2ccf37265be Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 15 Jan 2026 06:57:13 -0800 Subject: [PATCH 829/865] chore: update the ci pipeline to match other patterns (#1422) --- .github/workflows/{tests.yml => ci-build.yml} | 82 ++++++++++++++++++- .github/workflows/codecov.yml | 44 ---------- .github/workflows/flake8.yml | 28 ------- .github/workflows/mypy.yml | 28 ------- scripts/format.sh | 7 +- scripts/install_all_and_run_tests.sh | 27 +++--- scripts/lint.sh | 12 +++ scripts/run_flake8.sh | 7 -- scripts/run_mypy.sh | 15 ++-- scripts/run_tests.sh | 8 +- 10 files changed, 121 insertions(+), 137 deletions(-) rename .github/workflows/{tests.yml => ci-build.yml} (60%) delete mode 100644 .github/workflows/codecov.yml delete mode 100644 .github/workflows/flake8.yml delete mode 100644 .github/workflows/mypy.yml create mode 100755 scripts/lint.sh delete mode 100755 scripts/run_flake8.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/ci-build.yml similarity index 60% rename from .github/workflows/tests.yml rename to .github/workflows/ci-build.yml index 3ba2a17f2..a11fa10d7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/ci-build.yml @@ -1,4 +1,4 @@ -name: Run all the unit tests +name: Python CI on: push: @@ -9,8 +9,46 @@ on: - cron: "0 0 * * *" workflow_dispatch: +env: + LATEST_SUPPORTED_PY: "3.14" + jobs: - build: + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ env.LATEST_SUPPORTED_PY }} + - name: Run lint verification + run: ./scripts/lint.sh + + typecheck: + name: Typecheck + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ env.LATEST_SUPPORTED_PY }} + - name: Run mypy verification + run: ./scripts/run_mypy.sh + + unittest: + name: Unit tests runs-on: ubuntu-22.04 timeout-minutes: 10 strategy: @@ -85,10 +123,48 @@ jobs: report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} verbose: true + + codecov: + name: Code Coverage + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + env: + BOLT_PYTHON_CODECOV_RUNNING: "1" + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ env.LATEST_SUPPORTED_PY }} + - name: Install dependencies + run: | + pip install -U pip + pip install . + pip install -r requirements/adapter.txt + pip install -r requirements/testing.txt + pip install -r requirements/adapter_testing.txt + - name: Run all tests for codecov + run: | + pytest --cov=./slack_bolt/ --cov-report=xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + with: + fail_ci_if_error: true + report_type: coverage + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + notifications: name: Regression notifications runs-on: ubuntu-latest - needs: build + needs: + - lint + - typecheck + - unittest if: ${{ !success() && github.ref == 'refs/heads/main' && github.event_name != 'workflow_dispatch' }} steps: - name: Send notifications of failing tests diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml deleted file mode 100644 index 4485c27ae..000000000 --- a/.github/workflows/codecov.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Run codecov - -on: - push: - branches: - - main - pull_request: - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 10 - strategy: - matrix: - python-version: ["3.14"] - permissions: - contents: read - env: - BOLT_PYTHON_CODECOV_RUNNING: "1" - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - persist-credentials: false - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - pip install -U pip - pip install . - pip install -r requirements/adapter.txt - pip install -r requirements/testing.txt - pip install -r requirements/adapter_testing.txt - - name: Run all tests for codecov - run: | - pytest --cov=./slack_bolt/ --cov-report=xml - - name: Upload coverage to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 - with: - fail_ci_if_error: true - report_type: coverage - token: ${{ secrets.CODECOV_TOKEN }} - verbose: true diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml deleted file mode 100644 index 8305fe645..000000000 --- a/.github/workflows/flake8.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Run flake8 validation - -on: - push: - branches: - - main - pull_request: - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - python-version: ["3.14"] - permissions: - contents: read - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - persist-credentials: false - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 - with: - python-version: ${{ matrix.python-version }} - - name: Run flake8 verification - run: | - ./scripts/run_flake8.sh diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml deleted file mode 100644 index 353bad38b..000000000 --- a/.github/workflows/mypy.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Run mypy validation - -on: - push: - branches: - - main - pull_request: - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - python-version: ["3.14"] - permissions: - contents: read - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - persist-credentials: false - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 - with: - python-version: ${{ matrix.python-version }} - - name: Run mypy verification - run: | - ./scripts/run_mypy.sh diff --git a/scripts/format.sh b/scripts/format.sh index 77cecf9e4..e73bcdac4 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -4,7 +4,10 @@ script_dir=`dirname $0` cd ${script_dir}/.. -pip install -U pip -pip install -U -r requirements/tools.txt +if [[ "$1" != "--no-install" ]]; then + export PIP_REQUIRE_VIRTUALENV=1 + pip install -U pip + pip install -U -r requirements/tools.txt +fi black slack_bolt/ tests/ diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index 1f2690414..2bb9a2050 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -16,25 +16,20 @@ pip uninstall python-lambda test_target="$1" pip install -U -e . +pip install -U -r requirements/testing.txt +pip install -U -r requirements/adapter.txt +pip install -U -r requirements/adapter_testing.txt +pip install -U -r requirements/tools.txt +# To avoid errors due to the old versions of click forced by Chalice +pip install -U pip click if [[ $test_target != "" ]] then - pip install -U -r requirements/testing.txt && \ - pip install -U -r requirements/adapter.txt && \ - pip install -U -r requirements/adapter_testing.txt && \ - # To avoid errors due to the old versions of click forced by Chalice - pip install -U pip click && \ - black slack_bolt/ tests/ && \ + ./scripts/format.sh --no-install pytest $1 else - pip install -U -r requirements/testing.txt && \ - pip install -U -r requirements/adapter.txt && \ - pip install -U -r requirements/adapter_testing.txt && \ - pip install -r requirements/tools.txt && \ - # To avoid errors due to the old versions of click forced by Chalice - pip install -U pip click && \ - black slack_bolt/ tests/ && \ - flake8 slack_bolt/ && flake8 examples/ - pytest && \ - mypy --config-file pyproject.toml + ./scripts/format.sh --no-install + ./scripts/lint.sh --no-install + pytest + ./scripts/run_mypy.sh --no-install fi diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 000000000..efee01ebc --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# ./scripts/lint.sh + +script_dir=$(dirname $0) +cd ${script_dir}/.. + +if [[ "$1" != "--no-install" ]]; then + pip install -U pip + pip install -U -r requirements/tools.txt +fi + +flake8 slack_bolt/ && flake8 examples/ diff --git a/scripts/run_flake8.sh b/scripts/run_flake8.sh deleted file mode 100755 index e523920f9..000000000 --- a/scripts/run_flake8.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -# ./scripts/run_flake8.sh - -script_dir=$(dirname $0) -cd ${script_dir}/.. && \ - pip install -U -r requirements/tools.txt && \ - flake8 slack_bolt/ && flake8 examples/ diff --git a/scripts/run_mypy.sh b/scripts/run_mypy.sh index c018443b7..27589b348 100755 --- a/scripts/run_mypy.sh +++ b/scripts/run_mypy.sh @@ -2,9 +2,14 @@ # ./scripts/run_mypy.sh script_dir=$(dirname $0) -cd ${script_dir}/.. && \ +cd ${script_dir}/.. + +if [[ "$1" != "--no-install" ]]; then + pip install -U pip pip install -U . - pip install -U -r requirements/async.txt && \ - pip install -U -r requirements/adapter.txt && \ - pip install -U -r requirements/tools.txt && \ - mypy --config-file pyproject.toml + pip install -U -r requirements/async.txt + pip install -U -r requirements/adapter.txt + pip install -U -r requirements/tools.txt +fi + +mypy --config-file pyproject.toml diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index e4cc99709..cdac3c71c 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -7,12 +7,12 @@ script_dir=`dirname $0` cd ${script_dir}/.. test_target="$1" -python_version=`python --version | awk '{print $2}'` + +./scripts/format.sh --no-install if [[ $test_target != "" ]] then - black slack_bolt/ tests/ && \ - pytest -vv $1 + pytest -vv $1 else - black slack_bolt/ tests/ && pytest + pytest fi From c3929dfd41adb47f303cb7e27120fdb8a2690e45 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 3 Feb 2026 18:42:59 -0800 Subject: [PATCH 830/865] ci(deps): auto-approve / auto-merge dependencies from dependabot (#1434) --- .github/workflows/dependencies.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/dependencies.yml diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml new file mode 100644 index 000000000..824d57701 --- /dev/null +++ b/.github/workflows/dependencies.yml @@ -0,0 +1,29 @@ +name: Merge updates to dependencies +on: + pull_request: +jobs: + dependabot: + name: "@dependabot" + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Collect metadata + id: metadata + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + - name: Approve + if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' + run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Automerge + if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From fbbaaa08f7d7c024302c1b916db3fc3742eaa35f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:53:59 +0000 Subject: [PATCH 831/865] chore(deps): bump actions/checkout from 6.0.1 to 6.0.2 (#1424) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Michael Brooks --- .github/workflows/ci-build.yml | 8 ++++---- .github/workflows/pypi-release.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index a11fa10d7..7c360b98d 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -20,7 +20,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} @@ -37,7 +37,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} @@ -66,7 +66,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} @@ -133,7 +133,7 @@ jobs: env: BOLT_PYTHON_CODECOV_RUNNING: "1" steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 89a18c827..a072d20b0 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -18,7 +18,7 @@ jobs: contents: read steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.release.tag_name || github.ref }} persist-credentials: false From e5cf0f780760c443a7a5802a2d2c19fb4072c9aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:02:23 +0000 Subject: [PATCH 832/865] chore(deps): bump actions/setup-python from 6.1.0 to 6.2.0 (#1425) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-build.yml | 8 ++++---- .github/workflows/pypi-release.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 7c360b98d..6555a6531 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -24,7 +24,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.LATEST_SUPPORTED_PY }} - name: Run lint verification @@ -41,7 +41,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.LATEST_SUPPORTED_PY }} - name: Run mypy verification @@ -70,7 +70,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - name: Install synchronous dependencies @@ -137,7 +137,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.LATEST_SUPPORTED_PY }} - name: Install dependencies diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index a072d20b0..9c9003c92 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Set up Python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.x" From 868cedbce8164a5de5fdbe7db930f28ec6ac85c8 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 10 Feb 2026 16:43:35 -0800 Subject: [PATCH 833/865] fix: pin setuptools to maintain support for pyramid adapter (#1436) --- .github/dependabot.yml | 4 ++++ requirements/adapter.txt | 1 + 2 files changed, 5 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dc523d227..774d13833 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,10 @@ updates: schedule: interval: "monthly" open-pull-requests-limit: 5 + ignore: + # setuptools is pinned due to pyramid's dependency on deprecated pkg_resources + # See: https://github.com/Pylons/pyramid/issues/3731 + - dependency-name: "setuptools" - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/requirements/adapter.txt b/requirements/adapter.txt index b2097bcdb..c19c7713b 100644 --- a/requirements/adapter.txt +++ b/requirements/adapter.txt @@ -13,6 +13,7 @@ fastapi>=0.70.0,<1 Flask>=1,<4 Werkzeug>=2,<4 pyramid>=1,<3 +setuptools<82 # Pinned: Pyramid depends on pkg_resources (deprecated in setuptools 67.5.0, removed in 82+). See: https://github.com/Pylons/pyramid/issues/3731 # Sanic and its dependencies # Note: Sanic imports tracerite with wild card versions From 1ad642efded7629ac9b87988a84906783e765560 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Sun, 15 Feb 2026 22:23:17 -0800 Subject: [PATCH 834/865] Add 'agent: BoltAgent' listener argument (#1437) Co-authored-by: Luke Russell Co-authored-by: William Bergamin --- .gitignore | 3 + docs/english/_sidebar.json | 5 + docs/english/experiments.md | 34 ++++ slack_bolt/__init__.py | 2 + slack_bolt/adapter/__init__.py | 3 +- slack_bolt/agent/__init__.py | 5 + slack_bolt/agent/agent.py | 73 ++++++++ slack_bolt/agent/async_agent.py | 70 ++++++++ slack_bolt/kwargs_injection/args.py | 5 + slack_bolt/kwargs_injection/async_args.py | 5 + slack_bolt/kwargs_injection/async_utils.py | 23 ++- slack_bolt/kwargs_injection/utils.py | 23 ++- slack_bolt/warning/__init__.py | 6 + tests/scenario_tests/test_events_agent.py | 162 +++++++++++++++++ .../scenario_tests_async/test_events_agent.py | 169 ++++++++++++++++++ tests/slack_bolt/agent/__init__.py | 0 tests/slack_bolt/agent/test_agent.py | 103 +++++++++++ tests/slack_bolt_async/agent/__init__.py | 0 .../agent/test_async_agent.py | 114 ++++++++++++ 19 files changed, 799 insertions(+), 6 deletions(-) create mode 100644 docs/english/experiments.md create mode 100644 slack_bolt/agent/__init__.py create mode 100644 slack_bolt/agent/agent.py create mode 100644 slack_bolt/agent/async_agent.py create mode 100644 slack_bolt/warning/__init__.py create mode 100644 tests/scenario_tests/test_events_agent.py create mode 100644 tests/scenario_tests_async/test_events_agent.py create mode 100644 tests/slack_bolt/agent/__init__.py create mode 100644 tests/slack_bolt/agent/test_agent.py create mode 100644 tests/slack_bolt_async/agent/__init__.py create mode 100644 tests/slack_bolt_async/agent/test_async_agent.py diff --git a/.gitignore b/.gitignore index 2549060e7..b28dfa9ed 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ venv/ .venv* .env/ +# claude +.claude/*.local.json + # codecov / coverage .coverage cov_* diff --git a/docs/english/_sidebar.json b/docs/english/_sidebar.json index 859c4b52f..eab9d94f8 100644 --- a/docs/english/_sidebar.json +++ b/docs/english/_sidebar.json @@ -85,6 +85,11 @@ "tools/bolt-python/concepts/token-rotation" ] }, + { + "type": "category", + "label": "Experiments", + "items": ["tools/bolt-python/experiments"] + }, { "type": "category", "label": "Legacy", diff --git a/docs/english/experiments.md b/docs/english/experiments.md new file mode 100644 index 000000000..681c8cbc6 --- /dev/null +++ b/docs/english/experiments.md @@ -0,0 +1,34 @@ +# Experiments + +Bolt for Python includes experimental features still under active development. These features may be fleeting, may not be perfectly polished, and should be thought of as available for use "at your own risk." + +Experimental features are categorized as `semver:patch` until the experimental status is removed. + +We love feedback from our community, so we encourage you to explore and interact with the [GitHub repo](https://github.com/slackapi/bolt-python). Contributions, bug reports, and any feedback are all helpful; let us nurture the Slack CLI together to help make building Slack apps more pleasant for everyone. + +## Available experiments +* [Agent listener argument](#agent) + +## Agent listener argument {#agent} + +The `agent: BoltAgent` listener argument provides access to AI agent-related features. + +The `BoltAgent` and `AsyncBoltAgent` classes offer a `chat_stream()` method that comes pre-configured with event context defaults: `channel_id`, `thread_ts`, `team_id`, and `user_id` fields. + +The listener argument is wired into the Bolt `kwargs` injection system, so listeners can declare it as a parameter or access it via the `context.agent` property. + +### Example + +```python +from slack_bolt import BoltAgent + +@app.event("app_mention") +def handle_mention(agent: BoltAgent): + stream = agent.chat_stream() + stream.append(markdown_text="Hello!") + stream.stop() +``` + +### Limitations + +The `chat_stream()` method currently only works when the `thread_ts` field is available in the event context (DMs and threaded replies). Top-level channel messages do not have a `thread_ts` field, and the `ts` field is not yet provided to `BoltAgent`. \ No newline at end of file diff --git a/slack_bolt/__init__.py b/slack_bolt/__init__.py index 6331925f8..4e43252fd 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -21,6 +21,7 @@ from .response import BoltResponse # AI Agents & Assistants +from .agent import BoltAgent from .middleware.assistant.assistant import ( Assistant, ) @@ -46,6 +47,7 @@ "CustomListenerMatcher", "BoltRequest", "BoltResponse", + "BoltAgent", "Assistant", "AssistantThreadContext", "AssistantThreadContextStore", diff --git a/slack_bolt/adapter/__init__.py b/slack_bolt/adapter/__init__.py index f339226bc..9ca556e52 100644 --- a/slack_bolt/adapter/__init__.py +++ b/slack_bolt/adapter/__init__.py @@ -1,2 +1 @@ -"""Adapter modules for running Bolt apps along with Web frameworks or Socket Mode. -""" +"""Adapter modules for running Bolt apps along with Web frameworks or Socket Mode.""" diff --git a/slack_bolt/agent/__init__.py b/slack_bolt/agent/__init__.py new file mode 100644 index 000000000..4d751f27f --- /dev/null +++ b/slack_bolt/agent/__init__.py @@ -0,0 +1,5 @@ +from .agent import BoltAgent + +__all__ = [ + "BoltAgent", +] diff --git a/slack_bolt/agent/agent.py b/slack_bolt/agent/agent.py new file mode 100644 index 000000000..db1c78aa9 --- /dev/null +++ b/slack_bolt/agent/agent.py @@ -0,0 +1,73 @@ +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.web.chat_stream import ChatStream + + +class BoltAgent: + """Agent listener argument for building AI-powered Slack agents. + + Experimental: + This API is experimental and may change in future releases. + + FIXME: chat_stream() only works when thread_ts is available (DMs and threaded replies). + It does not work on channel messages because ts is not provided to BoltAgent yet. + + @app.event("app_mention") + def handle_mention(agent): + stream = agent.chat_stream() + stream.append(markdown_text="Hello!") + stream.stop() + """ + + def __init__( + self, + *, + client: WebClient, + channel_id: Optional[str] = None, + thread_ts: Optional[str] = None, + team_id: Optional[str] = None, + user_id: Optional[str] = None, + ): + self._client = client + self._channel_id = channel_id + self._thread_ts = thread_ts + self._team_id = team_id + self._user_id = user_id + + def chat_stream( + self, + *, + channel: Optional[str] = None, + thread_ts: Optional[str] = None, + recipient_team_id: Optional[str] = None, + recipient_user_id: Optional[str] = None, + **kwargs, + ) -> ChatStream: + """Creates a ChatStream with defaults from event context. + + Each call creates a new instance. Create multiple for parallel streams. + + Args: + channel: Channel ID. Defaults to the channel from the event context. + thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. + recipient_team_id: Team ID of the recipient. Defaults to the team from the event context. + recipient_user_id: User ID of the recipient. Defaults to the user from the event context. + **kwargs: Additional arguments passed to ``WebClient.chat_stream()``. + + Returns: + A new ``ChatStream`` instance. + """ + provided = [arg for arg in (channel, thread_ts, recipient_team_id, recipient_user_id) if arg is not None] + if provided and len(provided) < 4: + raise ValueError( + "Either provide all of channel, thread_ts, recipient_team_id, and recipient_user_id, or none of them" + ) + # Argument validation is delegated to chat_stream() and the API + return self._client.chat_stream( + channel=channel or self._channel_id, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + recipient_team_id=recipient_team_id or self._team_id, + recipient_user_id=recipient_user_id or self._user_id, + **kwargs, + ) diff --git a/slack_bolt/agent/async_agent.py b/slack_bolt/agent/async_agent.py new file mode 100644 index 000000000..2ee15aa2e --- /dev/null +++ b/slack_bolt/agent/async_agent.py @@ -0,0 +1,70 @@ +from typing import Optional + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_chat_stream import AsyncChatStream + + +class AsyncBoltAgent: + """Async agent listener argument for building AI-powered Slack agents. + + Experimental: + This API is experimental and may change in future releases. + + @app.event("app_mention") + async def handle_mention(agent): + stream = await agent.chat_stream() + await stream.append(markdown_text="Hello!") + await stream.stop() + """ + + def __init__( + self, + *, + client: AsyncWebClient, + channel_id: Optional[str] = None, + thread_ts: Optional[str] = None, + team_id: Optional[str] = None, + user_id: Optional[str] = None, + ): + self._client = client + self._channel_id = channel_id + self._thread_ts = thread_ts + self._team_id = team_id + self._user_id = user_id + + async def chat_stream( + self, + *, + channel: Optional[str] = None, + thread_ts: Optional[str] = None, + recipient_team_id: Optional[str] = None, + recipient_user_id: Optional[str] = None, + **kwargs, + ) -> AsyncChatStream: + """Creates an AsyncChatStream with defaults from event context. + + Each call creates a new instance. Create multiple for parallel streams. + + Args: + channel: Channel ID. Defaults to the channel from the event context. + thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. + recipient_team_id: Team ID of the recipient. Defaults to the team from the event context. + recipient_user_id: User ID of the recipient. Defaults to the user from the event context. + **kwargs: Additional arguments passed to ``AsyncWebClient.chat_stream()``. + + Returns: + A new ``AsyncChatStream`` instance. + """ + provided = [arg for arg in (channel, thread_ts, recipient_team_id, recipient_user_id) if arg is not None] + if provided and len(provided) < 4: + raise ValueError( + "Either provide all of channel, thread_ts, recipient_team_id, and recipient_user_id, or none of them" + ) + # Argument validation is delegated to chat_stream() and the API + return await self._client.chat_stream( + channel=channel or self._channel_id, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + recipient_team_id=recipient_team_id or self._team_id, + recipient_user_id=recipient_user_id or self._user_id, + **kwargs, + ) diff --git a/slack_bolt/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index 1a0ec3ca8..113e39c08 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -8,6 +8,7 @@ from slack_bolt.context.fail import Fail from slack_bolt.context.get_thread_context.get_thread_context import GetThreadContext from slack_bolt.context.respond import Respond +from slack_bolt.agent.agent import BoltAgent from slack_bolt.context.save_thread_context import SaveThreadContext from slack_bolt.context.say import Say from slack_bolt.context.set_status import SetStatus @@ -102,6 +103,8 @@ def handle_buttons(args): """`get_thread_context()` utility function for AI Agents & Assistants""" save_thread_context: Optional[SaveThreadContext] """`save_thread_context()` utility function for AI Agents & Assistants""" + agent: Optional[BoltAgent] + """`agent` listener argument for AI Agents & Assistants""" # middleware next: Callable[[], None] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -135,6 +138,7 @@ def __init__( set_suggested_prompts: Optional[SetSuggestedPrompts] = None, get_thread_context: Optional[GetThreadContext] = None, save_thread_context: Optional[SaveThreadContext] = None, + agent: Optional[BoltAgent] = None, # As this method is not supposed to be invoked by bolt-python users, # the naming conflict with the built-in one affects # only the internals of this method @@ -168,6 +172,7 @@ def __init__( self.set_suggested_prompts = set_suggested_prompts self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context + self.agent = agent self.next: Callable[[], None] = next self.next_: Callable[[], None] = next diff --git a/slack_bolt/kwargs_injection/async_args.py b/slack_bolt/kwargs_injection/async_args.py index 4953f2167..1f1dde024 100644 --- a/slack_bolt/kwargs_injection/async_args.py +++ b/slack_bolt/kwargs_injection/async_args.py @@ -1,6 +1,7 @@ from logging import Logger from typing import Callable, Awaitable, Dict, Any, Optional +from slack_bolt.agent.async_agent import AsyncBoltAgent from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.context.complete.async_complete import AsyncComplete @@ -101,6 +102,8 @@ async def handle_buttons(args): """`get_thread_context()` utility function for AI Agents & Assistants""" save_thread_context: Optional[AsyncSaveThreadContext] """`save_thread_context()` utility function for AI Agents & Assistants""" + agent: Optional[AsyncBoltAgent] + """`agent` listener argument for AI Agents & Assistants""" # middleware next: Callable[[], Awaitable[None]] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -134,6 +137,7 @@ def __init__( set_suggested_prompts: Optional[AsyncSetSuggestedPrompts] = None, get_thread_context: Optional[AsyncGetThreadContext] = None, save_thread_context: Optional[AsyncSaveThreadContext] = None, + agent: Optional[AsyncBoltAgent] = None, next: Callable[[], Awaitable[None]], **kwargs, # noqa ): @@ -164,6 +168,7 @@ def __init__( self.set_suggested_prompts = set_suggested_prompts self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context + self.agent = agent self.next: Callable[[], Awaitable[None]] = next self.next_: Callable[[], Awaitable[None]] = next diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index c8870c3cc..e43cd0c27 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -1,9 +1,11 @@ import inspect import logging +import warnings from typing import Callable, Dict, MutableSequence, Optional, Any from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse +from slack_bolt.warning import ExperimentalWarning from .async_args import AsyncArgs from slack_bolt.request.payload_utils import ( to_options, @@ -29,7 +31,7 @@ def build_async_required_kwargs( error: Optional[Exception] = None, # for error handlers next_keys_required: bool = True, # False for listeners / middleware / error handlers ) -> Dict[str, Any]: - all_available_args = { + all_available_args: Dict[str, Any] = { "logger": logger, "client": request.context.client, "req": request, @@ -83,6 +85,23 @@ def build_async_required_kwargs( if k not in all_available_args: all_available_args[k] = v + # Defer agent creation to avoid constructing AsyncBoltAgent on every request + if "agent" in required_arg_names: + from slack_bolt.agent.async_agent import AsyncBoltAgent + + all_available_args["agent"] = AsyncBoltAgent( + client=request.context.client, + channel_id=request.context.channel_id, + thread_ts=request.context.thread_ts, + team_id=request.context.team_id, + user_id=request.context.user_id, + ) + warnings.warn( + "The agent listener argument is experimental and may change in future versions.", + category=ExperimentalWarning, + stacklevel=2, # Point to the caller, not this internal helper + ) + if len(required_arg_names) > 0: # To support instance/class methods in a class for listeners/middleware, # check if the first argument is either self or cls @@ -102,7 +121,7 @@ def build_async_required_kwargs( for name in required_arg_names: if name == "args": if isinstance(request, AsyncBoltRequest): - kwargs[name] = AsyncArgs(**all_available_args) # type: ignore[arg-type] + kwargs[name] = AsyncArgs(**all_available_args) else: logger.warning(f"Unknown Request object type detected ({type(request)})") diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index c1909c67a..73fe99bba 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -1,9 +1,11 @@ import inspect import logging +import warnings from typing import Callable, Dict, MutableSequence, Optional, Any from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse +from slack_bolt.warning import ExperimentalWarning from .args import Args from slack_bolt.request.payload_utils import ( to_options, @@ -29,7 +31,7 @@ def build_required_kwargs( error: Optional[Exception] = None, # for error handlers next_keys_required: bool = True, # False for listeners / middleware / error handlers ) -> Dict[str, Any]: - all_available_args = { + all_available_args: Dict[str, Any] = { "logger": logger, "client": request.context.client, "req": request, @@ -82,6 +84,23 @@ def build_required_kwargs( if k not in all_available_args: all_available_args[k] = v + # Defer agent creation to avoid constructing BoltAgent on every request + if "agent" in required_arg_names: + from slack_bolt.agent.agent import BoltAgent + + all_available_args["agent"] = BoltAgent( + client=request.context.client, + channel_id=request.context.channel_id, + thread_ts=request.context.thread_ts, + team_id=request.context.team_id, + user_id=request.context.user_id, + ) + warnings.warn( + "The agent listener argument is experimental and may change in future versions.", + category=ExperimentalWarning, + stacklevel=2, # Point to the caller, not this internal helper + ) + if len(required_arg_names) > 0: # To support instance/class methods in a class for listeners/middleware, # check if the first argument is either self or cls @@ -101,7 +120,7 @@ def build_required_kwargs( for name in required_arg_names: if name == "args": if isinstance(request, BoltRequest): - kwargs[name] = Args(**all_available_args) # type: ignore[arg-type] + kwargs[name] = Args(**all_available_args) else: logger.warning(f"Unknown Request object type detected ({type(request)})") diff --git a/slack_bolt/warning/__init__.py b/slack_bolt/warning/__init__.py new file mode 100644 index 000000000..df71b812f --- /dev/null +++ b/slack_bolt/warning/__init__.py @@ -0,0 +1,6 @@ +"""Bolt specific warning types.""" + + +class ExperimentalWarning(FutureWarning): + """Warning for features that are still in experimental phase.""" + pass diff --git a/tests/scenario_tests/test_events_agent.py b/tests/scenario_tests/test_events_agent.py new file mode 100644 index 000000000..667739728 --- /dev/null +++ b/tests/scenario_tests/test_events_agent.py @@ -0,0 +1,162 @@ +import json +from time import sleep + +import pytest +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest, BoltContext, BoltAgent +from slack_bolt.agent.agent import BoltAgent as BoltAgentDirect +from slack_bolt.warning import ExperimentalWarning +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 TestEventsAgent: + 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_agent_injected_for_app_mention(self): + app = App(client=self.web_client) + + state = {"called": False} + + def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.event("app_mention") + def handle_mention(agent: BoltAgent, context: BoltContext): + assert agent is not None + assert isinstance(agent, BoltAgentDirect) + assert context.channel_id == "C111" + state["called"] = True + + request = BoltRequest(body=app_mention_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called() + + def test_agent_available_in_action_listener(self): + app = App(client=self.web_client) + + state = {"called": False} + + def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.action("test_action") + def handle_action(ack, agent: BoltAgent): + ack() + assert agent is not None + assert isinstance(agent, BoltAgentDirect) + state["called"] = True + + request = BoltRequest(body=json.dumps(action_event_body), mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called() + + def test_agent_kwarg_emits_experimental_warning(self): + app = App(client=self.web_client) + + state = {"called": False} + + def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.event("app_mention") + def handle_mention(agent: BoltAgent): + state["called"] = True + + request = BoltRequest(body=app_mention_event_body, mode="socket_mode") + with pytest.warns(ExperimentalWarning, match="agent listener argument is experimental"): + response = app.dispatch(request) + assert response.status == 200 + assert_target_called() + + +# ---- Test event bodies ---- + + +def build_payload(event: dict) -> dict: + return { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": event, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + +app_mention_event_body = build_payload( + { + "type": "app_mention", + "user": "W222", + "text": "<@W111> hello", + "ts": "1234567890.123456", + "channel": "C111", + "event_ts": "1234567890.123456", + } +) + +action_event_body = { + "type": "block_actions", + "user": {"id": "W222", "username": "test_user", "name": "test_user", "team_id": "T111"}, + "api_app_id": "A111", + "token": "verification_token", + "container": {"type": "message", "message_ts": "1234567890.123456", "channel_id": "C111", "is_ephemeral": False}, + "channel": {"id": "C111", "name": "test-channel"}, + "team": {"id": "T111", "domain": "test"}, + "enterprise": {"id": "E111", "name": "test"}, + "trigger_id": "111.222.xxx", + "actions": [ + { + "type": "button", + "block_id": "b", + "action_id": "test_action", + "text": {"type": "plain_text", "text": "Button"}, + "action_ts": "1234567890.123456", + } + ], +} diff --git a/tests/scenario_tests_async/test_events_agent.py b/tests/scenario_tests_async/test_events_agent.py new file mode 100644 index 000000000..1702cdb61 --- /dev/null +++ b/tests/scenario_tests_async/test_events_agent.py @@ -0,0 +1,169 @@ +import asyncio +import json + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.agent.async_agent import AsyncBoltAgent +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.warning import ExperimentalWarning +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEventsAgent: + 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(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_agent_injected_for_app_mention(self): + app = AsyncApp(client=self.web_client) + + state = {"called": False} + + async def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + await asyncio.sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.event("app_mention") + async def handle_mention(agent: AsyncBoltAgent, context: AsyncBoltContext): + assert agent is not None + assert isinstance(agent, AsyncBoltAgent) + assert context.channel_id == "C111" + state["called"] = True + + request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + + @pytest.mark.asyncio + async def test_agent_available_in_action_listener(self): + app = AsyncApp(client=self.web_client) + + state = {"called": False} + + async def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + await asyncio.sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.action("test_action") + async def handle_action(ack, agent: AsyncBoltAgent): + await ack() + assert agent is not None + assert isinstance(agent, AsyncBoltAgent) + state["called"] = True + + request = AsyncBoltRequest(body=json.dumps(action_event_body), mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + + @pytest.mark.asyncio + async def test_agent_kwarg_emits_experimental_warning(self): + app = AsyncApp(client=self.web_client) + + state = {"called": False} + + async def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + await asyncio.sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.event("app_mention") + async def handle_mention(agent: AsyncBoltAgent): + state["called"] = True + + request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") + with pytest.warns(ExperimentalWarning, match="agent listener argument is experimental"): + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + + +# ---- Test event bodies ---- + + +def build_payload(event: dict) -> dict: + return { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": event, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + +app_mention_event_body = build_payload( + { + "type": "app_mention", + "user": "W222", + "text": "<@W111> hello", + "ts": "1234567890.123456", + "channel": "C111", + "event_ts": "1234567890.123456", + } +) + +action_event_body = { + "type": "block_actions", + "user": {"id": "W222", "username": "test_user", "name": "test_user", "team_id": "T111"}, + "api_app_id": "A111", + "token": "verification_token", + "container": {"type": "message", "message_ts": "1234567890.123456", "channel_id": "C111", "is_ephemeral": False}, + "channel": {"id": "C111", "name": "test-channel"}, + "team": {"id": "T111", "domain": "test"}, + "enterprise": {"id": "E111", "name": "test"}, + "trigger_id": "111.222.xxx", + "actions": [ + { + "type": "button", + "block_id": "b", + "action_id": "test_action", + "text": {"type": "plain_text", "text": "Button"}, + "action_ts": "1234567890.123456", + } + ], +} diff --git a/tests/slack_bolt/agent/__init__.py b/tests/slack_bolt/agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/agent/test_agent.py b/tests/slack_bolt/agent/test_agent.py new file mode 100644 index 000000000..00e998379 --- /dev/null +++ b/tests/slack_bolt/agent/test_agent.py @@ -0,0 +1,103 @@ +from unittest.mock import MagicMock + +import pytest +from slack_sdk.web import WebClient +from slack_sdk.web.chat_stream import ChatStream + +from slack_bolt.agent.agent import BoltAgent + + +class TestBoltAgent: + def test_chat_stream_uses_context_defaults(self): + """BoltAgent.chat_stream() passes context defaults to WebClient.chat_stream().""" + client = MagicMock(spec=WebClient) + client.chat_stream.return_value = MagicMock(spec=ChatStream) + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + stream = agent.chat_stream() + + client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + + def test_chat_stream_overrides_context_defaults(self): + """Explicit kwargs to chat_stream() override context defaults.""" + client = MagicMock(spec=WebClient) + client.chat_stream.return_value = MagicMock(spec=ChatStream) + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + stream = agent.chat_stream( + channel="C999", + thread_ts="9999999999.999999", + recipient_team_id="T999", + recipient_user_id="U999", + ) + + client.chat_stream.assert_called_once_with( + channel="C999", + thread_ts="9999999999.999999", + recipient_team_id="T999", + recipient_user_id="U999", + ) + assert stream is not None + + def test_chat_stream_rejects_partial_overrides(self): + """Passing only some of the four context args raises ValueError.""" + client = MagicMock(spec=WebClient) + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + with pytest.raises(ValueError, match="Either provide all of"): + agent.chat_stream(channel="C999") + + def test_chat_stream_passes_extra_kwargs(self): + """Extra kwargs are forwarded to WebClient.chat_stream().""" + client = MagicMock(spec=WebClient) + client.chat_stream.return_value = MagicMock(spec=ChatStream) + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + agent.chat_stream(buffer_size=512) + + client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + buffer_size=512, + ) + + def test_import_from_slack_bolt(self): + from slack_bolt import BoltAgent as ImportedBoltAgent + + assert ImportedBoltAgent is BoltAgent + + def test_import_from_agent_module(self): + from slack_bolt.agent import BoltAgent as ImportedBoltAgent + + assert ImportedBoltAgent is BoltAgent diff --git a/tests/slack_bolt_async/agent/__init__.py b/tests/slack_bolt_async/agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/agent/test_async_agent.py b/tests/slack_bolt_async/agent/test_async_agent.py new file mode 100644 index 000000000..02251fa4b --- /dev/null +++ b/tests/slack_bolt_async/agent/test_async_agent.py @@ -0,0 +1,114 @@ +from unittest.mock import MagicMock + +import pytest +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_chat_stream import AsyncChatStream + +from slack_bolt.agent.async_agent import AsyncBoltAgent + + +def _make_async_chat_stream_mock(): + mock_stream = MagicMock(spec=AsyncChatStream) + call_tracker = MagicMock() + + async def fake_chat_stream(**kwargs): + call_tracker(**kwargs) + return mock_stream + + return fake_chat_stream, call_tracker, mock_stream + + +class TestAsyncBoltAgent: + @pytest.mark.asyncio + async def test_chat_stream_uses_context_defaults(self): + """AsyncBoltAgent.chat_stream() passes context defaults to AsyncWebClient.chat_stream().""" + client = MagicMock(spec=AsyncWebClient) + client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + stream = await agent.chat_stream() + + call_tracker.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + + @pytest.mark.asyncio + async def test_chat_stream_overrides_context_defaults(self): + """Explicit kwargs to chat_stream() override context defaults.""" + client = MagicMock(spec=AsyncWebClient) + client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + stream = await agent.chat_stream( + channel="C999", + thread_ts="9999999999.999999", + recipient_team_id="T999", + recipient_user_id="U999", + ) + + call_tracker.assert_called_once_with( + channel="C999", + thread_ts="9999999999.999999", + recipient_team_id="T999", + recipient_user_id="U999", + ) + assert stream is not None + + @pytest.mark.asyncio + async def test_chat_stream_rejects_partial_overrides(self): + """Passing only some of the four context args raises ValueError.""" + client = MagicMock(spec=AsyncWebClient) + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + with pytest.raises(ValueError, match="Either provide all of"): + await agent.chat_stream(channel="C999") + + @pytest.mark.asyncio + async def test_chat_stream_passes_extra_kwargs(self): + """Extra kwargs are forwarded to AsyncWebClient.chat_stream().""" + client = MagicMock(spec=AsyncWebClient) + client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + await agent.chat_stream(buffer_size=512) + + call_tracker.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + buffer_size=512, + ) + + @pytest.mark.asyncio + async def test_import_from_agent_module(self): + from slack_bolt.agent.async_agent import AsyncBoltAgent as ImportedAsyncBoltAgent + + assert ImportedAsyncBoltAgent is AsyncBoltAgent From cd52d19b034fbf19ce5949194b336aefe1e48ccb Mon Sep 17 00:00:00 2001 From: Ale Mercado <104795114+srtaalej@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:36:17 -0500 Subject: [PATCH 835/865] feat: add agent set status to BoltAgent (#1441) Co-authored-by: Eden Zimbelman --- slack_bolt/agent/agent.py | 32 ++++- slack_bolt/agent/async_agent.py | 33 ++++- tests/slack_bolt/agent/test_agent.py | 105 +++++++++++++++ .../agent/test_async_agent.py | 121 ++++++++++++++++++ 4 files changed, 288 insertions(+), 3 deletions(-) diff --git a/slack_bolt/agent/agent.py b/slack_bolt/agent/agent.py index db1c78aa9..3663b245b 100644 --- a/slack_bolt/agent/agent.py +++ b/slack_bolt/agent/agent.py @@ -1,6 +1,7 @@ -from typing import Optional +from typing import List, Optional from slack_sdk import WebClient +from slack_sdk.web import SlackResponse from slack_sdk.web.chat_stream import ChatStream @@ -71,3 +72,32 @@ def chat_stream( recipient_user_id=recipient_user_id or self._user_id, **kwargs, ) + + def set_status( + self, + *, + status: str, + loading_messages: Optional[List[str]] = None, + channel: Optional[str] = None, + thread_ts: Optional[str] = None, + **kwargs, + ) -> SlackResponse: + """Sets the status of an assistant thread. + + Args: + status: The status text to display. + loading_messages: Optional list of loading messages to cycle through. + channel: Channel ID. Defaults to the channel from the event context. + thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. + **kwargs: Additional arguments passed to ``WebClient.assistant_threads_setStatus()``. + + Returns: + ``SlackResponse`` from the API call. + """ + return self._client.assistant_threads_setStatus( + channel_id=channel or self._channel_id, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + status=status, + loading_messages=loading_messages, + **kwargs, + ) diff --git a/slack_bolt/agent/async_agent.py b/slack_bolt/agent/async_agent.py index 2ee15aa2e..5b86533e6 100644 --- a/slack_bolt/agent/async_agent.py +++ b/slack_bolt/agent/async_agent.py @@ -1,6 +1,6 @@ -from typing import Optional +from typing import List, Optional -from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient from slack_sdk.web.async_chat_stream import AsyncChatStream @@ -68,3 +68,32 @@ async def chat_stream( recipient_user_id=recipient_user_id or self._user_id, **kwargs, ) + + async def set_status( + self, + *, + status: str, + loading_messages: Optional[List[str]] = None, + channel: Optional[str] = None, + thread_ts: Optional[str] = None, + **kwargs, + ) -> AsyncSlackResponse: + """Sets the status of an assistant thread. + + Args: + status: The status text to display. + loading_messages: Optional list of loading messages to cycle through. + channel: Channel ID. Defaults to the channel from the event context. + thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. + **kwargs: Additional arguments passed to ``AsyncWebClient.assistant_threads_setStatus()``. + + Returns: + ``AsyncSlackResponse`` from the API call. + """ + return await self._client.assistant_threads_setStatus( + channel_id=channel or self._channel_id, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + status=status, + loading_messages=loading_messages, + **kwargs, + ) diff --git a/tests/slack_bolt/agent/test_agent.py b/tests/slack_bolt/agent/test_agent.py index 00e998379..7dad481b0 100644 --- a/tests/slack_bolt/agent/test_agent.py +++ b/tests/slack_bolt/agent/test_agent.py @@ -92,6 +92,111 @@ def test_chat_stream_passes_extra_kwargs(self): buffer_size=512, ) + def test_set_status_uses_context_defaults(self): + """BoltAgent.set_status() passes context defaults to WebClient.assistant_threads_setStatus().""" + client = MagicMock(spec=WebClient) + client.assistant_threads_setStatus.return_value = MagicMock() + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + agent.set_status(status="Thinking...") + + client.assistant_threads_setStatus.assert_called_once_with( + channel_id="C111", + thread_ts="1234567890.123456", + status="Thinking...", + loading_messages=None, + ) + + def test_set_status_with_loading_messages(self): + """BoltAgent.set_status() forwards loading_messages.""" + client = MagicMock(spec=WebClient) + client.assistant_threads_setStatus.return_value = MagicMock() + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + agent.set_status( + status="Thinking...", + loading_messages=["Sitting...", "Waiting..."], + ) + + client.assistant_threads_setStatus.assert_called_once_with( + channel_id="C111", + thread_ts="1234567890.123456", + status="Thinking...", + loading_messages=["Sitting...", "Waiting..."], + ) + + def test_set_status_overrides_context_defaults(self): + """Explicit channel/thread_ts override context defaults.""" + client = MagicMock(spec=WebClient) + client.assistant_threads_setStatus.return_value = MagicMock() + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + agent.set_status( + status="Thinking...", + channel="C999", + thread_ts="9999999999.999999", + ) + + client.assistant_threads_setStatus.assert_called_once_with( + channel_id="C999", + thread_ts="9999999999.999999", + status="Thinking...", + loading_messages=None, + ) + + def test_set_status_passes_extra_kwargs(self): + """Extra kwargs are forwarded to WebClient.assistant_threads_setStatus().""" + client = MagicMock(spec=WebClient) + client.assistant_threads_setStatus.return_value = MagicMock() + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + agent.set_status(status="Thinking...", token="xoxb-override") + + client.assistant_threads_setStatus.assert_called_once_with( + channel_id="C111", + thread_ts="1234567890.123456", + status="Thinking...", + loading_messages=None, + token="xoxb-override", + ) + + def test_set_status_requires_status(self): + """set_status() raises TypeError when status is not provided.""" + client = MagicMock(spec=WebClient) + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + with pytest.raises(TypeError): + agent.set_status() + def test_import_from_slack_bolt(self): from slack_bolt import BoltAgent as ImportedBoltAgent diff --git a/tests/slack_bolt_async/agent/test_async_agent.py b/tests/slack_bolt_async/agent/test_async_agent.py index 02251fa4b..8e4c4d5c8 100644 --- a/tests/slack_bolt_async/agent/test_async_agent.py +++ b/tests/slack_bolt_async/agent/test_async_agent.py @@ -18,6 +18,17 @@ async def fake_chat_stream(**kwargs): return fake_chat_stream, call_tracker, mock_stream +def _make_async_api_mock(): + mock_response = MagicMock() + call_tracker = MagicMock() + + async def fake_api_call(**kwargs): + call_tracker(**kwargs) + return mock_response + + return fake_api_call, call_tracker, mock_response + + class TestAsyncBoltAgent: @pytest.mark.asyncio async def test_chat_stream_uses_context_defaults(self): @@ -107,6 +118,116 @@ async def test_chat_stream_passes_extra_kwargs(self): buffer_size=512, ) + @pytest.mark.asyncio + async def test_set_status_uses_context_defaults(self): + """AsyncBoltAgent.set_status() passes context defaults to AsyncWebClient.assistant_threads_setStatus().""" + client = MagicMock(spec=AsyncWebClient) + client.assistant_threads_setStatus, call_tracker, _ = _make_async_api_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + await agent.set_status(status="Thinking...") + + call_tracker.assert_called_once_with( + channel_id="C111", + thread_ts="1234567890.123456", + status="Thinking...", + loading_messages=None, + ) + + @pytest.mark.asyncio + async def test_set_status_with_loading_messages(self): + """AsyncBoltAgent.set_status() forwards loading_messages.""" + client = MagicMock(spec=AsyncWebClient) + client.assistant_threads_setStatus, call_tracker, _ = _make_async_api_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + await agent.set_status( + status="Thinking...", + loading_messages=["Sitting...", "Waiting..."], + ) + + call_tracker.assert_called_once_with( + channel_id="C111", + thread_ts="1234567890.123456", + status="Thinking...", + loading_messages=["Sitting...", "Waiting..."], + ) + + @pytest.mark.asyncio + async def test_set_status_overrides_context_defaults(self): + """Explicit channel/thread_ts override context defaults.""" + client = MagicMock(spec=AsyncWebClient) + client.assistant_threads_setStatus, call_tracker, _ = _make_async_api_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + await agent.set_status( + status="Thinking...", + channel="C999", + thread_ts="9999999999.999999", + ) + + call_tracker.assert_called_once_with( + channel_id="C999", + thread_ts="9999999999.999999", + status="Thinking...", + loading_messages=None, + ) + + @pytest.mark.asyncio + async def test_set_status_passes_extra_kwargs(self): + """Extra kwargs are forwarded to AsyncWebClient.assistant_threads_setStatus().""" + client = MagicMock(spec=AsyncWebClient) + client.assistant_threads_setStatus, call_tracker, _ = _make_async_api_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + await agent.set_status(status="Thinking...", token="xoxb-override") + + call_tracker.assert_called_once_with( + channel_id="C111", + thread_ts="1234567890.123456", + status="Thinking...", + loading_messages=None, + token="xoxb-override", + ) + + @pytest.mark.asyncio + async def test_set_status_requires_status(self): + """set_status() raises TypeError when status is not provided.""" + client = MagicMock(spec=AsyncWebClient) + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + with pytest.raises(TypeError): + await agent.set_status() + @pytest.mark.asyncio async def test_import_from_agent_module(self): from slack_bolt.agent.async_agent import AsyncBoltAgent as ImportedAsyncBoltAgent From 5cb618256c04ae53fe6a4cf1335a797a2395fee8 Mon Sep 17 00:00:00 2001 From: Haley Elmendorf <31392893+haleychaas@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:49:43 -0600 Subject: [PATCH 836/865] Docs: Add headings so copy as markdown button shows up (#1443) --- docs/english/concepts/acknowledge.md | 3 +++ docs/english/concepts/adapters.md | 2 ++ docs/english/concepts/app-home.md | 3 +++ docs/english/concepts/authorization.md | 2 ++ docs/english/concepts/commands.md | 3 +++ docs/english/concepts/context.md | 2 ++ docs/english/concepts/custom-adapters.md | 2 ++ docs/english/concepts/errors.md | 2 ++ docs/english/concepts/global-middleware.md | 3 +++ docs/english/concepts/listener-middleware.md | 2 ++ docs/english/concepts/logging.md | 2 ++ docs/english/concepts/opening-modals.md | 2 ++ docs/english/concepts/select-menu-options.md | 3 +++ docs/english/concepts/web-api.md | 2 ++ 14 files changed, 33 insertions(+) diff --git a/docs/english/concepts/acknowledge.md b/docs/english/concepts/acknowledge.md index 7d91e0851..57b346bd3 100644 --- a/docs/english/concepts/acknowledge.md +++ b/docs/english/concepts/acknowledge.md @@ -11,6 +11,9 @@ We recommend calling `ack()` right away before initiating any time-consuming pro ::: Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +## Example + ```python # Example of responding to an external_select options request @app.options("menu_selection") diff --git a/docs/english/concepts/adapters.md b/docs/english/concepts/adapters.md index 321dae0ab..ad43a27da 100644 --- a/docs/english/concepts/adapters.md +++ b/docs/english/concepts/adapters.md @@ -8,6 +8,8 @@ To use an adapter, you'll create an app with the framework of your choosing and The full list adapters, as well as configuration and sample usage, can be found within the repository's [`examples`](https://github.com/slackapi/bolt-python/tree/main/examples) +## Example + ```python from slack_bolt import App app = App( diff --git a/docs/english/concepts/app-home.md b/docs/english/concepts/app-home.md index 8b0e2cf11..f4f15337f 100644 --- a/docs/english/concepts/app-home.md +++ b/docs/english/concepts/app-home.md @@ -5,6 +5,9 @@ You can subscribe to the [`app_home_opened`](/reference/events/app_home_opened) event to listen for when users open your App Home. Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +## Example + ```python @app.event("app_home_opened") def update_home_tab(client, event, logger): diff --git a/docs/english/concepts/authorization.md b/docs/english/concepts/authorization.md index 242a86b39..f6a258491 100644 --- a/docs/english/concepts/authorization.md +++ b/docs/english/concepts/authorization.md @@ -12,6 +12,8 @@ For a more custom solution, you can set the `authorize` parameter to a function - **`enterprise_id`** and **`team_id`**, which can be found in requests sent to your app. - **`user_id`** only when using `user_token`. +## Example + ```python import os from slack_bolt import App diff --git a/docs/english/concepts/commands.md b/docs/english/concepts/commands.md index 81167fb83..cd772c57b 100644 --- a/docs/english/concepts/commands.md +++ b/docs/english/concepts/commands.md @@ -9,6 +9,9 @@ There are two ways to respond to slash commands. The first way is to use `say()` When setting up commands within your app configuration, you'll append `/slack/events` to your request URL. Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +## Example + ```python # The echo command simply echoes on command @app.command("/echo") diff --git a/docs/english/concepts/context.md b/docs/english/concepts/context.md index fb134c896..46684ea28 100644 --- a/docs/english/concepts/context.md +++ b/docs/english/concepts/context.md @@ -4,6 +4,8 @@ All listeners have access to a `context` dictionary, which can be used to enrich `context` is just a dictionary, so you can directly modify it. +## Example + ```python # Listener middleware to fetch tasks from external system using user ID def fetch_tasks(context, event, next): diff --git a/docs/english/concepts/custom-adapters.md b/docs/english/concepts/custom-adapters.md index 62532e7cd..21f7f33e0 100644 --- a/docs/english/concepts/custom-adapters.md +++ b/docs/english/concepts/custom-adapters.md @@ -18,6 +18,8 @@ Your adapter will return [an instance of `BoltResponse`](https://github.com/slac For more in-depth examples of custom adapters, look at the implementations of the [built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter). +## Example + ```python # Necessary imports for Flask from flask import Request, Response, make_response diff --git a/docs/english/concepts/errors.md b/docs/english/concepts/errors.md index ed41c5816..7b40adb7f 100644 --- a/docs/english/concepts/errors.md +++ b/docs/english/concepts/errors.md @@ -4,6 +4,8 @@ If an error occurs in a listener, you can handle it directly using a try/except By default, the global error handler will log all non-handled exceptions to the console. To handle global errors yourself, you can attach a global error handler to your app using the `app.error(fn)` function. +## Example + ```python @app.error def custom_error_handler(error, body, logger): diff --git a/docs/english/concepts/global-middleware.md b/docs/english/concepts/global-middleware.md index dbcdeae99..7b7bdb059 100644 --- a/docs/english/concepts/global-middleware.md +++ b/docs/english/concepts/global-middleware.md @@ -5,6 +5,9 @@ Global middleware is run for all incoming requests, before any listener middlewa Both global and listener middleware must call `next()` to pass control of the execution chain to the next middleware. Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +## Example + ```python @app.use def auth_acme(client, context, logger, payload, next): diff --git a/docs/english/concepts/listener-middleware.md b/docs/english/concepts/listener-middleware.md index c8bfc964e..dd020373f 100644 --- a/docs/english/concepts/listener-middleware.md +++ b/docs/english/concepts/listener-middleware.md @@ -6,6 +6,8 @@ If your listener middleware is a quite simple one, you can use a listener matche Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. +## Example + ```python # Listener middleware which filters out messages from a bot def no_bot_messages(message, next): diff --git a/docs/english/concepts/logging.md b/docs/english/concepts/logging.md index 49e275d2d..599431550 100644 --- a/docs/english/concepts/logging.md +++ b/docs/english/concepts/logging.md @@ -4,6 +4,8 @@ By default, Bolt will log information from your app to the output destination. A Outside of a global context, you can also log a single message corresponding to a specific level. Because Bolt uses Python’s [standard logging module](https://docs.python.org/3/library/logging.html), you can use any its features. +## Example + ```python import logging diff --git a/docs/english/concepts/opening-modals.md b/docs/english/concepts/opening-modals.md index 1f053539f..01716f613 100644 --- a/docs/english/concepts/opening-modals.md +++ b/docs/english/concepts/opening-modals.md @@ -8,6 +8,8 @@ Read more about modal composition in the [API documentation](/surfaces/modals#co Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. +## Example + ```python # Listen for a shortcut invocation @app.shortcut("open_modal") diff --git a/docs/english/concepts/select-menu-options.md b/docs/english/concepts/select-menu-options.md index 40d29472c..8e6cbb9fe 100644 --- a/docs/english/concepts/select-menu-options.md +++ b/docs/english/concepts/select-menu-options.md @@ -10,6 +10,9 @@ To respond to options requests, you'll need to call `ack()` with a valid `option Additionally, you may want to apply filtering logic to the returned options based on user input. This can be accomplished by using the `payload` argument to your options listener and checking for the contents of the `value` property within it. Based on the `value` you can return different options. All listeners and middleware handlers in Bolt for Python have access to [many useful arguments](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) - be sure to check them out! Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +## Example + ```python # Example of responding to an external_select options request @app.options("external_action") diff --git a/docs/english/concepts/web-api.md b/docs/english/concepts/web-api.md index 9cf436851..81f7c9b60 100644 --- a/docs/english/concepts/web-api.md +++ b/docs/english/concepts/web-api.md @@ -8,6 +8,8 @@ The token used to initialize Bolt can be found in the `context` object, which is ::: +## Example + ```python @app.message("wake me up") def say_hello(client, message): From 92bff603ef91d590da0849a6a1c4af319bad4996 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 18 Feb 2026 18:24:50 -0800 Subject: [PATCH 837/865] feat(agent): add set_suggested_prompts helper (#1442) Co-authored-by: Ale Mercado --- slack_bolt/agent/agent.py | 39 +++++- slack_bolt/agent/async_agent.py | 39 +++++- tests/slack_bolt/agent/test_agent.py | 112 +++++++++++++++++ .../agent/test_async_agent.py | 117 ++++++++++++++++++ 4 files changed, 305 insertions(+), 2 deletions(-) diff --git a/slack_bolt/agent/agent.py b/slack_bolt/agent/agent.py index 3663b245b..056dba986 100644 --- a/slack_bolt/agent/agent.py +++ b/slack_bolt/agent/agent.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Dict, List, Optional, Sequence, Union from slack_sdk import WebClient from slack_sdk.web import SlackResponse @@ -101,3 +101,40 @@ def set_status( loading_messages=loading_messages, **kwargs, ) + + def set_suggested_prompts( + self, + *, + prompts: Sequence[Union[str, Dict[str, str]]], + title: Optional[str] = None, + channel: Optional[str] = None, + thread_ts: Optional[str] = None, + **kwargs, + ) -> SlackResponse: + """Sets suggested prompts for an assistant thread. + + Args: + prompts: A sequence of prompts. Each prompt can be either a string + (used as both title and message) or a dict with 'title' and 'message' keys. + title: Optional title for the suggested prompts section. + channel: Channel ID. Defaults to the channel from the event context. + thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. + **kwargs: Additional arguments passed to ``WebClient.assistant_threads_setSuggestedPrompts()``. + + Returns: + ``SlackResponse`` from the API call. + """ + prompts_arg: List[Dict[str, str]] = [] + for prompt in prompts: + if isinstance(prompt, str): + prompts_arg.append({"title": prompt, "message": prompt}) + else: + prompts_arg.append(prompt) + + return self._client.assistant_threads_setSuggestedPrompts( + channel_id=channel or self._channel_id, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + prompts=prompts_arg, + title=title, + **kwargs, + ) diff --git a/slack_bolt/agent/async_agent.py b/slack_bolt/agent/async_agent.py index 5b86533e6..5630e1b81 100644 --- a/slack_bolt/agent/async_agent.py +++ b/slack_bolt/agent/async_agent.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Dict, List, Optional, Sequence, Union from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient from slack_sdk.web.async_chat_stream import AsyncChatStream @@ -97,3 +97,40 @@ async def set_status( loading_messages=loading_messages, **kwargs, ) + + async def set_suggested_prompts( + self, + *, + prompts: Sequence[Union[str, Dict[str, str]]], + title: Optional[str] = None, + channel: Optional[str] = None, + thread_ts: Optional[str] = None, + **kwargs, + ) -> AsyncSlackResponse: + """Sets suggested prompts for an assistant thread. + + Args: + prompts: A sequence of prompts. Each prompt can be either a string + (used as both title and message) or a dict with 'title' and 'message' keys. + title: Optional title for the suggested prompts section. + channel: Channel ID. Defaults to the channel from the event context. + thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. + **kwargs: Additional arguments passed to ``AsyncWebClient.assistant_threads_setSuggestedPrompts()``. + + Returns: + ``AsyncSlackResponse`` from the API call. + """ + prompts_arg: List[Dict[str, str]] = [] + for prompt in prompts: + if isinstance(prompt, str): + prompts_arg.append({"title": prompt, "message": prompt}) + else: + prompts_arg.append(prompt) + + return await self._client.assistant_threads_setSuggestedPrompts( + channel_id=channel or self._channel_id, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + prompts=prompts_arg, + title=title, + **kwargs, + ) diff --git a/tests/slack_bolt/agent/test_agent.py b/tests/slack_bolt/agent/test_agent.py index 7dad481b0..1d14eda06 100644 --- a/tests/slack_bolt/agent/test_agent.py +++ b/tests/slack_bolt/agent/test_agent.py @@ -197,6 +197,118 @@ def test_set_status_requires_status(self): with pytest.raises(TypeError): agent.set_status() + def test_set_suggested_prompts_uses_context_defaults(self): + """BoltAgent.set_suggested_prompts() passes context defaults to WebClient.assistant_threads_setSuggestedPrompts().""" + client = MagicMock(spec=WebClient) + client.assistant_threads_setSuggestedPrompts.return_value = MagicMock() + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + agent.set_suggested_prompts(prompts=["What can you do?", "Help me write code"]) + + client.assistant_threads_setSuggestedPrompts.assert_called_once_with( + channel_id="C111", + thread_ts="1234567890.123456", + prompts=[ + {"title": "What can you do?", "message": "What can you do?"}, + {"title": "Help me write code", "message": "Help me write code"}, + ], + title=None, + ) + + def test_set_suggested_prompts_with_dict_prompts(self): + """BoltAgent.set_suggested_prompts() accepts dict prompts with title and message.""" + client = MagicMock(spec=WebClient) + client.assistant_threads_setSuggestedPrompts.return_value = MagicMock() + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + agent.set_suggested_prompts( + prompts=[ + {"title": "Short title", "message": "A much longer message for this prompt"}, + ], + title="Suggestions", + ) + + client.assistant_threads_setSuggestedPrompts.assert_called_once_with( + channel_id="C111", + thread_ts="1234567890.123456", + prompts=[ + {"title": "Short title", "message": "A much longer message for this prompt"}, + ], + title="Suggestions", + ) + + def test_set_suggested_prompts_overrides_context_defaults(self): + """Explicit channel/thread_ts override context defaults.""" + client = MagicMock(spec=WebClient) + client.assistant_threads_setSuggestedPrompts.return_value = MagicMock() + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + agent.set_suggested_prompts( + prompts=["Hello"], + channel="C999", + thread_ts="9999999999.999999", + ) + + client.assistant_threads_setSuggestedPrompts.assert_called_once_with( + channel_id="C999", + thread_ts="9999999999.999999", + prompts=[{"title": "Hello", "message": "Hello"}], + title=None, + ) + + def test_set_suggested_prompts_passes_extra_kwargs(self): + """Extra kwargs are forwarded to WebClient.assistant_threads_setSuggestedPrompts().""" + client = MagicMock(spec=WebClient) + client.assistant_threads_setSuggestedPrompts.return_value = MagicMock() + + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + agent.set_suggested_prompts(prompts=["Hello"], token="xoxb-override") + + client.assistant_threads_setSuggestedPrompts.assert_called_once_with( + channel_id="C111", + thread_ts="1234567890.123456", + prompts=[{"title": "Hello", "message": "Hello"}], + title=None, + token="xoxb-override", + ) + + def test_set_suggested_prompts_requires_prompts(self): + """set_suggested_prompts() raises TypeError when prompts is not provided.""" + client = MagicMock(spec=WebClient) + agent = BoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + with pytest.raises(TypeError): + agent.set_suggested_prompts() + def test_import_from_slack_bolt(self): from slack_bolt import BoltAgent as ImportedBoltAgent diff --git a/tests/slack_bolt_async/agent/test_async_agent.py b/tests/slack_bolt_async/agent/test_async_agent.py index 8e4c4d5c8..b934bbaeb 100644 --- a/tests/slack_bolt_async/agent/test_async_agent.py +++ b/tests/slack_bolt_async/agent/test_async_agent.py @@ -228,6 +228,123 @@ async def test_set_status_requires_status(self): with pytest.raises(TypeError): await agent.set_status() + @pytest.mark.asyncio + async def test_set_suggested_prompts_uses_context_defaults(self): + """AsyncBoltAgent.set_suggested_prompts() passes context defaults to AsyncWebClient.assistant_threads_setSuggestedPrompts().""" + client = MagicMock(spec=AsyncWebClient) + client.assistant_threads_setSuggestedPrompts, call_tracker, _ = _make_async_api_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + await agent.set_suggested_prompts(prompts=["What can you do?", "Help me write code"]) + + call_tracker.assert_called_once_with( + channel_id="C111", + thread_ts="1234567890.123456", + prompts=[ + {"title": "What can you do?", "message": "What can you do?"}, + {"title": "Help me write code", "message": "Help me write code"}, + ], + title=None, + ) + + @pytest.mark.asyncio + async def test_set_suggested_prompts_with_dict_prompts(self): + """AsyncBoltAgent.set_suggested_prompts() accepts dict prompts with title and message.""" + client = MagicMock(spec=AsyncWebClient) + client.assistant_threads_setSuggestedPrompts, call_tracker, _ = _make_async_api_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + await agent.set_suggested_prompts( + prompts=[ + {"title": "Short title", "message": "A much longer message for this prompt"}, + ], + title="Suggestions", + ) + + call_tracker.assert_called_once_with( + channel_id="C111", + thread_ts="1234567890.123456", + prompts=[ + {"title": "Short title", "message": "A much longer message for this prompt"}, + ], + title="Suggestions", + ) + + @pytest.mark.asyncio + async def test_set_suggested_prompts_overrides_context_defaults(self): + """Explicit channel/thread_ts override context defaults.""" + client = MagicMock(spec=AsyncWebClient) + client.assistant_threads_setSuggestedPrompts, call_tracker, _ = _make_async_api_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + await agent.set_suggested_prompts( + prompts=["Hello"], + channel="C999", + thread_ts="9999999999.999999", + ) + + call_tracker.assert_called_once_with( + channel_id="C999", + thread_ts="9999999999.999999", + prompts=[{"title": "Hello", "message": "Hello"}], + title=None, + ) + + @pytest.mark.asyncio + async def test_set_suggested_prompts_passes_extra_kwargs(self): + """Extra kwargs are forwarded to AsyncWebClient.assistant_threads_setSuggestedPrompts().""" + client = MagicMock(spec=AsyncWebClient) + client.assistant_threads_setSuggestedPrompts, call_tracker, _ = _make_async_api_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + await agent.set_suggested_prompts(prompts=["Hello"], token="xoxb-override") + + call_tracker.assert_called_once_with( + channel_id="C111", + thread_ts="1234567890.123456", + prompts=[{"title": "Hello", "message": "Hello"}], + title=None, + token="xoxb-override", + ) + + @pytest.mark.asyncio + async def test_set_suggested_prompts_requires_prompts(self): + """set_suggested_prompts() raises TypeError when prompts is not provided.""" + client = MagicMock(spec=AsyncWebClient) + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + thread_ts="1234567890.123456", + team_id="T111", + user_id="W222", + ) + with pytest.raises(TypeError): + await agent.set_suggested_prompts() + @pytest.mark.asyncio async def test_import_from_agent_module(self): from slack_bolt.agent.async_agent import AsyncBoltAgent as ImportedAsyncBoltAgent From d789facab62a77c37e0dc0f34609a0a91253230c Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 18 Feb 2026 18:34:54 -0800 Subject: [PATCH 838/865] feat(agent): default to message 'ts' when no 'thread_ts' is avaialble for 'agent.chat_stream(...)' (#1444) Co-authored-by: Ale Mercado --- slack_bolt/agent/agent.py | 11 ++--- slack_bolt/agent/async_agent.py | 8 ++-- slack_bolt/kwargs_injection/async_utils.py | 5 +- slack_bolt/kwargs_injection/utils.py | 5 +- slack_bolt/request/internals.py | 3 ++ tests/slack_bolt/agent/test_agent.py | 45 ++++++++++++++++++ .../agent/test_async_agent.py | 47 +++++++++++++++++++ 7 files changed, 113 insertions(+), 11 deletions(-) diff --git a/slack_bolt/agent/agent.py b/slack_bolt/agent/agent.py index 056dba986..aa84bae90 100644 --- a/slack_bolt/agent/agent.py +++ b/slack_bolt/agent/agent.py @@ -11,9 +11,6 @@ class BoltAgent: Experimental: This API is experimental and may change in future releases. - FIXME: chat_stream() only works when thread_ts is available (DMs and threaded replies). - It does not work on channel messages because ts is not provided to BoltAgent yet. - @app.event("app_mention") def handle_mention(agent): stream = agent.chat_stream() @@ -27,12 +24,14 @@ def __init__( client: WebClient, channel_id: Optional[str] = None, thread_ts: Optional[str] = None, + ts: Optional[str] = None, team_id: Optional[str] = None, user_id: Optional[str] = None, ): self._client = client self._channel_id = channel_id self._thread_ts = thread_ts + self._ts = ts self._team_id = team_id self._user_id = user_id @@ -67,7 +66,7 @@ def chat_stream( # Argument validation is delegated to chat_stream() and the API return self._client.chat_stream( channel=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] recipient_team_id=recipient_team_id or self._team_id, recipient_user_id=recipient_user_id or self._user_id, **kwargs, @@ -96,7 +95,7 @@ def set_status( """ return self._client.assistant_threads_setStatus( channel_id=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] status=status, loading_messages=loading_messages, **kwargs, @@ -133,7 +132,7 @@ def set_suggested_prompts( return self._client.assistant_threads_setSuggestedPrompts( channel_id=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] prompts=prompts_arg, title=title, **kwargs, diff --git a/slack_bolt/agent/async_agent.py b/slack_bolt/agent/async_agent.py index 5630e1b81..7272338e1 100644 --- a/slack_bolt/agent/async_agent.py +++ b/slack_bolt/agent/async_agent.py @@ -23,12 +23,14 @@ def __init__( client: AsyncWebClient, channel_id: Optional[str] = None, thread_ts: Optional[str] = None, + ts: Optional[str] = None, team_id: Optional[str] = None, user_id: Optional[str] = None, ): self._client = client self._channel_id = channel_id self._thread_ts = thread_ts + self._ts = ts self._team_id = team_id self._user_id = user_id @@ -63,7 +65,7 @@ async def chat_stream( # Argument validation is delegated to chat_stream() and the API return await self._client.chat_stream( channel=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] recipient_team_id=recipient_team_id or self._team_id, recipient_user_id=recipient_user_id or self._user_id, **kwargs, @@ -92,7 +94,7 @@ async def set_status( """ return await self._client.assistant_threads_setStatus( channel_id=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] status=status, loading_messages=loading_messages, **kwargs, @@ -129,7 +131,7 @@ async def set_suggested_prompts( return await self._client.assistant_threads_setSuggestedPrompts( channel_id=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts, # type: ignore[arg-type] + thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] prompts=prompts_arg, title=title, **kwargs, diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index e43cd0c27..aa84b2d11 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -89,10 +89,13 @@ def build_async_required_kwargs( if "agent" in required_arg_names: from slack_bolt.agent.async_agent import AsyncBoltAgent + event = request.body.get("event", {}) + all_available_args["agent"] = AsyncBoltAgent( client=request.context.client, channel_id=request.context.channel_id, - thread_ts=request.context.thread_ts, + thread_ts=request.context.thread_ts or event.get("thread_ts"), + ts=event.get("ts"), team_id=request.context.team_id, user_id=request.context.user_id, ) diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 73fe99bba..5cd410a07 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -88,10 +88,13 @@ def build_required_kwargs( if "agent" in required_arg_names: from slack_bolt.agent.agent import BoltAgent + event = request.body.get("event", {}) + all_available_args["agent"] = BoltAgent( client=request.context.client, channel_id=request.context.channel_id, - thread_ts=request.context.thread_ts, + thread_ts=request.context.thread_ts or event.get("thread_ts"), + ts=event.get("ts"), team_id=request.context.team_id, user_id=request.context.user_id, ) diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 014a8134a..e6a32db0d 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -218,6 +218,9 @@ def extract_thread_ts(payload: Dict[str, Any]) -> Optional[str]: # This utility initially supports only the use cases for AI assistants, but it may be fine to add more patterns. # That said, note that thread_ts is always required for assistant threads, but it's not for channels. # Thus, blindly setting this thread_ts to say utility can break existing apps' behaviors. + # + # The BoltAgent class handles non-assistant thread_ts separately by reading from the event directly, + # allowing it to work correctly without affecting say() behavior. if is_assistant_event(payload): event = payload["event"] if ( diff --git a/tests/slack_bolt/agent/test_agent.py b/tests/slack_bolt/agent/test_agent.py index 1d14eda06..87d51d9eb 100644 --- a/tests/slack_bolt/agent/test_agent.py +++ b/tests/slack_bolt/agent/test_agent.py @@ -92,6 +92,51 @@ def test_chat_stream_passes_extra_kwargs(self): buffer_size=512, ) + def test_chat_stream_falls_back_to_ts(self): + """When thread_ts is not set, chat_stream() falls back to ts.""" + client = MagicMock(spec=WebClient) + client.chat_stream.return_value = MagicMock(spec=ChatStream) + + agent = BoltAgent( + client=client, + channel_id="C111", + team_id="T111", + ts="1111111111.111111", + user_id="W222", + ) + stream = agent.chat_stream() + + client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="1111111111.111111", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + + def test_chat_stream_prefers_thread_ts_over_ts(self): + """thread_ts takes priority over ts.""" + client = MagicMock(spec=WebClient) + client.chat_stream.return_value = MagicMock(spec=ChatStream) + + agent = BoltAgent( + client=client, + channel_id="C111", + team_id="T111", + thread_ts="1234567890.123456", + ts="1111111111.111111", + user_id="W222", + ) + stream = agent.chat_stream() + + client.chat_stream.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + def test_set_status_uses_context_defaults(self): """BoltAgent.set_status() passes context defaults to WebClient.assistant_threads_setStatus().""" client = MagicMock(spec=WebClient) diff --git a/tests/slack_bolt_async/agent/test_async_agent.py b/tests/slack_bolt_async/agent/test_async_agent.py index b934bbaeb..7c01a4301 100644 --- a/tests/slack_bolt_async/agent/test_async_agent.py +++ b/tests/slack_bolt_async/agent/test_async_agent.py @@ -118,6 +118,53 @@ async def test_chat_stream_passes_extra_kwargs(self): buffer_size=512, ) + @pytest.mark.asyncio + async def test_chat_stream_falls_back_to_ts(self): + """When thread_ts is not set, chat_stream() falls back to ts.""" + client = MagicMock(spec=AsyncWebClient) + client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + team_id="T111", + ts="1111111111.111111", + user_id="W222", + ) + stream = await agent.chat_stream() + + call_tracker.assert_called_once_with( + channel="C111", + thread_ts="1111111111.111111", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + + @pytest.mark.asyncio + async def test_chat_stream_prefers_thread_ts_over_ts(self): + """thread_ts takes priority over ts.""" + client = MagicMock(spec=AsyncWebClient) + client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() + + agent = AsyncBoltAgent( + client=client, + channel_id="C111", + team_id="T111", + thread_ts="1234567890.123456", + ts="1111111111.111111", + user_id="W222", + ) + stream = await agent.chat_stream() + + call_tracker.assert_called_once_with( + channel="C111", + thread_ts="1234567890.123456", + recipient_team_id="T111", + recipient_user_id="W222", + ) + assert stream is not None + @pytest.mark.asyncio async def test_set_status_uses_context_defaults(self): """AsyncBoltAgent.set_status() passes context defaults to AsyncWebClient.assistant_threads_setStatus().""" From 837e120a3119f6b92da1dd52064a2400e9f97ba9 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 19 Feb 2026 11:28:37 -0800 Subject: [PATCH 839/865] fix(agent): match channel_id api argument for set_status and set_suggested_prompts (#1446) --- slack_bolt/agent/agent.py | 12 ++++++------ slack_bolt/agent/async_agent.py | 12 ++++++------ tests/slack_bolt/agent/test_agent.py | 8 ++++---- tests/slack_bolt_async/agent/test_async_agent.py | 8 ++++---- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/slack_bolt/agent/agent.py b/slack_bolt/agent/agent.py index aa84bae90..523b0e33c 100644 --- a/slack_bolt/agent/agent.py +++ b/slack_bolt/agent/agent.py @@ -77,7 +77,7 @@ def set_status( *, status: str, loading_messages: Optional[List[str]] = None, - channel: Optional[str] = None, + channel_id: Optional[str] = None, thread_ts: Optional[str] = None, **kwargs, ) -> SlackResponse: @@ -86,7 +86,7 @@ def set_status( Args: status: The status text to display. loading_messages: Optional list of loading messages to cycle through. - channel: Channel ID. Defaults to the channel from the event context. + channel_id: Channel ID. Defaults to the channel from the event context. thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. **kwargs: Additional arguments passed to ``WebClient.assistant_threads_setStatus()``. @@ -94,7 +94,7 @@ def set_status( ``SlackResponse`` from the API call. """ return self._client.assistant_threads_setStatus( - channel_id=channel or self._channel_id, # type: ignore[arg-type] + channel_id=channel_id or self._channel_id, # type: ignore[arg-type] thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] status=status, loading_messages=loading_messages, @@ -106,7 +106,7 @@ def set_suggested_prompts( *, prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, - channel: Optional[str] = None, + channel_id: Optional[str] = None, thread_ts: Optional[str] = None, **kwargs, ) -> SlackResponse: @@ -116,7 +116,7 @@ def set_suggested_prompts( prompts: A sequence of prompts. Each prompt can be either a string (used as both title and message) or a dict with 'title' and 'message' keys. title: Optional title for the suggested prompts section. - channel: Channel ID. Defaults to the channel from the event context. + channel_id: Channel ID. Defaults to the channel from the event context. thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. **kwargs: Additional arguments passed to ``WebClient.assistant_threads_setSuggestedPrompts()``. @@ -131,7 +131,7 @@ def set_suggested_prompts( prompts_arg.append(prompt) return self._client.assistant_threads_setSuggestedPrompts( - channel_id=channel or self._channel_id, # type: ignore[arg-type] + channel_id=channel_id or self._channel_id, # type: ignore[arg-type] thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] prompts=prompts_arg, title=title, diff --git a/slack_bolt/agent/async_agent.py b/slack_bolt/agent/async_agent.py index 7272338e1..da4ec6c0a 100644 --- a/slack_bolt/agent/async_agent.py +++ b/slack_bolt/agent/async_agent.py @@ -76,7 +76,7 @@ async def set_status( *, status: str, loading_messages: Optional[List[str]] = None, - channel: Optional[str] = None, + channel_id: Optional[str] = None, thread_ts: Optional[str] = None, **kwargs, ) -> AsyncSlackResponse: @@ -85,7 +85,7 @@ async def set_status( Args: status: The status text to display. loading_messages: Optional list of loading messages to cycle through. - channel: Channel ID. Defaults to the channel from the event context. + channel_id: Channel ID. Defaults to the channel from the event context. thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. **kwargs: Additional arguments passed to ``AsyncWebClient.assistant_threads_setStatus()``. @@ -93,7 +93,7 @@ async def set_status( ``AsyncSlackResponse`` from the API call. """ return await self._client.assistant_threads_setStatus( - channel_id=channel or self._channel_id, # type: ignore[arg-type] + channel_id=channel_id or self._channel_id, # type: ignore[arg-type] thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] status=status, loading_messages=loading_messages, @@ -105,7 +105,7 @@ async def set_suggested_prompts( *, prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, - channel: Optional[str] = None, + channel_id: Optional[str] = None, thread_ts: Optional[str] = None, **kwargs, ) -> AsyncSlackResponse: @@ -115,7 +115,7 @@ async def set_suggested_prompts( prompts: A sequence of prompts. Each prompt can be either a string (used as both title and message) or a dict with 'title' and 'message' keys. title: Optional title for the suggested prompts section. - channel: Channel ID. Defaults to the channel from the event context. + channel_id: Channel ID. Defaults to the channel from the event context. thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. **kwargs: Additional arguments passed to ``AsyncWebClient.assistant_threads_setSuggestedPrompts()``. @@ -130,7 +130,7 @@ async def set_suggested_prompts( prompts_arg.append(prompt) return await self._client.assistant_threads_setSuggestedPrompts( - channel_id=channel or self._channel_id, # type: ignore[arg-type] + channel_id=channel_id or self._channel_id, # type: ignore[arg-type] thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] prompts=prompts_arg, title=title, diff --git a/tests/slack_bolt/agent/test_agent.py b/tests/slack_bolt/agent/test_agent.py index 87d51d9eb..76ac7d17b 100644 --- a/tests/slack_bolt/agent/test_agent.py +++ b/tests/slack_bolt/agent/test_agent.py @@ -183,7 +183,7 @@ def test_set_status_with_loading_messages(self): ) def test_set_status_overrides_context_defaults(self): - """Explicit channel/thread_ts override context defaults.""" + """Explicit channel_id/thread_ts override context defaults.""" client = MagicMock(spec=WebClient) client.assistant_threads_setStatus.return_value = MagicMock() @@ -196,7 +196,7 @@ def test_set_status_overrides_context_defaults(self): ) agent.set_status( status="Thinking...", - channel="C999", + channel_id="C999", thread_ts="9999999999.999999", ) @@ -295,7 +295,7 @@ def test_set_suggested_prompts_with_dict_prompts(self): ) def test_set_suggested_prompts_overrides_context_defaults(self): - """Explicit channel/thread_ts override context defaults.""" + """Explicit channel_id/thread_ts override context defaults.""" client = MagicMock(spec=WebClient) client.assistant_threads_setSuggestedPrompts.return_value = MagicMock() @@ -308,7 +308,7 @@ def test_set_suggested_prompts_overrides_context_defaults(self): ) agent.set_suggested_prompts( prompts=["Hello"], - channel="C999", + channel_id="C999", thread_ts="9999999999.999999", ) diff --git a/tests/slack_bolt_async/agent/test_async_agent.py b/tests/slack_bolt_async/agent/test_async_agent.py index 7c01a4301..3ed8ef0b4 100644 --- a/tests/slack_bolt_async/agent/test_async_agent.py +++ b/tests/slack_bolt_async/agent/test_async_agent.py @@ -214,7 +214,7 @@ async def test_set_status_with_loading_messages(self): @pytest.mark.asyncio async def test_set_status_overrides_context_defaults(self): - """Explicit channel/thread_ts override context defaults.""" + """Explicit channel_id/thread_ts override context defaults.""" client = MagicMock(spec=AsyncWebClient) client.assistant_threads_setStatus, call_tracker, _ = _make_async_api_mock() @@ -227,7 +227,7 @@ async def test_set_status_overrides_context_defaults(self): ) await agent.set_status( status="Thinking...", - channel="C999", + channel_id="C999", thread_ts="9999999999.999999", ) @@ -331,7 +331,7 @@ async def test_set_suggested_prompts_with_dict_prompts(self): @pytest.mark.asyncio async def test_set_suggested_prompts_overrides_context_defaults(self): - """Explicit channel/thread_ts override context defaults.""" + """Explicit channel_id/thread_ts override context defaults.""" client = MagicMock(spec=AsyncWebClient) client.assistant_threads_setSuggestedPrompts, call_tracker, _ = _make_async_api_mock() @@ -344,7 +344,7 @@ async def test_set_suggested_prompts_overrides_context_defaults(self): ) await agent.set_suggested_prompts( prompts=["Hello"], - channel="C999", + channel_id="C999", thread_ts="9999999999.999999", ) From bf767eb22fa3dcb2e622e20734247864ac02cbbb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 22:16:55 +0000 Subject: [PATCH 840/865] chore(deps): bump actions/stale from 10.1.1 to 10.2.0 (#1448) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/triage-issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index cf13d3afc..c29bface2 100644 --- a/.github/workflows/triage-issues.yml +++ b/.github/workflows/triage-issues.yml @@ -16,7 +16,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: days-before-issue-stale: 30 days-before-issue-close: 10 From 0f3afc22e6697f32c32dc1600e7dfbc0ecf15138 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:41:08 -0800 Subject: [PATCH 841/865] chore(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 (#1449) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pypi-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 9c9003c92..34025a6fd 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -33,7 +33,7 @@ jobs: scripts/build_pypi_package.sh - name: Persist dist folder - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: release-dist path: dist/ From 5a153e689fc1ac2a1bf67a1a9f5ea4cef10e3cce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:58:33 +0000 Subject: [PATCH 842/865] chore(deps): bump actions/download-artifact from 7.0.0 to 8.0.0 (#1450) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pypi-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 34025a6fd..7ec974574 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -52,7 +52,7 @@ jobs: steps: - name: Retrieve dist folder - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: release-dist path: dist/ @@ -76,7 +76,7 @@ jobs: steps: - name: Retrieve dist folder - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: release-dist path: dist/ From 72a90d242086d7e77abd0f1be076d6d64f74fa82 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 6 Mar 2026 10:34:16 -0800 Subject: [PATCH 843/865] chore(claude): add claude code support for maintainers (#1445) Co-authored-by: William Bergamin Co-authored-by: William Bergamin --- .claude/.gitignore | 4 + .claude/CLAUDE.md | 1 + .claude/settings.json | 34 +++++ .gitignore | 3 - AGENTS.md | 202 +++++++++++++++++++++++++++ scripts/install.sh | 22 +++ scripts/install_all_and_run_tests.sh | 30 ++-- scripts/run_tests.sh | 10 +- slack_bolt/warning/__init__.py | 1 + 9 files changed, 275 insertions(+), 32 deletions(-) create mode 100644 .claude/.gitignore create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/settings.json create mode 100644 AGENTS.md create mode 100755 scripts/install.sh diff --git a/.claude/.gitignore b/.claude/.gitignore new file mode 100644 index 000000000..3a2f7f6a1 --- /dev/null +++ b/.claude/.gitignore @@ -0,0 +1,4 @@ +CLAUDE.local.md +settings.local.json +worktrees/ +plans/ diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..dba71e970 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +@../AGENTS.md diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..705fd286c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,34 @@ +{ + "permissions": { + "allow": [ + "Bash(./scripts/build_pypi_package.sh:*)", + "Bash(./scripts/format.sh:*)", + "Bash(./scripts/install_all_and_run_tests.sh:*)", + "Bash(./scripts/lint.sh:*)", + "Bash(./scripts/run_mypy.sh:*)", + "Bash(./scripts/run_tests.sh:*)", + "Bash(./scripts/install.sh:*)", + "Bash(echo $VIRTUAL_ENV)", + "Bash(gh issue view:*)", + "Bash(gh label list:*)", + "Bash(gh pr checks:*)", + "Bash(gh pr diff:*)", + "Bash(gh pr list:*)", + "Bash(gh pr status:*)", + "Bash(gh pr update-branch:*)", + "Bash(gh pr view:*)", + "Bash(gh search code:*)", + "Bash(git diff:*)", + "Bash(git grep:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git status:*)", + "Bash(grep:*)", + "Bash(ls:*)", + "Bash(tree:*)", + "WebFetch(domain:github.com)", + "WebFetch(domain:docs.slack.dev)", + "WebFetch(domain:raw.githubusercontent.com)" + ] + } +} diff --git a/.gitignore b/.gitignore index b28dfa9ed..2549060e7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,6 @@ venv/ .venv* .env/ -# claude -.claude/*.local.json - # codecov / coverage .coverage cov_* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..537cabfcf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,202 @@ +# AGENTS.md - bolt-python + +## Project Overview + +Slack Bolt for Python -- a framework for building Slack apps in Python. + +- **Foundation:** Built on top of `slack_sdk` (see `pyproject.toml` constraints). +- **Execution Models:** Supports both synchronous (`App`) and asynchronous (`AsyncApp` using `asyncio`) execution. Async mode requires `aiohttp` as an additional dependency. +- **Framework Adapters:** Features built-in adapters for web frameworks (Flask, FastAPI, Django, Tornado, Pyramid, and many more) and serverless environments (AWS Lambda, Google Cloud Functions). +- **Python Version:** Requires Python 3.7+ as defined in `pyproject.toml`. + +- **Repository**: +- **Documentation**: +- **PyPI**: +- **Current version**: defined in `slack_bolt/version.py` (referenced by `pyproject.toml` via `[tool.setuptools.dynamic]`) + +## Environment Setup + +A python virtual environment (`venv`) should be activated before running any commands. + +```bash +# Create a venv (first time only) +python -m venv .venv + +# Activate +source .venv/bin/activate + +# Install all dependencies +./scripts/install.sh +``` + +You can verify the venv is active by checking `echo $VIRTUAL_ENV`. If tools like `black`, `flake8`, `mypy` or `pytest` are not found, ask the user to activate the venv. + +## Common Commands + +### Testing + +Always use the project scripts instead of calling `pytest` directly: + +```bash +# Install all dependencies and run all tests (formats, lints, tests, typechecks) +./scripts/install_all_and_run_tests.sh + +# Run a single test file +./scripts/run_tests.sh tests/scenario_tests/test_app.py + +# Run a single test function +./scripts/run_tests.sh tests/scenario_tests/test_app.py::TestApp::test_name +``` + +### Formatting, Linting, Type Checking + +```bash +# Format (black, line-length=125) +./scripts/format.sh --no-install + +# Lint (flake8, line-length=125, ignores: F841,F821,W503,E402) +./scripts/lint.sh --no-install + +# Type check (mypy) +./scripts/run_mypy.sh --no-install +``` + +## Architecture + +### Request Processing Pipeline + +Incoming requests flow through a middleware chain before reaching listeners: + +1. **SSL Check** -> **Request Verification** (signature) -> **URL Verification** -> **Authorization** (token injection) -> **Ignoring Self Events** -> Custom middleware +2. **Listener Matching** -- `ListenerMatcher` implementations check if a listener should handle the request +3. **Listener Execution** -- listener-specific middleware runs, then `ack()` is called, then the handler executes + +For FaaS environments (`process_before_response=True`), long-running handlers execute as "lazy listeners" in a thread pool after the ack response is returned. + +### Core Abstractions + +- **`App` / `AsyncApp`** (`slack_bolt/app/`) -- Central class. Registers listeners via decorators (`@app.event()`, `@app.action()`, `@app.command()`, `@app.message()`, `@app.view()`, `@app.shortcut()`, `@app.options()`, `@app.function()`). Dispatches incoming requests through middleware to matching listeners. +- **`Middleware`** (`slack_bolt/middleware/`) -- Abstract base with `process(req, resp, next)`. Built-in: authorization, request verification, SSL check, URL verification, assistant, self-event ignoring. +- **`Listener`** (`slack_bolt/listener/`) -- Has matchers, middleware, and an ack/handler function. `CustomListener` is the main implementation. +- **`ListenerMatcher`** (`slack_bolt/listener_matcher/`) -- Determines if a listener handles a given request. Built-in matchers for events, actions, commands, messages (regex), shortcuts, views, options, functions. +- **`BoltContext`** (`slack_bolt/context/`) -- Dict-like object passed to listeners with `client`, `say()`, `ack()`, `respond()`, `complete()`, `fail()`, plus event metadata (`user_id`, `channel_id`, `team_id`, etc.). +- **`BoltRequest` / `BoltResponse`** (`slack_bolt/request/`, `slack_bolt/response/`) -- Request/response wrappers. Request has `mode` of "http" or "socket_mode". + +### Kwargs Injection + +Listeners receive arguments by parameter name. The framework inspects function signatures and injects matching args: `body`, `event`, `action`, `command`, `payload`, `context`, `client`, `ack`, `say`, `respond`, `logger`, `complete`, `fail`, `agent`, etc. Defined in `slack_bolt/kwargs_injection/args.py`. + +### Adapter System + +Each adapter in `slack_bolt/adapter/` converts between a web framework's request/response types and `BoltRequest`/`BoltResponse`. Adapters exist for: Flask, FastAPI, Django, Starlette, Sanic, Bottle, Tornado, CherryPy, Falcon, Pyramid, AWS Lambda, Google Cloud Functions, Socket Mode, WSGI, ASGI, and more. + +### Sync/Async Mirroring Pattern + +**This is the most important pattern in this codebase.** Almost every module has both a sync and async variant. When you modify one, you almost always must modify the other. + +**File naming convention:** Async files use the `async_` prefix alongside their sync counterpart: + +```text +slack_bolt/middleware/custom_middleware.py # sync +slack_bolt/middleware/async_custom_middleware.py # async + +slack_bolt/context/say/say.py # sync +slack_bolt/context/say/async_say.py # async + +slack_bolt/listener/custom_listener.py # sync +slack_bolt/listener/async_listener.py # async + +slack_bolt/adapter/fastapi/async_handler.py # async-only (no sync FastAPI adapter) +slack_bolt/adapter/flask/handler.py # sync-only (no async Flask adapter) +``` + +**Which modules come in sync/async pairs:** + +- `slack_bolt/app/` -- `app.py` / `async_app.py` +- `slack_bolt/middleware/` -- every middleware has an `async_` counterpart +- `slack_bolt/listener/` -- `listener.py` / `async_listener.py`, plus error/completion/start handlers +- `slack_bolt/listener_matcher/` -- `builtins.py` / `async_builtins.py` +- `slack_bolt/context/` -- each subdirectory (e.g., `say/`, `ack/`, `respond/`) has `async_` variants +- `slack_bolt/kwargs_injection/` -- `args.py` / `async_args.py`, `utils.py` / `async_utils.py` + +**Adapters are an exception:** Most adapters are sync-only or async-only depending on the framework. Async-native frameworks (FastAPI, Starlette, Sanic, Tornado, ASGI, Socket Mode) have `async_handler.py`. Sync-only frameworks (Flask, Django, Bottle, CherryPy, Falcon, Pyramid, AWS Lambda, Google Cloud Functions, WSGI) have `handler.py`. + +### AI Agents & Assistants + +`BoltAgent` (`slack_bolt/agent/`) provides `chat_stream()`, `set_status()`, and `set_suggested_prompts()` for AI-powered agents. `Assistant` middleware (`slack_bolt/middleware/assistant/`) handles assistant thread events. + +## Key Development Patterns + +### Adding or Modifying Middleware + +1. Implement the sync version in `slack_bolt/middleware/` (subclass `Middleware`, implement `process()`) +2. Implement the async version with `async_` prefix (subclass `AsyncMiddleware`, implement `async_process()`) +3. Export built-in middleware from `slack_bolt/middleware/__init__.py` (sync) and `async_builtins.py` (async) + +### Adding a Context Utility + +Each context utility lives in its own subdirectory under `slack_bolt/context/`: + +```text +slack_bolt/context/my_util/ + __init__.py + my_util.py # sync implementation + async_my_util.py # async implementation + internals.py # shared logic (optional) +``` + +Then wire it into `BoltContext` (`slack_bolt/context/context.py`) and `AsyncBoltContext` (`slack_bolt/context/async_context.py`). + +### Adding a New Adapter + +1. Create `slack_bolt/adapter//` +2. Add `__init__.py` and `handler.py` (or `async_handler.py` for async frameworks) +3. The handler converts the framework's request to `BoltRequest`, calls `app.dispatch()`, and converts `BoltResponse` back +4. Add the framework to `requirements/adapter.txt` with version constraints +5. Add adapter tests in `tests/adapter_tests/` (or `tests/adapter_tests_async/`) + +### Adding a Kwargs-Injectable Argument + +1. Add the new arg to `slack_bolt/kwargs_injection/args.py` and `async_args.py` +2. Update the `Args` class with the new property +3. Populate the arg in the appropriate context or listener setup code + +## Dependencies + +The core package has a **single required runtime dependency**: `slack_sdk` (defined in `pyproject.toml`). Do not add runtime dependencies. + +**`requirements/` directory structure:** + +- `async.txt` -- async runtime deps (`aiohttp`, `websockets`) +- `adapter.txt` -- all framework adapter deps (Flask, Django, FastAPI, etc.) +- `testing.txt` -- test runner deps (`pytest`, `pytest-asyncio`, includes `async.txt`) +- `testing_without_asyncio.txt` -- test deps without async (`pytest`, `pytest-cov`) +- `adapter_testing.txt` -- adapter-specific test deps (`moto`, `boddle`, `sanic-testing`) +- `tools.txt` -- dev tools (`mypy`, `flake8`, `black`) + +When adding a new dependency: add it to the appropriate `requirements/*.txt` file with version constraints, never to `pyproject.toml` `dependencies` (unless it's a core runtime dep, which is very rare). + +## Test Organization + +- `tests/scenario_tests/` -- Integration-style tests with realistic Slack payloads +- `tests/slack_bolt/` -- Unit tests mirroring the source structure +- `tests/adapter_tests/` and `tests/adapter_tests_async/` -- Framework adapter tests +- `tests/mock_web_api_server/` -- Mock Slack API server used by tests +- Async test variants use `_async` suffix directories + +**Where to put new tests:** Mirror the source structure. For `slack_bolt/middleware/foo.py`, add tests in `tests/slack_bolt/middleware/test_foo.py`. For async variants, use the `_async` suffix directory or file naming pattern. Adapter tests go in `tests/adapter_tests/` (sync) or `tests/adapter_tests_async/` (async). + +**Mock server:** Many tests use `tests/mock_web_api_server/` to simulate Slack API responses. Look at existing tests for usage patterns rather than making real API calls. + +## Code Style + +- **Black** formatter configured in `pyproject.toml` (line-length=125) +- **Flake8** linter configured in `.flake8` (line-length=125, ignores: F841,F821,W503,E402) +- **MyPy** configured in `pyproject.toml` +- **pytest** configured in `pyproject.toml` + +## GitHub & CI/CD + +- `.github/` -- GitHub-specific configuration and documentation +- `.github/workflows/` -- Continuous integration pipeline definitions that run on GitHub Actions +- `.github/maintainers_guide.md` -- Maintainer workflows and release process diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 000000000..96159c63c --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Installs all dependencies of the project +# ./scripts/install.sh + +script_dir=`dirname $0` +cd ${script_dir}/.. +rm -rf ./slack_bolt.egg-info + +# Update pip to prevent warnings +pip install -U pip + +# The package causes a conflict with moto +pip uninstall python-lambda + +pip install -U -e . +pip install -U -r requirements/testing.txt +pip install -U -r requirements/adapter.txt +pip install -U -r requirements/adapter_testing.txt +pip install -U -r requirements/tools.txt + +# To avoid errors due to the old versions of click forced by Chalice +pip install -U pip click diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index 2bb9a2050..939e71ffd 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -5,31 +5,19 @@ script_dir=`dirname $0` cd ${script_dir}/.. -rm -rf ./slack_bolt.egg-info -# Update pip to prevent warnings -pip install -U pip +test_target="${1:-tests/}" -# The package causes a conflict with moto -pip uninstall python-lambda +# keep in sync with LATEST_SUPPORTED_PY in .github/workflows/ci-build.yml +LATEST_SUPPORTED_PY="3.14" +current_py=$(python --version | sed -E 's/Python ([0-9]+\.[0-9]+).*/\1/') -test_target="$1" +./scripts/install.sh -pip install -U -e . -pip install -U -r requirements/testing.txt -pip install -U -r requirements/adapter.txt -pip install -U -r requirements/adapter_testing.txt -pip install -U -r requirements/tools.txt -# To avoid errors due to the old versions of click forced by Chalice -pip install -U pip click +./scripts/format.sh --no-install +./scripts/lint.sh --no-install +pytest $test_target -if [[ $test_target != "" ]] -then - ./scripts/format.sh --no-install - pytest $1 -else - ./scripts/format.sh --no-install - ./scripts/lint.sh --no-install - pytest +if [[ "$current_py" == "$LATEST_SUPPORTED_PY" ]]; then ./scripts/run_mypy.sh --no-install fi diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index cdac3c71c..d4dc767e3 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -6,13 +6,7 @@ script_dir=`dirname $0` cd ${script_dir}/.. -test_target="$1" +test_target="${1:-tests/}" ./scripts/format.sh --no-install - -if [[ $test_target != "" ]] -then - pytest -vv $1 -else - pytest -fi +pytest -vv $test_target diff --git a/slack_bolt/warning/__init__.py b/slack_bolt/warning/__init__.py index df71b812f..4991f4cd9 100644 --- a/slack_bolt/warning/__init__.py +++ b/slack_bolt/warning/__init__.py @@ -3,4 +3,5 @@ class ExperimentalWarning(FutureWarning): """Warning for features that are still in experimental phase.""" + pass From 0fc53805a262c21d80057b41b07552f1d1ac072d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:19:30 -0700 Subject: [PATCH 844/865] chore(deps): bump black from 25.1.0 to 26.3.1 in /requirements (#1457) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/tools.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/tools.txt b/requirements/tools.txt index 7609eb52e..dd13bd614 100644 --- a/requirements/tools.txt +++ b/requirements/tools.txt @@ -1,3 +1,3 @@ mypy==1.19.1 flake8==7.3.0 -black==25.1.0 +black==26.3.1 From 4d15431ca824963017e6e8b5e4a5b3cc90232d53 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 12 Mar 2026 14:55:40 -0700 Subject: [PATCH 845/865] chore: improve AGENTS.md (#1458) --- AGENTS.md | 156 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 100 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 537cabfcf..57f2fa588 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,8 @@ Slack Bolt for Python -- a framework for building Slack apps in Python. ## Environment Setup +You can verify the venv is active by checking `echo $VIRTUAL_ENV`. If tools like `black`, `flake8`, `mypy` or `pytest` are not found, ask the user to activate the venv. + A python virtual environment (`venv`) should be activated before running any commands. ```bash @@ -29,18 +31,30 @@ source .venv/bin/activate ./scripts/install.sh ``` -You can verify the venv is active by checking `echo $VIRTUAL_ENV`. If tools like `black`, `flake8`, `mypy` or `pytest` are not found, ask the user to activate the venv. - ## Common Commands -### Testing +### Pre-submission Checklist -Always use the project scripts instead of calling `pytest` directly: +Before considering any work complete, you MUST run these commands in order and confirm they all pass: + +```bash +./scripts/format.sh --no-install # 1. Format +./scripts/lint.sh --no-install # 2. Lint +./scripts/run_tests.sh # 3. Run relevant tests (see Testing below) +./scripts/run_mypy.sh --no-install # 4. Type check +``` + +To run everything at once (installs deps + formats + lints + tests + typechecks): ```bash -# Install all dependencies and run all tests (formats, lints, tests, typechecks) ./scripts/install_all_and_run_tests.sh +``` +### Testing + +Always use the project scripts instead of calling `pytest` directly: + +```bash # Run a single test file ./scripts/run_tests.sh tests/scenario_tests/test_app.py @@ -51,16 +65,70 @@ Always use the project scripts instead of calling `pytest` directly: ### Formatting, Linting, Type Checking ```bash -# Format (black, line-length=125) +# Format -- Black, configured in pyproject.toml ./scripts/format.sh --no-install -# Lint (flake8, line-length=125, ignores: F841,F821,W503,E402) +# Lint -- Flake8, configured in .flake8 ./scripts/lint.sh --no-install -# Type check (mypy) +# Type check -- mypy, configured in pyproject.toml ./scripts/run_mypy.sh --no-install ``` +## Critical Conventions + +### Sync/Async Mirroring Rule + +**When modifying any sync module, you MUST also update the corresponding async module (and vice versa).** This is the most important convention in this codebase. + +Almost every module has both a sync and async variant. Async files use the `async_` prefix alongside their sync counterpart: + +```text +slack_bolt/middleware/custom_middleware.py # sync +slack_bolt/middleware/async_custom_middleware.py # async + +slack_bolt/context/say/say.py # sync +slack_bolt/context/say/async_say.py # async + +slack_bolt/listener/custom_listener.py # sync +slack_bolt/listener/async_listener.py # async +``` + +**Modules that come in sync/async pairs:** + +- `slack_bolt/app/` -- `app.py` / `async_app.py` +- `slack_bolt/middleware/` -- every middleware has an `async_` counterpart +- `slack_bolt/listener/` -- `listener.py` / `async_listener.py`, plus error/completion/start handlers +- `slack_bolt/listener_matcher/` -- `builtins.py` / `async_builtins.py` +- `slack_bolt/context/` -- each subdirectory (e.g., `say/`, `ack/`, `respond/`) has `async_` variants +- `slack_bolt/kwargs_injection/` -- `args.py` / `async_args.py`, `utils.py` / `async_utils.py` + +**Adapters are an exception:** Most adapters are sync-only or async-only depending on the framework. Async-native frameworks (FastAPI, Starlette, Sanic, Tornado, ASGI, Socket Mode) have `async_handler.py`. Sync-only frameworks (Flask, Django, Bottle, CherryPy, Falcon, Pyramid, AWS Lambda, Google Cloud Functions, WSGI) have `handler.py`. + +### Prefer the Middleware Pattern + +Middleware is the project's preferred approach for cross-cutting concerns. Before adding logic to individual listeners or utility functions, consider whether it belongs as a built-in middleware in the framework. + +**When to add built-in middleware:** + +- Cross-cutting concerns that apply to many or all requests (logging, metrics, observability) +- Request validation, transformation, or enrichment +- Authorization extensions beyond the built-in `SingleTeamAuthorization`/`MultiTeamsAuthorization` +- Feature-level request handling (the `Assistant` middleware in `slack_bolt/middleware/assistant/assistant.py` is the canonical example -- it intercepts assistant thread events and dispatches them to registered sub-listeners) + +**How to add built-in middleware:** + +1. Subclass `Middleware` (sync) and implement `process(self, *, req, resp, next)`. Call `next()` to continue the chain. +2. Subclass `AsyncMiddleware` (async) and implement `async_process(self, *, req, resp, next)`. Call `await next()` to continue. +3. Export from `slack_bolt/middleware/__init__.py` (sync) and `slack_bolt/middleware/async_builtins.py` (async). +4. Register the middleware in `App.__init__()` (`slack_bolt/app/app.py`) and `AsyncApp.__init__()` (`slack_bolt/app/async_app.py`) where the default middleware chain is assembled. + +**Canonical example:** `AttachingFunctionToken` (`slack_bolt/middleware/attaching_function_token/`) is a good small middleware to follow -- it has a clean sync/async pair, a focused `process()` method, and is properly exported and registered in the app's middleware chain. + +### Single Runtime Dependency Rule + +The core package depends ONLY on `slack_sdk` (defined in `pyproject.toml`). Never add runtime dependencies to `pyproject.toml`. Additional dependencies go in the appropriate `requirements/*.txt` file. + ## Architecture ### Request Processing Pipeline @@ -90,49 +158,12 @@ Listeners receive arguments by parameter name. The framework inspects function s Each adapter in `slack_bolt/adapter/` converts between a web framework's request/response types and `BoltRequest`/`BoltResponse`. Adapters exist for: Flask, FastAPI, Django, Starlette, Sanic, Bottle, Tornado, CherryPy, Falcon, Pyramid, AWS Lambda, Google Cloud Functions, Socket Mode, WSGI, ASGI, and more. -### Sync/Async Mirroring Pattern - -**This is the most important pattern in this codebase.** Almost every module has both a sync and async variant. When you modify one, you almost always must modify the other. - -**File naming convention:** Async files use the `async_` prefix alongside their sync counterpart: - -```text -slack_bolt/middleware/custom_middleware.py # sync -slack_bolt/middleware/async_custom_middleware.py # async - -slack_bolt/context/say/say.py # sync -slack_bolt/context/say/async_say.py # async - -slack_bolt/listener/custom_listener.py # sync -slack_bolt/listener/async_listener.py # async - -slack_bolt/adapter/fastapi/async_handler.py # async-only (no sync FastAPI adapter) -slack_bolt/adapter/flask/handler.py # sync-only (no async Flask adapter) -``` - -**Which modules come in sync/async pairs:** - -- `slack_bolt/app/` -- `app.py` / `async_app.py` -- `slack_bolt/middleware/` -- every middleware has an `async_` counterpart -- `slack_bolt/listener/` -- `listener.py` / `async_listener.py`, plus error/completion/start handlers -- `slack_bolt/listener_matcher/` -- `builtins.py` / `async_builtins.py` -- `slack_bolt/context/` -- each subdirectory (e.g., `say/`, `ack/`, `respond/`) has `async_` variants -- `slack_bolt/kwargs_injection/` -- `args.py` / `async_args.py`, `utils.py` / `async_utils.py` - -**Adapters are an exception:** Most adapters are sync-only or async-only depending on the framework. Async-native frameworks (FastAPI, Starlette, Sanic, Tornado, ASGI, Socket Mode) have `async_handler.py`. Sync-only frameworks (Flask, Django, Bottle, CherryPy, Falcon, Pyramid, AWS Lambda, Google Cloud Functions, WSGI) have `handler.py`. - ### AI Agents & Assistants `BoltAgent` (`slack_bolt/agent/`) provides `chat_stream()`, `set_status()`, and `set_suggested_prompts()` for AI-powered agents. `Assistant` middleware (`slack_bolt/middleware/assistant/`) handles assistant thread events. ## Key Development Patterns -### Adding or Modifying Middleware - -1. Implement the sync version in `slack_bolt/middleware/` (subclass `Middleware`, implement `process()`) -2. Implement the async version with `async_` prefix (subclass `AsyncMiddleware`, implement `async_process()`) -3. Export built-in middleware from `slack_bolt/middleware/__init__.py` (sync) and `async_builtins.py` (async) - ### Adding a Context Utility Each context utility lives in its own subdirectory under `slack_bolt/context/`: @@ -153,7 +184,7 @@ Then wire it into `BoltContext` (`slack_bolt/context/context.py`) and `AsyncBolt 2. Add `__init__.py` and `handler.py` (or `async_handler.py` for async frameworks) 3. The handler converts the framework's request to `BoltRequest`, calls `app.dispatch()`, and converts `BoltResponse` back 4. Add the framework to `requirements/adapter.txt` with version constraints -5. Add adapter tests in `tests/adapter_tests/` (or `tests/adapter_tests_async/`) +5. Add adapter tests in `tests/adapter_tests/` (sync) or `tests/adapter_tests_async/` (async) ### Adding a Kwargs-Injectable Argument @@ -161,6 +192,13 @@ Then wire it into `BoltContext` (`slack_bolt/context/context.py`) and `AsyncBolt 2. Update the `Args` class with the new property 3. Populate the arg in the appropriate context or listener setup code +## Security Considerations + +- **Request Verification:** The built-in `RequestVerification` middleware validates `x-slack-signature` and `x-slack-request-timestamp` on every incoming HTTP request. Never disable this in production. It is automatically skipped for `socket_mode` requests. +- **Tokens & Secrets:** `SLACK_SIGNING_SECRET` and `SLACK_BOT_TOKEN` must come from environment variables. Never hardcode or commit secrets. +- **Authorization Middleware:** `SingleTeamAuthorization` and `MultiTeamsAuthorization` verify tokens and inject an authorized `WebClient` into the context. Do not bypass these. +- **Tests:** Always use mock servers (`tests/mock_web_api_server/`) and dummy values. Never use real tokens in tests. + ## Dependencies The core package has a **single required runtime dependency**: `slack_sdk` (defined in `pyproject.toml`). Do not add runtime dependencies. @@ -176,7 +214,9 @@ The core package has a **single required runtime dependency**: `slack_sdk` (defi When adding a new dependency: add it to the appropriate `requirements/*.txt` file with version constraints, never to `pyproject.toml` `dependencies` (unless it's a core runtime dep, which is very rare). -## Test Organization +## Test Organization and CI + +### Directory Structure - `tests/scenario_tests/` -- Integration-style tests with realistic Slack payloads - `tests/slack_bolt/` -- Unit tests mirroring the source structure @@ -188,15 +228,19 @@ When adding a new dependency: add it to the appropriate `requirements/*.txt` fil **Mock server:** Many tests use `tests/mock_web_api_server/` to simulate Slack API responses. Look at existing tests for usage patterns rather than making real API calls. -## Code Style +### CI Pipeline + +GitHub Actions (`.github/workflows/ci-build.yml`) runs on every push to `main` and every PR: -- **Black** formatter configured in `pyproject.toml` (line-length=125) -- **Flake8** linter configured in `.flake8` (line-length=125, ignores: F841,F821,W503,E402) -- **MyPy** configured in `pyproject.toml` -- **pytest** configured in `pyproject.toml` +- **Lint** -- `./scripts/lint.sh` on latest Python +- **Typecheck** -- `./scripts/run_mypy.sh` on latest Python +- **Unit tests** -- full test suite across Python 3.7--3.14 matrix +- **Code coverage** -- uploaded to Codecov -## GitHub & CI/CD +## PR and Commit Guidelines -- `.github/` -- GitHub-specific configuration and documentation -- `.github/workflows/` -- Continuous integration pipeline definitions that run on GitHub Actions -- `.github/maintainers_guide.md` -- Maintainer workflows and release process +- PRs target the `main` branch +- You MUST run `./scripts/install_all_and_run_tests.sh` before submitting +- PR template (`.github/pull_request_template.md`) requires: Summary, Testing steps, Category checkboxes (`App`, `AsyncApp`, Adapters, Docs, Others) +- Requirements: CLA signed, test suite passes, code review approval +- Commits should be atomic with descriptive messages. Reference related issue numbers. From 898e0b8c987de9df42c89640e0bcbf4acd7a2e56 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 16 Mar 2026 11:35:03 -0700 Subject: [PATCH 846/865] chore: format project to latest fomatter version (#1460) --- slack_bolt/app/app.py | 4 +- slack_bolt/app/async_app.py | 6 +- slack_bolt/middleware/assistant/assistant.py | 18 ++-- .../middleware/assistant/async_assistant.py | 26 ++--- slack_bolt/request/payload_utils.py | 1 - tests/scenario_tests/test_view_submission.py | 1 - .../test_view_submission.py | 1 - .../logger/test_unmatched_suggestions.py | 98 ++++++------------- .../logger/test_unmatched_suggestions.py | 98 ++++++------------- 9 files changed, 83 insertions(+), 170 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 5a7f32917..fcf5bb788 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -1401,7 +1401,7 @@ def _init_context(self, req: BoltRequest): # For AI Agents & Assistants if is_assistant_event(req.body): assistant = AssistantUtilities( - payload=to_event(req.body), # type:ignore[arg-type] + payload=to_event(req.body), # type: ignore[arg-type] context=req.context, thread_context_store=self._assistant_thread_context_store, ) @@ -1457,7 +1457,7 @@ def _register_listener( CustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, # type:ignore[arg-type] + lazy_functions=functions, # type: ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 39f3c3c0e..62c491084 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -616,7 +616,7 @@ async def async_middleware_next(): self._framework_logger.debug(debug_checking_listener(listener_name)) if await listener.async_matches(req=req, resp=resp): # type: ignore[arg-type] # run all the middleware attached to this listener first - (middleware_resp, next_was_not_called) = await listener.run_async_middleware( + middleware_resp, next_was_not_called = await listener.run_async_middleware( req=req, resp=resp # type: ignore[arg-type] ) if next_was_not_called: @@ -1434,7 +1434,7 @@ def _init_context(self, req: AsyncBoltRequest): # For AI Agents & Assistants if is_assistant_event(req.body): assistant = AsyncAssistantUtilities( - payload=to_event(req.body), # type:ignore[arg-type] + payload=to_event(req.body), # type: ignore[arg-type] context=req.context, thread_context_store=self._assistant_thread_context_store, ) @@ -1495,7 +1495,7 @@ def _register_listener( AsyncCustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, # type:ignore[arg-type] + lazy_functions=functions, # type: ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, diff --git a/slack_bolt/middleware/assistant/assistant.py b/slack_bolt/middleware/assistant/assistant.py index beac71bca..d61386105 100644 --- a/slack_bolt/middleware/assistant/assistant.py +++ b/slack_bolt/middleware/assistant/assistant.py @@ -67,7 +67,7 @@ def thread_started( self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -106,7 +106,7 @@ def user_message( self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -145,7 +145,7 @@ def bot_message( self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -184,7 +184,7 @@ def thread_context_changed( self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -214,13 +214,13 @@ def _merge_matchers( ): return [CustomListenerMatcher(app_name=self.app_name, func=primary_matcher)] + ( custom_matchers or [] - ) # type:ignore[operator] + ) # type: ignore[operator] @staticmethod def default_thread_context_changed(save_thread_context: SaveThreadContext, payload: dict): save_thread_context(payload["assistant_thread"]["context"]) - def process( # type:ignore[return] + def process( # type: ignore[return] self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse] ) -> Optional[BoltResponse]: if self._thread_context_changed_listeners is None: @@ -255,8 +255,8 @@ def build_listener( middleware: Optional[List[Middleware]] = None, base_logger: Optional[Logger] = None, ) -> Listener: - if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] - listener_or_functions = [listener_or_functions] # type:ignore[list-item] + if isinstance(listener_or_functions, Callable): # type: ignore[arg-type] + listener_or_functions = [listener_or_functions] # type: ignore[list-item] if isinstance(listener_or_functions, Listener): return listener_or_functions @@ -270,7 +270,7 @@ def build_listener( for matcher in matchers: if isinstance(matcher, ListenerMatcher): listener_matchers.append(matcher) - elif isinstance(matcher, Callable): # type:ignore[arg-type] + elif isinstance(matcher, Callable): # type: ignore[arg-type] listener_matchers.append( build_listener_matcher( func=matcher, diff --git a/slack_bolt/middleware/assistant/async_assistant.py b/slack_bolt/middleware/assistant/async_assistant.py index 2fdd828d7..ae82595a8 100644 --- a/slack_bolt/middleware/assistant/async_assistant.py +++ b/slack_bolt/middleware/assistant/async_assistant.py @@ -63,7 +63,7 @@ def thread_started( func=is_assistant_thread_started_event, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -72,7 +72,7 @@ def thread_started( self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -109,7 +109,7 @@ def user_message( func=is_user_message_event_in_assistant_thread, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -118,7 +118,7 @@ def user_message( self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -155,7 +155,7 @@ def bot_message( func=is_bot_message_event_in_assistant_thread, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -164,7 +164,7 @@ def bot_message( self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -201,7 +201,7 @@ def thread_context_changed( func=is_assistant_thread_context_changed_event, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -210,7 +210,7 @@ def thread_context_changed( self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -238,14 +238,14 @@ def _merge_matchers( primary_matcher: Union[Callable[..., bool], AsyncListenerMatcher], custom_matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]], ): - return [primary_matcher] + (custom_matchers or []) # type:ignore[operator] + return [primary_matcher] + (custom_matchers or []) # type: ignore[operator] @staticmethod async def default_thread_context_changed(save_thread_context: AsyncSaveThreadContext, payload: dict): new_context: dict = payload["assistant_thread"]["context"] await save_thread_context(new_context) - async def async_process( # type:ignore[return] + async def async_process( # type: ignore[return] self, *, req: AsyncBoltRequest, @@ -284,8 +284,8 @@ def build_listener( middleware: Optional[List[AsyncMiddleware]] = None, base_logger: Optional[Logger] = None, ) -> AsyncListener: - if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] - listener_or_functions = [listener_or_functions] # type:ignore[list-item] + if isinstance(listener_or_functions, Callable): # type: ignore[arg-type] + listener_or_functions = [listener_or_functions] # type: ignore[list-item] if isinstance(listener_or_functions, AsyncListener): return listener_or_functions @@ -302,7 +302,7 @@ def build_listener( else: listener_matchers.append( build_listener_matcher( - func=matcher, # type:ignore[arg-type] + func=matcher, # type: ignore[arg-type] asyncio=True, base_logger=base_logger, ) diff --git a/slack_bolt/request/payload_utils.py b/slack_bolt/request/payload_utils.py index c1016c65d..1ebf70d4f 100644 --- a/slack_bolt/request/payload_utils.py +++ b/slack_bolt/request/payload_utils.py @@ -1,6 +1,5 @@ from typing import Dict, Any, Optional - # ------------------------------------------ # Public Utilities # ------------------------------------------ diff --git a/tests/scenario_tests/test_view_submission.py b/tests/scenario_tests/test_view_submission.py index b0eb58212..0f5b23f85 100644 --- a/tests/scenario_tests/test_view_submission.py +++ b/tests/scenario_tests/test_view_submission.py @@ -14,7 +14,6 @@ ) from tests.utils import remove_os_env_temporarily, restore_os_env - body = { "type": "view_submission", "team": { diff --git a/tests/scenario_tests_async/test_view_submission.py b/tests/scenario_tests_async/test_view_submission.py index 49a6e8fc5..6511243fa 100644 --- a/tests/scenario_tests_async/test_view_submission.py +++ b/tests/scenario_tests_async/test_view_submission.py @@ -15,7 +15,6 @@ ) from tests.utils import remove_os_env_temporarily, restore_os_env - body = { "type": "view_submission", "team": { diff --git a/tests/slack_bolt/logger/test_unmatched_suggestions.py b/tests/slack_bolt/logger/test_unmatched_suggestions.py index 2c0c82b99..b470fa061 100644 --- a/tests/slack_bolt/logger/test_unmatched_suggestions.py +++ b/tests/slack_bolt/logger/test_unmatched_suggestions.py @@ -22,8 +22,7 @@ def test_block_actions(self): "block_id": "b", "action_id": "action-id-value", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -31,9 +30,7 @@ def test_block_actions(self): def handle_some_action(ack, body, logger): ack() logger.info(body) -""" - == message - ) +""" == message def test_attachment_actions(self): req: BoltRequest = BoltRequest(body=attachment_actions, mode="socket_mode") @@ -49,8 +46,7 @@ def test_attachment_actions(self): } ], } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -58,9 +54,7 @@ def test_attachment_actions(self): def handle_some_action(ack, body, logger): ack() logger.info(body) -""" - == message - ) +""" == message def test_app_mention_event(self): req: BoltRequest = BoltRequest(body=app_mention_event, mode="socket_mode") @@ -69,17 +63,14 @@ def test_app_mention_event(self): "event": {"type": "app_mention"}, } message = warning_unhandled_request(req) - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @app.event("app_mention") def handle_app_mention_events(body, logger): logger.info(body) -""" - == message - ) +""" == message def test_function_event(self): req: BoltRequest = BoltRequest(body=function_event, mode="socket_mode") @@ -88,8 +79,7 @@ def test_function_event(self): "event": {"type": "function_executed"}, } message = warning_unhandled_request(req) - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -104,9 +94,7 @@ def handle_some_function(ack, body, complete, fail, logger): except Exception as e: error = f"Failed to handle a function request (error: {{e}})" fail(error=error) -""" - == message - ) +""" == message def test_commands(self): req: BoltRequest = BoltRequest(body=slash_command, mode="socket_mode") @@ -115,8 +103,7 @@ def test_commands(self): "type": None, "command": "/start-conv", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -124,9 +111,7 @@ def test_commands(self): def handle_some_command(ack, body, logger): ack() logger.info(body) -""" - == message - ) +""" == message def test_shortcut(self): req: BoltRequest = BoltRequest(body=global_shortcut, mode="socket_mode") @@ -135,8 +120,7 @@ def test_shortcut(self): "type": "shortcut", "callback_id": "test-shortcut", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -144,9 +128,7 @@ def test_shortcut(self): def handle_shortcuts(ack, body, logger): ack() logger.info(body) -""" - == message - ) +""" == message req: BoltRequest = BoltRequest(body=message_shortcut, mode="socket_mode") message = warning_unhandled_request(req) @@ -154,8 +136,7 @@ def handle_shortcuts(ack, body, logger): "type": "message_action", "callback_id": "test-shortcut", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -163,9 +144,7 @@ def handle_shortcuts(ack, body, logger): def handle_shortcuts(ack, body, logger): ack() logger.info(body) -""" - == message - ) +""" == message def test_view(self): req: BoltRequest = BoltRequest(body=view_submission, mode="socket_mode") @@ -174,8 +153,7 @@ def test_view(self): "type": "view_submission", "view": {"type": "modal", "callback_id": "view-id"}, } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -183,9 +161,7 @@ def test_view(self): def handle_view_submission_events(ack, body, logger): ack() logger.info(body) -""" - == message - ) +""" == message req: BoltRequest = BoltRequest(body=view_closed, mode="socket_mode") message = warning_unhandled_request(req) @@ -193,8 +169,7 @@ def handle_view_submission_events(ack, body, logger): "type": "view_closed", "view": {"type": "modal", "callback_id": "view-id"}, } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -202,9 +177,7 @@ def handle_view_submission_events(ack, body, logger): def handle_view_closed_events(ack, body, logger): ack() logger.info(body) -""" - == message - ) +""" == message def test_block_suggestion(self): req: BoltRequest = BoltRequest(body=block_suggestion, mode="socket_mode") @@ -216,17 +189,14 @@ def test_block_suggestion(self): "action_id": "the-id", "value": "search word", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @app.options("the-id") def handle_some_options(ack): ack(options=[ ... ]) -""" - == message - ) +""" == message def test_dialog_suggestion(self): req: BoltRequest = BoltRequest(body=dialog_suggestion, mode="socket_mode") @@ -236,17 +206,14 @@ def test_dialog_suggestion(self): "callback_id": "the-id", "value": "search keyword", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @app.options({{"type": "dialog_suggestion", "callback_id": "the-id"}}) def handle_some_options(ack): ack(options=[ ... ]) -""" - == message - ) +""" == message def test_step(self): req: BoltRequest = BoltRequest(body=step_edit_payload, mode="socket_mode") @@ -255,8 +222,7 @@ def test_step(self): "type": "workflow_step_edit", "callback_id": "copy_review", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -269,17 +235,14 @@ def test_step(self): ) # Pass Step to set up listeners app.step(ws) -""" - == message - ) +""" == message req: BoltRequest = BoltRequest(body=step_save_payload, mode="socket_mode") message = warning_unhandled_request(req) filtered_body = { "type": "view_submission", "view": {"type": "workflow_step", "callback_id": "copy_review"}, } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -292,17 +255,14 @@ def test_step(self): ) # Pass Step to set up listeners app.step(ws) -""" - == message - ) +""" == message req: BoltRequest = BoltRequest(body=step_execute_payload, mode="socket_mode") message = warning_unhandled_request(req) filtered_body = { "type": "event_callback", "event": {"type": "workflow_step_execute"}, } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -315,9 +275,7 @@ def test_step(self): ) # Pass Step to set up listeners app.step(ws) -""" - == message - ) +""" == message block_actions = { diff --git a/tests/slack_bolt_async/logger/test_unmatched_suggestions.py b/tests/slack_bolt_async/logger/test_unmatched_suggestions.py index 93343c4a2..d8c659892 100644 --- a/tests/slack_bolt_async/logger/test_unmatched_suggestions.py +++ b/tests/slack_bolt_async/logger/test_unmatched_suggestions.py @@ -22,8 +22,7 @@ def test_block_actions(self): "block_id": "b", "action_id": "action-id-value", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -31,9 +30,7 @@ def test_block_actions(self): async def handle_some_action(ack, body, logger): await ack() logger.info(body) -""" - == message - ) +""" == message def test_attachment_actions(self): req: AsyncBoltRequest = AsyncBoltRequest(body=attachment_actions, mode="socket_mode") @@ -49,8 +46,7 @@ def test_attachment_actions(self): } ], } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -58,9 +54,7 @@ def test_attachment_actions(self): async def handle_some_action(ack, body, logger): await ack() logger.info(body) -""" - == message - ) +""" == message def test_app_mention_event(self): req: AsyncBoltRequest = AsyncBoltRequest(body=app_mention_event, mode="socket_mode") @@ -69,17 +63,14 @@ def test_app_mention_event(self): "event": {"type": "app_mention"}, } message = warning_unhandled_request(req) - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @app.event("app_mention") async def handle_app_mention_events(body, logger): logger.info(body) -""" - == message - ) +""" == message def test_function_event(self): req: AsyncBoltRequest = AsyncBoltRequest(body=function_event, mode="socket_mode") @@ -88,8 +79,7 @@ def test_function_event(self): "event": {"type": "function_executed"}, } message = warning_unhandled_request(req) - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -104,9 +94,7 @@ async def handle_some_function(ack, body, complete, fail, logger): except Exception as e: error = f"Failed to handle a function request (error: {{e}})" await fail(error=error) -""" - == message - ) +""" == message def test_commands(self): req: AsyncBoltRequest = AsyncBoltRequest(body=slash_command, mode="socket_mode") @@ -115,8 +103,7 @@ def test_commands(self): "type": None, "command": "/start-conv", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -124,9 +111,7 @@ def test_commands(self): async def handle_some_command(ack, body, logger): await ack() logger.info(body) -""" - == message - ) +""" == message def test_shortcut(self): req: AsyncBoltRequest = AsyncBoltRequest(body=global_shortcut, mode="socket_mode") @@ -135,8 +120,7 @@ def test_shortcut(self): "type": "shortcut", "callback_id": "test-shortcut", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -144,9 +128,7 @@ def test_shortcut(self): async def handle_shortcuts(ack, body, logger): await ack() logger.info(body) -""" - == message - ) +""" == message req: AsyncBoltRequest = AsyncBoltRequest(body=message_shortcut, mode="socket_mode") message = warning_unhandled_request(req) @@ -154,8 +136,7 @@ async def handle_shortcuts(ack, body, logger): "type": "message_action", "callback_id": "test-shortcut", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -163,9 +144,7 @@ async def handle_shortcuts(ack, body, logger): async def handle_shortcuts(ack, body, logger): await ack() logger.info(body) -""" - == message - ) +""" == message def test_view(self): req: AsyncBoltRequest = AsyncBoltRequest(body=view_submission, mode="socket_mode") @@ -174,8 +153,7 @@ def test_view(self): "type": "view_submission", "view": {"type": "modal", "callback_id": "view-id"}, } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -183,9 +161,7 @@ def test_view(self): async def handle_view_submission_events(ack, body, logger): await ack() logger.info(body) -""" - == message - ) +""" == message req: AsyncBoltRequest = AsyncBoltRequest(body=view_closed, mode="socket_mode") message = warning_unhandled_request(req) @@ -193,8 +169,7 @@ async def handle_view_submission_events(ack, body, logger): "type": "view_closed", "view": {"type": "modal", "callback_id": "view-id"}, } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -202,9 +177,7 @@ async def handle_view_submission_events(ack, body, logger): async def handle_view_closed_events(ack, body, logger): await ack() logger.info(body) -""" - == message - ) +""" == message def test_block_suggestion(self): req: AsyncBoltRequest = AsyncBoltRequest(body=block_suggestion, mode="socket_mode") @@ -216,17 +189,14 @@ def test_block_suggestion(self): "action_id": "the-id", "value": "search word", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @app.options("the-id") async def handle_some_options(ack): await ack(options=[ ... ]) -""" - == message - ) +""" == message def test_dialog_suggestion(self): req: AsyncBoltRequest = AsyncBoltRequest(body=dialog_suggestion, mode="socket_mode") @@ -236,17 +206,14 @@ def test_dialog_suggestion(self): "callback_id": "the-id", "value": "search keyword", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @app.options({{"type": "dialog_suggestion", "callback_id": "the-id"}}) async def handle_some_options(ack): await ack(options=[ ... ]) -""" - == message - ) +""" == message def test_step(self): req: AsyncBoltRequest = AsyncBoltRequest(body=step_edit_payload, mode="socket_mode") @@ -255,8 +222,7 @@ def test_step(self): "type": "workflow_step_edit", "callback_id": "copy_review", } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -269,17 +235,14 @@ def test_step(self): ) # Pass Step to set up listeners app.step(ws) -""" - == message - ) +""" == message req: AsyncBoltRequest = AsyncBoltRequest(body=step_save_payload, mode="socket_mode") message = warning_unhandled_request(req) filtered_body = { "type": "view_submission", "view": {"type": "workflow_step", "callback_id": "copy_review"}, } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -292,17 +255,14 @@ def test_step(self): ) # Pass Step to set up listeners app.step(ws) -""" - == message - ) +""" == message req: AsyncBoltRequest = AsyncBoltRequest(body=step_execute_payload, mode="socket_mode") message = warning_unhandled_request(req) filtered_body = { "type": "event_callback", "event": {"type": "workflow_step_execute"}, } - assert ( - f"""Unhandled request ({filtered_body}) + assert f"""Unhandled request ({filtered_body}) --- [Suggestion] You can handle this type of event with the following listener function: @@ -315,9 +275,7 @@ def test_step(self): ) # Pass Step to set up listeners app.step(ws) -""" - == message - ) +""" == message block_actions = { From f0db283064225c2247a32bf9febb28efbbe3a498 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 17 Mar 2026 06:21:14 -0700 Subject: [PATCH 847/865] chore: improve testing around assistant utilities (#1461) --- tests/scenario_tests/test_events_assistant.py | 138 +++++++-- ...est_events_assistant_without_middleware.py | 249 ++++++++++++++++ .../test_events_assistant.py | 152 ++++++++-- ...est_events_assistant_without_middleware.py | 268 ++++++++++++++++++ 4 files changed, 750 insertions(+), 57 deletions(-) create mode 100644 tests/scenario_tests/test_events_assistant_without_middleware.py create mode 100644 tests/scenario_tests_async/test_events_assistant_without_middleware.py diff --git a/tests/scenario_tests/test_events_assistant.py b/tests/scenario_tests/test_events_assistant.py index 07f7ede53..3372380fd 100644 --- a/tests/scenario_tests/test_events_assistant.py +++ b/tests/scenario_tests/test_events_assistant.py @@ -1,4 +1,4 @@ -from time import sleep +import time from slack_sdk.web import WebClient @@ -10,6 +10,13 @@ from tests.utils import remove_os_env_temporarily, restore_os_env +def assert_target_called(called: dict, timeout: float = 0.5): + deadline = time.time() + timeout + while called["value"] is not True and time.time() < deadline: + time.sleep(0.1) + assert called["value"] is True + + class TestEventsAssistant: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" @@ -26,81 +33,156 @@ def teardown_method(self): cleanup_mock_web_api_server(self) restore_os_env(self.old_os_env) - def test_assistant_threads(self): + def test_thread_started(self): app = App(client=self.web_client) assistant = Assistant() - - state = {"called": False} - - def assert_target_called(): - count = 0 - while state["called"] is False and count < 20: - sleep(0.1) - count += 1 - assert state["called"] is True - state["called"] = False + called = {"value": False} @assistant.thread_started - def start_thread(say: Say, set_suggested_prompts: SetSuggestedPrompts, context: BoltContext): + def start_thread(say: Say, set_suggested_prompts: SetSuggestedPrompts, set_status: SetStatus, context: BoltContext): assert context.channel_id == "D111" assert context.thread_ts == "1726133698.626339" + assert set_status.thread_ts == context.thread_ts + assert say.thread_ts == context.thread_ts say("Hi, how can I help you today?") set_suggested_prompts(prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}]) set_suggested_prompts( prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}], title="foo" ) - state["called"] = True + called["value"] = True + + app.assistant(assistant) + + request = BoltRequest(body=thread_started_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_thread_context_changed(self): + app = App(client=self.web_client) + assistant = Assistant() + called = {"value": False} @assistant.thread_context_changed def handle_thread_context_changed(context: BoltContext): assert context.channel_id == "D111" assert context.thread_ts == "1726133698.626339" - state["called"] = True + called["value"] = True + + app.assistant(assistant) + + request = BoltRequest(body=thread_context_changed_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_user_message(self): + app = App(client=self.web_client) + assistant = Assistant() + called = {"value": False} @assistant.user_message def handle_user_message(say: Say, set_status: SetStatus, context: BoltContext): assert context.channel_id == "D111" assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts try: set_status("is typing...") say("Here you are!") - state["called"] = True + called["value"] = True except Exception as e: - say(f"Oops, something went wrong (error: {e}") + say(f"Oops, something went wrong (error: {e})") app.assistant(assistant) - request = BoltRequest(body=thread_started_event_body, mode="socket_mode") + request = BoltRequest(body=user_message_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called() + assert_target_called(called) - request = BoltRequest(body=thread_context_changed_event_body, mode="socket_mode") - response = app.dispatch(request) - assert response.status == 200 - assert_target_called() + def test_user_message_with_assistant_thread(self): + app = App(client=self.web_client) + assistant = Assistant() + called = {"value": False} - request = BoltRequest(body=user_message_event_body, mode="socket_mode") - response = app.dispatch(request) - assert response.status == 200 - assert_target_called() + @assistant.user_message + def handle_user_message(say: Say, set_status: SetStatus, context: BoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + try: + set_status("is typing...") + say("Here you are!") + called["value"] = True + except Exception as e: + say(f"Oops, something went wrong (error: {e})") + + app.assistant(assistant) request = BoltRequest(body=user_message_event_body_with_assistant_thread, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called() + assert_target_called(called) + + def test_message_changed(self): + app = App(client=self.web_client) + assistant = Assistant() + called = {"value": False} + + @assistant.user_message + def handle_user_message(): + called["value"] = True + + @assistant.bot_message + def handle_bot_message(): + called["value"] = True + + app.assistant(assistant) request = BoltRequest(body=message_changed_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 + assert called["value"] is False + + def test_channel_user_message_ignored(self): + app = App(client=self.web_client) + assistant = Assistant() + called = {"value": False} + + @assistant.user_message + def handle_user_message(): + called["value"] = True + + @assistant.bot_message + def handle_bot_message(): + called["value"] = True + + app.assistant(assistant) request = BoltRequest(body=channel_user_message_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 404 + assert called["value"] is False + + def test_channel_message_changed_ignored(self): + app = App(client=self.web_client) + assistant = Assistant() + called = {"value": False} + + @assistant.user_message + def handle_user_message(): + called["value"] = True + + @assistant.bot_message + def handle_bot_message(): + called["value"] = True + + app.assistant(assistant) request = BoltRequest(body=channel_message_changed_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 404 + assert called["value"] is False def build_payload(event: dict) -> dict: diff --git a/tests/scenario_tests/test_events_assistant_without_middleware.py b/tests/scenario_tests/test_events_assistant_without_middleware.py new file mode 100644 index 000000000..5307aa4c6 --- /dev/null +++ b/tests/scenario_tests/test_events_assistant_without_middleware.py @@ -0,0 +1,249 @@ +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest, Say, SetStatus, SetTitle, SaveThreadContext, BoltContext +from slack_bolt.context.get_thread_context.get_thread_context import GetThreadContext +from slack_bolt.context.set_suggested_prompts.set_suggested_prompts import SetSuggestedPrompts +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.scenario_tests.test_events_assistant import ( + assert_target_called, + channel_message_changed_event_body, + channel_user_message_event_body, + message_changed_event_body, + thread_context_changed_event_body, + thread_started_event_body, + user_message_event_body, + user_message_event_body_with_assistant_thread, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestEventsAssistantWithoutMiddleware: + 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_thread_started(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.event("assistant_thread_started") + def handle_assistant_thread_started( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + say("Hi, how can I help you today?") + set_suggested_prompts(prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}]) + called["value"] = True + + request = BoltRequest(body=thread_started_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_thread_context_changed(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.event("assistant_thread_context_changed") + def handle_assistant_thread_context_changed( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + called["value"] = True + + request = BoltRequest(body=thread_context_changed_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_user_message(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.message("") + def handle_message( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + try: + set_status("is typing...") + say("Here you are!") + called["value"] = True + except Exception as e: + say(f"Oops, something went wrong (error: {e})") + + request = BoltRequest(body=user_message_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_user_message_with_assistant_thread(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.message("") + def handle_message( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + try: + set_status("is typing...") + say("Here you are!") + called["value"] = True + except Exception as e: + say(f"Oops, something went wrong (error: {e})") + + request = BoltRequest(body=user_message_event_body_with_assistant_thread, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_message_changed(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.event("message") + def handle_message_event( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.thread_ts is None + assert say.thread_ts == context.thread_ts + assert set_status is None + assert set_title is None + assert set_suggested_prompts is None + assert get_thread_context is None + assert save_thread_context is None + called["value"] = True + + request = BoltRequest(body=message_changed_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_channel_user_message(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.event("message") + def handle_message_event( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.thread_ts is None + assert say.thread_ts == context.thread_ts + assert set_status is None + assert set_title is None + assert set_suggested_prompts is None + assert get_thread_context is None + assert save_thread_context is None + called["value"] = True + + request = BoltRequest(body=channel_user_message_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_channel_message_changed(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.event("message") + def handle_message_event( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.thread_ts is None + assert say.thread_ts == context.thread_ts + assert set_status is None + assert set_title is None + assert set_suggested_prompts is None + assert get_thread_context is None + assert save_thread_context is None + called["value"] = True + + request = BoltRequest(body=channel_message_changed_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) diff --git a/tests/scenario_tests_async/test_events_assistant.py b/tests/scenario_tests_async/test_events_assistant.py index b131b4e38..c6d04474d 100644 --- a/tests/scenario_tests_async/test_events_assistant.py +++ b/tests/scenario_tests_async/test_events_assistant.py @@ -1,4 +1,5 @@ import asyncio +import time import pytest from slack_sdk.web.async_client import AsyncWebClient @@ -17,6 +18,13 @@ from tests.utils import remove_os_env_temporarily, restore_os_env +async def assert_target_called(called: dict, timeout: float = 0.5): + deadline = time.time() + timeout + while called["value"] is not True and time.time() < deadline: + await asyncio.sleep(0.1) + assert called["value"] is True + + class TestAsyncEventsAssistant: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" @@ -29,6 +37,7 @@ class TestAsyncEventsAssistant: def setup_teardown(self): old_os_env = remove_os_env_temporarily() setup_mock_web_api_server_async(self) + try: yield # run the test here finally: @@ -36,25 +45,22 @@ def setup_teardown(self): restore_os_env(old_os_env) @pytest.mark.asyncio - async def test_assistant_events(self): + async def test_thread_started(self): app = AsyncApp(client=self.web_client) - assistant = AsyncAssistant() - - state = {"called": False} - - async def assert_target_called(): - count = 0 - while state["called"] is False and count < 20: - await asyncio.sleep(0.1) - count += 1 - assert state["called"] is True - state["called"] = False + called = {"value": False} @assistant.thread_started - async def start_thread(say: AsyncSay, set_suggested_prompts: AsyncSetSuggestedPrompts, context: AsyncBoltContext): + async def start_thread( + say: AsyncSay, + set_suggested_prompts: AsyncSetSuggestedPrompts, + set_status: AsyncSetStatus, + context: AsyncBoltContext, + ): assert context.channel_id == "D111" assert context.thread_ts == "1726133698.626339" + assert set_status.thread_ts == context.thread_ts + assert say.thread_ts == context.thread_ts await say("Hi, how can I help you today?") await set_suggested_prompts( prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}] @@ -63,58 +69,146 @@ async def start_thread(say: AsyncSay, set_suggested_prompts: AsyncSetSuggestedPr prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}], title="foo", ) - state["called"] = True + called["value"] = True + + app.assistant(assistant) + + request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_thread_context_changed(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + called = {"value": False} @assistant.thread_context_changed - async def handle_user_message(context: AsyncBoltContext): + async def handle_thread_context_changed(context: AsyncBoltContext): assert context.channel_id == "D111" assert context.thread_ts == "1726133698.626339" - state["called"] = True + called["value"] = True + + app.assistant(assistant) + + request = AsyncBoltRequest(body=thread_context_changed_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_user_message(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + called = {"value": False} @assistant.user_message async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context: AsyncBoltContext): assert context.channel_id == "D111" assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts try: await set_status("is typing...") await say("Here you are!") - state["called"] = True + called["value"] = True except Exception as e: - await say(f"Oops, something went wrong (error: {e}") + await say(f"Oops, something went wrong (error: {e})") app.assistant(assistant) - request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") + request = AsyncBoltRequest(body=user_message_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called() + await assert_target_called(called) - request = AsyncBoltRequest(body=thread_context_changed_event_body, mode="socket_mode") - response = await app.async_dispatch(request) - assert response.status == 200 - await assert_target_called() + @pytest.mark.asyncio + async def test_user_message_with_assistant_thread(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + called = {"value": False} - request = AsyncBoltRequest(body=user_message_event_body, mode="socket_mode") - response = await app.async_dispatch(request) - assert response.status == 200 - await assert_target_called() + @assistant.user_message + async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context: AsyncBoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + try: + await set_status("is typing...") + await say("Here you are!") + called["value"] = True + except Exception as e: + await say(f"Oops, something went wrong (error: {e})") + + app.assistant(assistant) request = AsyncBoltRequest(body=user_message_event_body_with_assistant_thread, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called() + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_message_changed(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + called = {"value": False} + + @assistant.user_message + async def handle_user_message(): + called["value"] = True + + @assistant.bot_message + async def handle_bot_message(): + called["value"] = True + + app.assistant(assistant) request = AsyncBoltRequest(body=message_changed_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 + assert called["value"] is False + + @pytest.mark.asyncio + async def test_channel_user_message_ignored(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + called = {"value": False} + + @assistant.user_message + async def handle_user_message(): + called["value"] = True + + @assistant.bot_message + async def handle_bot_message(): + called["value"] = True + + app.assistant(assistant) request = AsyncBoltRequest(body=channel_user_message_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 404 + assert called["value"] is False + + @pytest.mark.asyncio + async def test_channel_message_changed_ignored(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + called = {"value": False} + + @assistant.user_message + async def handle_user_message(): + called["value"] = True + + @assistant.bot_message + async def handle_bot_message(): + called["value"] = True + + app.assistant(assistant) request = AsyncBoltRequest(body=channel_message_changed_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 404 + assert called["value"] is False def build_payload(event: dict) -> dict: diff --git a/tests/scenario_tests_async/test_events_assistant_without_middleware.py b/tests/scenario_tests_async/test_events_assistant_without_middleware.py new file mode 100644 index 000000000..92f488ff3 --- /dev/null +++ b/tests/scenario_tests_async/test_events_assistant_without_middleware.py @@ -0,0 +1,268 @@ +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.context.set_status.async_set_status import AsyncSetStatus +from slack_bolt.context.set_title.async_set_title import AsyncSetTitle +from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts +from slack_bolt.context.get_thread_context.async_get_thread_context import AsyncGetThreadContext +from slack_bolt.context.save_thread_context.async_save_thread_context import AsyncSaveThreadContext +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.scenario_tests_async.test_events_assistant import ( + assert_target_called, + channel_message_changed_event_body, + channel_user_message_event_body, + message_changed_event_body, + thread_context_changed_event_body, + thread_started_event_body, + user_message_event_body, + user_message_event_body_with_assistant_thread, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEventsAssistantWithoutMiddleware: + 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(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + + try: + yield + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_thread_started(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.event("assistant_thread_started") + async def handle_assistant_thread_started( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + await say("Hi, how can I help you today?") + await set_suggested_prompts( + prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}] + ) + called["value"] = True + + request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_thread_context_changed(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.event("assistant_thread_context_changed") + async def handle_assistant_thread_context_changed( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + called["value"] = True + + request = AsyncBoltRequest(body=thread_context_changed_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_user_message(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.message("") + async def handle_message( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + try: + await set_status("is typing...") + await say("Here you are!") + called["value"] = True + except Exception as e: + await say(f"Oops, something went wrong (error: {e})") + + request = AsyncBoltRequest(body=user_message_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_user_message_with_assistant_thread(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.message("") + async def handle_message( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + try: + await set_status("is typing...") + await say("Here you are!") + called["value"] = True + except Exception as e: + await say(f"Oops, something went wrong (error: {e})") + + request = AsyncBoltRequest(body=user_message_event_body_with_assistant_thread, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_message_changed(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.event("message") + async def handle_message_event( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.thread_ts is None + assert say.thread_ts == context.thread_ts + assert set_status is None + assert set_title is None + assert set_suggested_prompts is None + assert get_thread_context is None + assert save_thread_context is None + called["value"] = True + + request = AsyncBoltRequest(body=message_changed_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_channel_user_message(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.event("message") + async def handle_message_event( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.thread_ts is None + assert say.thread_ts == context.thread_ts + assert set_status is None + assert set_title is None + assert set_suggested_prompts is None + assert get_thread_context is None + assert save_thread_context is None + called["value"] = True + + request = AsyncBoltRequest(body=channel_user_message_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_channel_message_changed(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.event("message") + async def handle_message_event( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.thread_ts is None + assert say.thread_ts == context.thread_ts + assert set_status is None + assert set_title is None + assert set_suggested_prompts is None + assert get_thread_context is None + assert save_thread_context is None + called["value"] = True + + request = AsyncBoltRequest(body=channel_message_changed_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) From 785b81352ccf2d382c430c7cf57bc05c8f167599 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 17 Mar 2026 10:01:06 -0700 Subject: [PATCH 848/865] fix(assistant): improve middleware dispatch and inject kwargs in middleware (#1456) --- slack_bolt/app/app.py | 27 +-- slack_bolt/app/async_app.py | 24 +-- slack_bolt/context/async_context.py | 2 +- slack_bolt/context/context.py | 2 +- slack_bolt/middleware/__init__.py | 2 + slack_bolt/middleware/assistant/assistant.py | 11 + .../middleware/assistant/async_assistant.py | 11 + slack_bolt/middleware/async_builtins.py | 2 + .../attaching_agent_kwargs/__init__.py | 5 + .../async_attaching_agent_kwargs.py | 39 ++++ .../attaching_agent_kwargs.py | 33 +++ slack_bolt/request/internals.py | 39 +--- tests/scenario_tests/test_events_assistant.py | 71 +++++++ ...est_events_assistant_without_middleware.py | 31 ++- .../test_events_assistant.py | 127 +++++++++++ ...est_events_assistant_without_middleware.py | 32 ++- .../attaching_agent_kwargs/__init__.py | 0 .../test_attaching_agent_kwargs.py | 64 ++++++ tests/slack_bolt/request/test_internals.py | 201 ++++++++++++++++++ .../attaching_agent_kwargs/__init__.py | 0 .../test_async_attaching_agent_kwargs.py | 69 ++++++ 21 files changed, 715 insertions(+), 77 deletions(-) create mode 100644 slack_bolt/middleware/attaching_agent_kwargs/__init__.py create mode 100644 slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py create mode 100644 slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py create mode 100644 tests/slack_bolt/middleware/attaching_agent_kwargs/__init__.py create mode 100644 tests/slack_bolt/middleware/attaching_agent_kwargs/test_attaching_agent_kwargs.py create mode 100644 tests/slack_bolt_async/middleware/attaching_agent_kwargs/__init__.py create mode 100644 tests/slack_bolt_async/middleware/attaching_agent_kwargs/test_async_attaching_agent_kwargs.py diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index fcf5bb788..566eb82d7 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -22,7 +22,6 @@ from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore -from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities from slack_bolt.error import BoltError, BoltUnhandledRequestError from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner from slack_bolt.listener.builtins import TokenRevocationListeners @@ -70,6 +69,7 @@ IgnoringSelfEvents, CustomMiddleware, AttachingFunctionToken, + AttachingAgentKwargs, ) from slack_bolt.middleware.assistant import Assistant from slack_bolt.middleware.message_listener_matches import MessageListenerMatches @@ -83,10 +83,6 @@ from slack_bolt.oauth.internals import select_consistent_installation_store from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.request import BoltRequest -from slack_bolt.request.payload_utils import ( - is_assistant_event, - to_event, -) from slack_bolt.response import BoltResponse from slack_bolt.util.utils import ( create_web_client, @@ -137,6 +133,7 @@ def __init__( listener_executor: Optional[Executor] = None, # for AI Agents & Assistants assistant_thread_context_store: Optional[AssistantThreadContextStore] = None, + attaching_agent_kwargs_enabled: bool = True, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -357,6 +354,7 @@ def message_hello(message, say): listener_executor = ThreadPoolExecutor(max_workers=5) self._assistant_thread_context_store = assistant_thread_context_store + self._attaching_agent_kwargs_enabled = attaching_agent_kwargs_enabled self._process_before_response = process_before_response self._listener_runner = ThreadListenerRunner( @@ -841,10 +839,13 @@ def ask_for_introduction(event, say): middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, base_logger=self._base_logger) + if self._attaching_agent_kwargs_enabled: + middleware.insert(0, AttachingAgentKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__ @@ -902,6 +903,8 @@ def __call__(*args, **kwargs): primary_matcher = builtin_matchers.message_event( keyword=keyword, constraints=constraints, base_logger=self._base_logger ) + if self._attaching_agent_kwargs_enabled: + middleware.insert(0, AttachingAgentKwargs(self._assistant_thread_context_store)) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) @@ -1398,20 +1401,6 @@ def _init_context(self, req: BoltRequest): # It is intended for apps that start lazy listeners from their custom global middleware. req.context["listener_runner"] = self.listener_runner - # For AI Agents & Assistants - if is_assistant_event(req.body): - assistant = AssistantUtilities( - payload=to_event(req.body), # type: ignore[arg-type] - context=req.context, - thread_context_store=self._assistant_thread_context_store, - ) - req.context["say"] = assistant.say - req.context["set_status"] = assistant.set_status - req.context["set_title"] = assistant.set_title - req.context["set_suggested_prompts"] = assistant.set_suggested_prompts - req.context["get_thread_context"] = assistant.get_thread_context - req.context["save_thread_context"] = assistant.save_thread_context - @staticmethod def _to_listener_functions( kwargs: dict, diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 62c491084..9cd8c911f 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -8,7 +8,6 @@ from aiohttp import web from slack_bolt.app.async_server import AsyncSlackAppServer -from slack_bolt.context.assistant.async_assistant_utilities import AsyncAssistantUtilities from slack_bolt.context.assistant.thread_context_store.async_store import ( AsyncAssistantThreadContextStore, ) @@ -30,7 +29,6 @@ AsyncMessageListenerMatches, ) from slack_bolt.oauth.async_internals import select_consistent_installation_store -from slack_bolt.request.payload_utils import is_assistant_event, to_event from slack_bolt.util.utils import get_name_for_callable, is_callable_coroutine from slack_bolt.workflows.step.async_step import ( AsyncWorkflowStep, @@ -88,6 +86,7 @@ AsyncIgnoringSelfEvents, AsyncUrlVerification, AsyncAttachingFunctionToken, + AsyncAttachingAgentKwargs, ) from slack_bolt.middleware.async_custom_middleware import ( AsyncMiddleware, @@ -143,6 +142,7 @@ def __init__( verification_token: Optional[str] = None, # for AI Agents & Assistants assistant_thread_context_store: Optional[AsyncAssistantThreadContextStore] = None, + attaching_agent_kwargs_enabled: bool = True, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -363,6 +363,7 @@ async def message_hello(message, say): # async function self._async_listeners: List[AsyncListener] = [] self._assistant_thread_context_store = assistant_thread_context_store + self._attaching_agent_kwargs_enabled = attaching_agent_kwargs_enabled self._process_before_response = process_before_response self._async_listener_runner = AsyncioListenerRunner( @@ -866,10 +867,13 @@ async def ask_for_introduction(event, say): middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, True, base_logger=self._base_logger) + if self._attaching_agent_kwargs_enabled: + middleware.insert(0, AsyncAttachingAgentKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__ @@ -930,6 +934,8 @@ def __call__(*args, **kwargs): asyncio=True, base_logger=self._base_logger, ) + if self._attaching_agent_kwargs_enabled: + middleware.insert(0, AsyncAttachingAgentKwargs(self._assistant_thread_context_store)) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) @@ -1431,20 +1437,6 @@ def _init_context(self, req: AsyncBoltRequest): # It is intended for apps that start lazy listeners from their custom global middleware. req.context["listener_runner"] = self.listener_runner - # For AI Agents & Assistants - if is_assistant_event(req.body): - assistant = AsyncAssistantUtilities( - payload=to_event(req.body), # type: ignore[arg-type] - context=req.context, - thread_context_store=self._assistant_thread_context_store, - ) - req.context["say"] = assistant.say - req.context["set_status"] = assistant.set_status - req.context["set_title"] = assistant.set_title - req.context["set_suggested_prompts"] = assistant.set_suggested_prompts - req.context["get_thread_context"] = assistant.get_thread_context - req.context["save_thread_context"] = assistant.save_thread_context - @staticmethod def _to_listener_functions( kwargs: dict, diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index 47eb4744e..631f74a82 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -110,7 +110,7 @@ async def handle_button_clicks(ack, say): Callable `say()` function """ if "say" not in self: - self["say"] = AsyncSay(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) + self["say"] = AsyncSay(client=self.client, channel=self.channel_id) return self["say"] @property diff --git a/slack_bolt/context/context.py b/slack_bolt/context/context.py index 31edf2891..48df4ad32 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -111,7 +111,7 @@ def handle_button_clicks(ack, say): Callable `say()` function """ if "say" not in self: - self["say"] = Say(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) + self["say"] = Say(client=self.client, channel=self.channel_id) return self["say"] @property diff --git a/slack_bolt/middleware/__init__.py b/slack_bolt/middleware/__init__.py index 0e4044f99..7b51fb239 100644 --- a/slack_bolt/middleware/__init__.py +++ b/slack_bolt/middleware/__init__.py @@ -17,6 +17,7 @@ from .ssl_check import SslCheck from .url_verification import UrlVerification from .attaching_function_token import AttachingFunctionToken +from .attaching_agent_kwargs import AttachingAgentKwargs builtin_middleware_classes = [ SslCheck, @@ -41,5 +42,6 @@ "SslCheck", "UrlVerification", "AttachingFunctionToken", + "AttachingAgentKwargs", "builtin_middleware_classes", ] diff --git a/slack_bolt/middleware/assistant/assistant.py b/slack_bolt/middleware/assistant/assistant.py index d61386105..9696e826e 100644 --- a/slack_bolt/middleware/assistant/assistant.py +++ b/slack_bolt/middleware/assistant/assistant.py @@ -7,6 +7,7 @@ from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore from slack_bolt.listener_matcher.builtins import build_listener_matcher +from slack_bolt.middleware.attaching_agent_kwargs import AttachingAgentKwargs from slack_bolt.request.request import BoltRequest from slack_bolt.response.response import BoltResponse from slack_bolt.listener_matcher import CustomListenerMatcher @@ -236,6 +237,15 @@ def process( # type: ignore[return] if listeners is not None: for listener in listeners: if listener.matches(req=req, resp=resp): + middleware_resp, next_was_not_called = listener.run_middleware(req=req, resp=resp) + if next_was_not_called: + if middleware_resp is not None: + return middleware_resp + # The listener middleware didn't call next(). + # Skip this listener and try the next one. + continue + if middleware_resp is not None: + resp = middleware_resp return listener_runner.run( request=req, response=resp, @@ -262,6 +272,7 @@ def build_listener( return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] + middleware.insert(0, AttachingAgentKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) diff --git a/slack_bolt/middleware/assistant/async_assistant.py b/slack_bolt/middleware/assistant/async_assistant.py index ae82595a8..d841e2de0 100644 --- a/slack_bolt/middleware/assistant/async_assistant.py +++ b/slack_bolt/middleware/assistant/async_assistant.py @@ -8,6 +8,7 @@ from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner from slack_bolt.listener_matcher.builtins import build_listener_matcher +from slack_bolt.middleware.attaching_agent_kwargs.async_attaching_agent_kwargs import AsyncAttachingAgentKwargs from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from slack_bolt.error import BoltError @@ -265,6 +266,15 @@ async def async_process( # type: ignore[return] if listeners is not None: for listener in listeners: if listener is not None and await listener.async_matches(req=req, resp=resp): + middleware_resp, next_was_not_called = await listener.run_async_middleware(req=req, resp=resp) + if next_was_not_called: + if middleware_resp is not None: + return middleware_resp + # The listener middleware didn't call next(). + # Skip this listener and try the next one. + continue + if middleware_resp is not None: + resp = middleware_resp return await listener_runner.run( request=req, response=resp, @@ -291,6 +301,7 @@ def build_listener( return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] + middleware.insert(0, AsyncAttachingAgentKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) diff --git a/slack_bolt/middleware/async_builtins.py b/slack_bolt/middleware/async_builtins.py index d2d82c1fb..755b55c20 100644 --- a/slack_bolt/middleware/async_builtins.py +++ b/slack_bolt/middleware/async_builtins.py @@ -10,6 +10,7 @@ AsyncMessageListenerMatches, ) from .attaching_function_token.async_attaching_function_token import AsyncAttachingFunctionToken +from .attaching_agent_kwargs.async_attaching_agent_kwargs import AsyncAttachingAgentKwargs __all__ = [ "AsyncIgnoringSelfEvents", @@ -18,4 +19,5 @@ "AsyncUrlVerification", "AsyncMessageListenerMatches", "AsyncAttachingFunctionToken", + "AsyncAttachingAgentKwargs", ] diff --git a/slack_bolt/middleware/attaching_agent_kwargs/__init__.py b/slack_bolt/middleware/attaching_agent_kwargs/__init__.py new file mode 100644 index 000000000..98926fc14 --- /dev/null +++ b/slack_bolt/middleware/attaching_agent_kwargs/__init__.py @@ -0,0 +1,5 @@ +from .attaching_agent_kwargs import AttachingAgentKwargs + +__all__ = [ + "AttachingAgentKwargs", +] diff --git a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py new file mode 100644 index 000000000..0b43c21ce --- /dev/null +++ b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py @@ -0,0 +1,39 @@ +from typing import Optional, Callable, Awaitable + +from slack_bolt.context.assistant.async_assistant_utilities import AsyncAssistantUtilities +from slack_bolt.context.assistant.thread_context_store.async_store import AsyncAssistantThreadContextStore +from slack_bolt.middleware.async_middleware import AsyncMiddleware +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.request.payload_utils import is_assistant_event, to_event +from slack_bolt.response import BoltResponse + + +class AsyncAttachingAgentKwargs(AsyncMiddleware): + + thread_context_store: Optional[AsyncAssistantThreadContextStore] + + def __init__(self, thread_context_store: Optional[AsyncAssistantThreadContextStore] = None): + self.thread_context_store = thread_context_store + + async def async_process( + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + next: Callable[[], Awaitable[BoltResponse]], + ) -> Optional[BoltResponse]: + event = to_event(req.body) + if event is not None: + if is_assistant_event(req.body): + assistant = AsyncAssistantUtilities( + payload=event, + context=req.context, + thread_context_store=self.thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_status"] = assistant.set_status + req.context["set_title"] = assistant.set_title + req.context["set_suggested_prompts"] = assistant.set_suggested_prompts + req.context["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + return await next() diff --git a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py new file mode 100644 index 000000000..4963ea67d --- /dev/null +++ b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py @@ -0,0 +1,33 @@ +from typing import Optional, Callable + +from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities +from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore +from slack_bolt.middleware import Middleware +from slack_bolt.request.payload_utils import is_assistant_event, to_event +from slack_bolt.request.request import BoltRequest +from slack_bolt.response.response import BoltResponse + + +class AttachingAgentKwargs(Middleware): + + thread_context_store: Optional[AssistantThreadContextStore] + + def __init__(self, thread_context_store: Optional[AssistantThreadContextStore] = None): + self.thread_context_store = thread_context_store + + def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse]) -> Optional[BoltResponse]: + event = to_event(req.body) + if event is not None: + if is_assistant_event(req.body): + assistant = AssistantUtilities( + payload=event, + context=req.context, + thread_context_store=self.thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_status"] = assistant.set_status + req.context["set_title"] = assistant.set_title + req.context["set_suggested_prompts"] = assistant.set_suggested_prompts + req.context["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + return next() diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index e6a32db0d..466f5daaf 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -3,7 +3,6 @@ from urllib.parse import parse_qsl, parse_qs from slack_bolt.context import BoltContext -from slack_bolt.request.payload_utils import is_assistant_event def parse_query(query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]]) -> Dict[str, Sequence[str]]: @@ -215,33 +214,17 @@ def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: def extract_thread_ts(payload: Dict[str, Any]) -> Optional[str]: - # This utility initially supports only the use cases for AI assistants, but it may be fine to add more patterns. - # That said, note that thread_ts is always required for assistant threads, but it's not for channels. - # Thus, blindly setting this thread_ts to say utility can break existing apps' behaviors. - # - # The BoltAgent class handles non-assistant thread_ts separately by reading from the event directly, - # allowing it to work correctly without affecting say() behavior. - if is_assistant_event(payload): - event = payload["event"] - if ( - event.get("assistant_thread") is not None - and event["assistant_thread"].get("channel_id") is not None - and event["assistant_thread"].get("thread_ts") is not None - ): - # assistant_thread_started, assistant_thread_context_changed - # "assistant_thread" property can exist for message event without channel_id and thread_ts - # Thus, the above if check verifies these properties exist - return event["assistant_thread"]["thread_ts"] - elif event.get("channel") is not None: - if event.get("thread_ts") is not None: - # message in an assistant thread - return event["thread_ts"] - elif event.get("message", {}).get("thread_ts") is not None: - # message_changed - return event["message"]["thread_ts"] - elif event.get("previous_message", {}).get("thread_ts") is not None: - # message_deleted - return event["previous_message"]["thread_ts"] + thread_ts = payload.get("thread_ts") + if thread_ts is not None: + return thread_ts + if payload.get("event") is not None: + return extract_thread_ts(payload["event"]) + if isinstance(payload.get("assistant_thread"), dict): + return extract_thread_ts(payload["assistant_thread"]) + if isinstance(payload.get("message"), dict): + return extract_thread_ts(payload["message"]) + if isinstance(payload.get("previous_message"), dict): + return extract_thread_ts(payload["previous_message"]) return None diff --git a/tests/scenario_tests/test_events_assistant.py b/tests/scenario_tests/test_events_assistant.py index 3372380fd..5bc270d86 100644 --- a/tests/scenario_tests/test_events_assistant.py +++ b/tests/scenario_tests/test_events_assistant.py @@ -1,8 +1,13 @@ import time +from time import sleep +from typing import Callable from slack_sdk.web import WebClient from slack_bolt import App, BoltRequest, Assistant, Say, SetSuggestedPrompts, SetStatus, BoltContext +from slack_bolt.middleware import Middleware +from slack_bolt.request import BoltRequest as BoltRequestType +from slack_bolt.response import BoltResponse from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -184,6 +189,72 @@ def handle_bot_message(): assert response.status == 404 assert called["value"] is False + def test_assistant_with_custom_listener_middleware(self): + app = App(client=self.web_client) + assistant = Assistant() + handler_called = {"value": False} + middleware_called = {"value": False} + + class TestMiddleware(Middleware): + def process(self, *, req: BoltRequestType, resp: BoltResponse, next: Callable[[], BoltResponse]): + middleware_called["value"] = True + # Verify assistant utilities are available + assert req.context.get("set_status") is not None + assert req.context.get("set_title") is not None + assert req.context.get("set_suggested_prompts") is not None + assert req.context.get("get_thread_context") is not None + assert req.context.get("save_thread_context") is not None + return next() + + @assistant.thread_started(middleware=[TestMiddleware()]) + def start_thread(): + handler_called["value"] = True + + @assistant.user_message(middleware=[TestMiddleware()]) + def handle_user_message(): + handler_called["value"] = True + + app.assistant(assistant) + + request = BoltRequest(body=thread_started_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(handler_called) + assert_target_called(middleware_called) + + handler_called = {"value": False} + middleware_called = {"value": False} + + request = BoltRequest(body=user_message_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(handler_called) + assert_target_called(middleware_called) + + def test_assistant_custom_middleware_can_short_circuit(self): + app = App(client=self.web_client) + assistant = Assistant() + handler_called = {"value": False} + middleware_called = {"value": False} + + class BlockingMiddleware(Middleware): + def process(self, *, req: BoltRequestType, resp: BoltResponse, next: Callable[[], BoltResponse]): + middleware_called["value"] = True + # Intentionally not calling next() to short-circuit + return BoltResponse(status=200) + + @assistant.thread_started(middleware=[BlockingMiddleware()]) + def start_thread(say: Say, context: BoltContext): + handler_called["value"] = True + + app.assistant(assistant) + + request = BoltRequest(body=thread_started_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(middleware_called) + assert handler_called["value"] is False + def build_payload(event: dict) -> dict: return { diff --git a/tests/scenario_tests/test_events_assistant_without_middleware.py b/tests/scenario_tests/test_events_assistant_without_middleware.py index 5307aa4c6..36d86c43a 100644 --- a/tests/scenario_tests/test_events_assistant_without_middleware.py +++ b/tests/scenario_tests/test_events_assistant_without_middleware.py @@ -178,8 +178,8 @@ def handle_message_event( save_thread_context: SaveThreadContext, context: BoltContext, ): - assert context.thread_ts is None - assert say.thread_ts == context.thread_ts + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == None assert set_status is None assert set_title is None assert set_suggested_prompts is None @@ -206,8 +206,8 @@ def handle_message_event( save_thread_context: SaveThreadContext, context: BoltContext, ): - assert context.thread_ts is None - assert say.thread_ts == context.thread_ts + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == None assert set_status is None assert set_title is None assert set_suggested_prompts is None @@ -234,8 +234,8 @@ def handle_message_event( save_thread_context: SaveThreadContext, context: BoltContext, ): - assert context.thread_ts is None - assert say.thread_ts == context.thread_ts + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == None assert set_status is None assert set_title is None assert set_suggested_prompts is None @@ -247,3 +247,22 @@ def handle_message_event( response = app.dispatch(request) assert response.status == 200 assert_target_called(called) + + def test_assistant_events_agent_kwargs_disabled(self): + app = App(client=self.web_client, attaching_agent_kwargs_enabled=False) + + called = {"value": False} + + @app.event("assistant_thread_started") + def start_thread(context: BoltContext): + assert context.get("set_status") is None + assert context.get("set_title") is None + assert context.get("set_suggested_prompts") is None + assert context.get("get_thread_context") is None + assert context.get("save_thread_context") is None + called["value"] = True + + request = BoltRequest(body=thread_started_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) diff --git a/tests/scenario_tests_async/test_events_assistant.py b/tests/scenario_tests_async/test_events_assistant.py index c6d04474d..87b337536 100644 --- a/tests/scenario_tests_async/test_events_assistant.py +++ b/tests/scenario_tests_async/test_events_assistant.py @@ -1,5 +1,6 @@ import asyncio import time +from typing import Awaitable, Callable, Optional import pytest from slack_sdk.web.async_client import AsyncWebClient @@ -10,7 +11,9 @@ from slack_bolt.context.set_status.async_set_status import AsyncSetStatus from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts from slack_bolt.middleware.assistant.async_assistant import AsyncAssistant +from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse from tests.mock_web_api_server import ( cleanup_mock_web_api_server_async, setup_mock_web_api_server_async, @@ -210,6 +213,130 @@ async def handle_bot_message(): assert response.status == 404 assert called["value"] is False + @pytest.mark.asyncio + async def test_assistant_events_kwargs_disabled(self): + app = AsyncApp(client=self.web_client, attaching_agent_kwargs_enabled=False) + + state = {"called": False} + + async def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + await asyncio.sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @app.event("assistant_thread_started") + async def start_thread(context: AsyncBoltContext): + assert context.get("set_status") is None + assert context.get("set_title") is None + assert context.get("set_suggested_prompts") is None + assert context.get("get_thread_context") is None + assert context.get("save_thread_context") is None + state["called"] = True + + request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + + @pytest.mark.asyncio + async def test_assistant_with_custom_listener_middleware(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + + state = {"called": False, "middleware_called": False} + + async def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + await asyncio.sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + class TestAsyncMiddleware(AsyncMiddleware): + async def async_process( + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + next: Callable[[], Awaitable[BoltResponse]], + ) -> Optional[BoltResponse]: + state["middleware_called"] = True + # Verify assistant utilities are available (set by _AsyncAssistantMiddleware before this) + assert req.context.get("set_status") is not None + assert req.context.get("set_title") is not None + assert req.context.get("set_suggested_prompts") is not None + assert req.context.get("get_thread_context") is not None + assert req.context.get("save_thread_context") is not None + return await next() + + @assistant.thread_started(middleware=[TestAsyncMiddleware()]) + async def start_thread(say: AsyncSay, set_suggested_prompts: AsyncSetSuggestedPrompts, context: AsyncBoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + await say("Hi, how can I help you today?") + await set_suggested_prompts( + prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}] + ) + state["called"] = True + + @assistant.user_message(middleware=[TestAsyncMiddleware()]) + async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context: AsyncBoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + await set_status("is typing...") + await say("Here you are!") + state["called"] = True + + app.assistant(assistant) + + request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + assert state["middleware_called"] is True + state["middleware_called"] = False + + request = AsyncBoltRequest(body=user_message_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + assert state["middleware_called"] is True + + @pytest.mark.asyncio + async def test_assistant_custom_middleware_can_short_circuit(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + + state = {"handler_called": False} + + class BlockingAsyncMiddleware(AsyncMiddleware): + async def async_process( + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + next: Callable[[], Awaitable[BoltResponse]], + ) -> Optional[BoltResponse]: + # Intentionally not calling next() to short-circuit + return BoltResponse(status=200) + + @assistant.thread_started(middleware=[BlockingAsyncMiddleware()]) + async def start_thread(say: AsyncSay, context: AsyncBoltContext): + state["handler_called"] = True + + app.assistant(assistant) + + request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + assert state["handler_called"] is False + def build_payload(event: dict) -> dict: return { diff --git a/tests/scenario_tests_async/test_events_assistant_without_middleware.py b/tests/scenario_tests_async/test_events_assistant_without_middleware.py index 92f488ff3..be6c2b166 100644 --- a/tests/scenario_tests_async/test_events_assistant_without_middleware.py +++ b/tests/scenario_tests_async/test_events_assistant_without_middleware.py @@ -195,8 +195,8 @@ async def handle_message_event( save_thread_context: AsyncSaveThreadContext, context: AsyncBoltContext, ): - assert context.thread_ts is None - assert say.thread_ts == context.thread_ts + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == None assert set_status is None assert set_title is None assert set_suggested_prompts is None @@ -224,8 +224,8 @@ async def handle_message_event( save_thread_context: AsyncSaveThreadContext, context: AsyncBoltContext, ): - assert context.thread_ts is None - assert say.thread_ts == context.thread_ts + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == None assert set_status is None assert set_title is None assert set_suggested_prompts is None @@ -253,8 +253,8 @@ async def handle_message_event( save_thread_context: AsyncSaveThreadContext, context: AsyncBoltContext, ): - assert context.thread_ts is None - assert say.thread_ts == context.thread_ts + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == None assert set_status is None assert set_title is None assert set_suggested_prompts is None @@ -266,3 +266,23 @@ async def handle_message_event( response = await app.async_dispatch(request) assert response.status == 200 await assert_target_called(called) + + @pytest.mark.asyncio + async def test_assistant_events_agent_kwargs_disabled(self): + app = AsyncApp(client=self.web_client, attaching_agent_kwargs_enabled=False) + + called = {"value": False} + + @app.event("assistant_thread_started") + async def start_thread(context: AsyncBoltContext): + assert context.get("set_status") is None + assert context.get("set_title") is None + assert context.get("set_suggested_prompts") is None + assert context.get("get_thread_context") is None + assert context.get("save_thread_context") is None + called["value"] = True + + request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) diff --git a/tests/slack_bolt/middleware/attaching_agent_kwargs/__init__.py b/tests/slack_bolt/middleware/attaching_agent_kwargs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/middleware/attaching_agent_kwargs/test_attaching_agent_kwargs.py b/tests/slack_bolt/middleware/attaching_agent_kwargs/test_attaching_agent_kwargs.py new file mode 100644 index 000000000..f56bd2e62 --- /dev/null +++ b/tests/slack_bolt/middleware/attaching_agent_kwargs/test_attaching_agent_kwargs.py @@ -0,0 +1,64 @@ +from slack_sdk import WebClient + +from slack_bolt.middleware.attaching_agent_kwargs import AttachingAgentKwargs +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse +from tests.scenario_tests.test_events_assistant import ( + thread_started_event_body, + user_message_event_body, + channel_user_message_event_body, +) + + +def next(): + return BoltResponse(status=200) + + +AGENT_KWARGS = ("say", "set_status", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") + + +class TestAttachingAgentKwargs: + def test_assistant_event_attaches_kwargs(self): + middleware = AttachingAgentKwargs() + req = BoltRequest(body=thread_started_event_body, mode="socket_mode") + req.context["client"] = WebClient(token="xoxb-test") + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + for key in AGENT_KWARGS: + assert key in req.context, f"{key} should be set on context" + assert req.context["say"].thread_ts == "1726133698.626339" + + def test_user_message_event_attaches_kwargs(self): + middleware = AttachingAgentKwargs() + req = BoltRequest(body=user_message_event_body, mode="socket_mode") + req.context["client"] = WebClient(token="xoxb-test") + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + for key in AGENT_KWARGS: + assert key in req.context, f"{key} should be set on context" + assert req.context["say"].thread_ts == "1726133698.626339" + + def test_non_assistant_event_does_not_attach_kwargs(self): + middleware = AttachingAgentKwargs() + req = BoltRequest(body=channel_user_message_event_body, mode="socket_mode") + req.context["client"] = WebClient(token="xoxb-test") + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + for key in AGENT_KWARGS: + assert key not in req.context, f"{key} should not be set on context" + + def test_non_event_does_not_attach_kwargs(self): + middleware = AttachingAgentKwargs() + req = BoltRequest(body="payload={}", headers={}) + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + for key in AGENT_KWARGS: + assert key not in req.context, f"{key} should not be set on context" diff --git a/tests/slack_bolt/request/test_internals.py b/tests/slack_bolt/request/test_internals.py index 752fa6d2d..0b267e3de 100644 --- a/tests/slack_bolt/request/test_internals.py +++ b/tests/slack_bolt/request/test_internals.py @@ -13,6 +13,7 @@ extract_actor_team_id, extract_actor_user_id, extract_function_execution_id, + extract_thread_ts, ) @@ -111,6 +112,196 @@ def teardown_method(self): }, ] + thread_ts_event_requests = [ + { + "event": { + "type": "app_mention", + "channel": "C111", + "user": "U111", + "ts": "123.420", + "thread_ts": "123.456", + }, + }, + { + "event": { + "type": "message", + "channel": "C111", + "user": "U111", + "ts": "123.420", + "thread_ts": "123.456", + }, + }, + { + "event": { + "type": "message", + "subtype": "bot_message", + "channel": "C111", + "bot_id": "B111", + "ts": "123.420", + "thread_ts": "123.456", + }, + }, + { + "event": { + "type": "message", + "subtype": "file_share", + "channel": "C111", + "user": "U111", + "ts": "123.420", + "thread_ts": "123.456", + }, + }, + { + "event": { + "type": "message", + "subtype": "thread_broadcast", + "channel": "C111", + "user": "U111", + "ts": "123.420", + "thread_ts": "123.456", + "root": {"thread_ts": "123.420"}, + }, + }, + { + "event": { + "type": "link_shared", + "channel": "C111", + "user": "U111", + "thread_ts": "123.456", + "links": [{"url": "https://example.com"}], + }, + }, + { + "event": { + "type": "message", + "subtype": "message_changed", + "channel": "C111", + "message": { + "type": "message", + "user": "U111", + "text": "edited", + "ts": "123.420", + "thread_ts": "123.456", + }, + }, + }, + { + "event": { + "type": "message", + "subtype": "message_changed", + "channel": "C111", + "message": { + "type": "message", + "user": "U111", + "text": "edited", + "ts": "123.420", + "thread_ts": "123.456", + }, + "previous_message": { + "type": "message", + "user": "U111", + "text": "deleted", + "ts": "123.420", + "thread_ts": "123.420", + }, + }, + }, + { + "event": { + "type": "message", + "subtype": "message_deleted", + "channel": "C111", + "previous_message": { + "type": "message", + "user": "U111", + "text": "deleted", + "ts": "123.420", + "thread_ts": "123.456", + }, + }, + }, + { + "event": { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "U123ABC456", + "context": { + "channel_id": "C123ABC456", + "team_id": "T123ABC456", + "enterprise_id": "E123ABC456", + }, + "channel_id": "D123ABC456", + "thread_ts": "123.456", + }, + "event_ts": "1715873754.429808", + }, + }, + { + "event": { + "type": "assistant_thread_context_changed", + "assistant_thread": { + "user_id": "U123ABC456", + "context": { + "channel_id": "C123ABC456", + "team_id": "T123ABC456", + "enterprise_id": "E123ABC456", + }, + "channel_id": "D123ABC456", + "thread_ts": "123.456", + }, + "event_ts": "17298244.022142", + }, + }, + { + "event": { + "type": "message", + "subtype": "message_changed", + "message": { + "text": "Chats from 2024-09-28", + "subtype": "assistant_app_thread", + "user": "U123456ABCD", + "type": "message", + "team": "T123456ABCD", + "thread_ts": "123.456", + "reply_count": 1, + "ts": "123.420", + }, + "channel": "D987654ABCD", + "hidden": True, + "ts": "123.420", + "event_ts": "123.420", + "channel_type": "im", + }, + }, + ] + + no_thread_ts_requests = [ + { + "event": { + "type": "reaction_added", + "user": "U111", + "reaction": "thumbsup", + "item": {"type": "message", "channel": "C111", "ts": "123.420"}, + }, + }, + { + "event": { + "type": "channel_created", + "channel": {"id": "C222", "name": "test", "created": 1678455198}, + }, + }, + { + "event": { + "type": "message", + "channel": "C111", + "user": "U111", + "text": "hello", + "ts": "123.420", + }, + }, + {}, + ] + slack_connect_authorizations = [ { "enterprise_id": "INSTALLED_ENTERPRISE_ID", @@ -337,6 +528,16 @@ def test_function_inputs_extraction(self): inputs = extract_function_inputs(req) assert inputs == {"customer_id": "Ux111"} + def test_extract_thread_ts(self): + for req in self.thread_ts_event_requests: + thread_ts = extract_thread_ts(req) + assert thread_ts == "123.456", f"Expected thread_ts for {req}" + + def test_extract_thread_ts_fail(self): + for req in self.no_thread_ts_requests: + thread_ts = extract_thread_ts(req) + assert thread_ts is None, f"Expected None for {req}" + def test_is_enterprise_install_extraction(self): for req in self.requests: should_be_false = extract_is_enterprise_install(req) diff --git a/tests/slack_bolt_async/middleware/attaching_agent_kwargs/__init__.py b/tests/slack_bolt_async/middleware/attaching_agent_kwargs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/middleware/attaching_agent_kwargs/test_async_attaching_agent_kwargs.py b/tests/slack_bolt_async/middleware/attaching_agent_kwargs/test_async_attaching_agent_kwargs.py new file mode 100644 index 000000000..55883e5f3 --- /dev/null +++ b/tests/slack_bolt_async/middleware/attaching_agent_kwargs/test_async_attaching_agent_kwargs.py @@ -0,0 +1,69 @@ +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.middleware.attaching_agent_kwargs.async_attaching_agent_kwargs import AsyncAttachingAgentKwargs +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from tests.scenario_tests_async.test_events_assistant import ( + thread_started_event_body, + user_message_event_body, + channel_user_message_event_body, +) + + +async def next(): + return BoltResponse(status=200) + + +AGENT_KWARGS = ("say", "set_status", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") + + +class TestAsyncAttachingAgentKwargs: + @pytest.mark.asyncio + async def test_assistant_event_attaches_kwargs(self): + middleware = AsyncAttachingAgentKwargs() + req = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") + req.context["client"] = AsyncWebClient(token="xoxb-test") + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + for key in AGENT_KWARGS: + assert key in req.context, f"{key} should be set on context" + assert req.context["say"].thread_ts == "1726133698.626339" + + @pytest.mark.asyncio + async def test_user_message_event_attaches_kwargs(self): + middleware = AsyncAttachingAgentKwargs() + req = AsyncBoltRequest(body=user_message_event_body, mode="socket_mode") + req.context["client"] = AsyncWebClient(token="xoxb-test") + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + for key in AGENT_KWARGS: + assert key in req.context, f"{key} should be set on context" + assert req.context["say"].thread_ts == "1726133698.626339" + + @pytest.mark.asyncio + async def test_non_assistant_event_does_not_attach_kwargs(self): + middleware = AsyncAttachingAgentKwargs() + req = AsyncBoltRequest(body=channel_user_message_event_body, mode="socket_mode") + req.context["client"] = AsyncWebClient(token="xoxb-test") + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + for key in AGENT_KWARGS: + assert key not in req.context, f"{key} should not be set on context" + + @pytest.mark.asyncio + async def test_non_event_does_not_attach_kwargs(self): + middleware = AsyncAttachingAgentKwargs() + req = AsyncBoltRequest(body="payload={}", headers={}) + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + for key in AGENT_KWARGS: + assert key not in req.context, f"{key} should not be set on context" From f1bc61f1827a16075ba137c78225e166719f75ec Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 19 Mar 2026 06:57:33 -0700 Subject: [PATCH 849/865] feat: add support for say_stream utility (#1462) Co-authored-by: Eden Zimbelman --- slack_bolt/__init__.py | 2 + slack_bolt/async_app.py | 2 + slack_bolt/context/async_context.py | 5 + slack_bolt/context/base_context.py | 1 + slack_bolt/context/context.py | 5 + slack_bolt/context/say_stream/__init__.py | 6 + .../context/say_stream/async_say_stream.py | 74 ++++++ slack_bolt/context/say_stream/say_stream.py | 74 ++++++ slack_bolt/kwargs_injection/args.py | 5 + slack_bolt/kwargs_injection/async_args.py | 5 + slack_bolt/kwargs_injection/async_utils.py | 1 + slack_bolt/kwargs_injection/utils.py | 1 + .../async_attaching_agent_kwargs.py | 12 + .../attaching_agent_kwargs.py | 12 + .../scenario_tests/test_events_say_stream.py | 238 +++++++++++++++++ .../test_events_say_stream.py | 250 ++++++++++++++++++ tests/slack_bolt/context/test_say_stream.py | 103 ++++++++ .../context/test_async_say_stream.py | 117 ++++++++ 18 files changed, 913 insertions(+) create mode 100644 slack_bolt/context/say_stream/__init__.py create mode 100644 slack_bolt/context/say_stream/async_say_stream.py create mode 100644 slack_bolt/context/say_stream/say_stream.py create mode 100644 tests/scenario_tests/test_events_say_stream.py create mode 100644 tests/scenario_tests_async/test_events_say_stream.py create mode 100644 tests/slack_bolt/context/test_say_stream.py create mode 100644 tests/slack_bolt_async/context/test_async_say_stream.py diff --git a/slack_bolt/__init__.py b/slack_bolt/__init__.py index 4e43252fd..dfe950bf2 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -14,6 +14,7 @@ from .context.fail import Fail from .context.respond import Respond from .context.say import Say +from .context.say_stream import SayStream from .kwargs_injection import Args from .listener import Listener from .listener_matcher import CustomListenerMatcher @@ -42,6 +43,7 @@ "Fail", "Respond", "Say", + "SayStream", "Args", "Listener", "CustomListenerMatcher", diff --git a/slack_bolt/async_app.py b/slack_bolt/async_app.py index fdf724d4c..f95d952aa 100644 --- a/slack_bolt/async_app.py +++ b/slack_bolt/async_app.py @@ -59,6 +59,7 @@ async def command(ack, body, respond): from .context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts from .context.get_thread_context.async_get_thread_context import AsyncGetThreadContext from .context.save_thread_context.async_save_thread_context import AsyncSaveThreadContext +from .context.say_stream.async_say_stream import AsyncSayStream __all__ = [ "AsyncApp", @@ -66,6 +67,7 @@ async def command(ack, body, respond): "AsyncBoltContext", "AsyncRespond", "AsyncSay", + "AsyncSayStream", "AsyncListener", "AsyncCustomListenerMatcher", "AsyncBoltRequest", diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index 631f74a82..33f260d38 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -10,6 +10,7 @@ from slack_bolt.context.get_thread_context.async_get_thread_context import AsyncGetThreadContext from slack_bolt.context.save_thread_context.async_save_thread_context import AsyncSaveThreadContext from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream from slack_bolt.context.set_status.async_set_status import AsyncSetStatus from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts from slack_bolt.context.set_title.async_set_title import AsyncSetTitle @@ -203,6 +204,10 @@ def set_suggested_prompts(self) -> Optional[AsyncSetSuggestedPrompts]: def get_thread_context(self) -> Optional[AsyncGetThreadContext]: return self.get("get_thread_context") + @property + def say_stream(self) -> Optional[AsyncSayStream]: + return self.get("say_stream") + @property def save_thread_context(self) -> Optional[AsyncSaveThreadContext]: return self.get("save_thread_context") diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 843d5ef60..502febcb8 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -38,6 +38,7 @@ class BaseContext(dict): "set_status", "set_title", "set_suggested_prompts", + "say_stream", ] # Note that these items are not copyable, so when you add new items to this list, # you must modify ThreadListenerRunner/AsyncioListenerRunner's _build_lazy_request method to pass the values. diff --git a/slack_bolt/context/context.py b/slack_bolt/context/context.py index 48df4ad32..6184d5083 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -10,6 +10,7 @@ from slack_bolt.context.respond import Respond from slack_bolt.context.save_thread_context import SaveThreadContext from slack_bolt.context.say import Say +from slack_bolt.context.say_stream import SayStream from slack_bolt.context.set_status import SetStatus from slack_bolt.context.set_suggested_prompts import SetSuggestedPrompts from slack_bolt.context.set_title import SetTitle @@ -204,6 +205,10 @@ def set_suggested_prompts(self) -> Optional[SetSuggestedPrompts]: def get_thread_context(self) -> Optional[GetThreadContext]: return self.get("get_thread_context") + @property + def say_stream(self) -> Optional[SayStream]: + return self.get("say_stream") + @property def save_thread_context(self) -> Optional[SaveThreadContext]: return self.get("save_thread_context") diff --git a/slack_bolt/context/say_stream/__init__.py b/slack_bolt/context/say_stream/__init__.py new file mode 100644 index 000000000..86db7b1cc --- /dev/null +++ b/slack_bolt/context/say_stream/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .say_stream import SayStream + +__all__ = [ + "SayStream", +] diff --git a/slack_bolt/context/say_stream/async_say_stream.py b/slack_bolt/context/say_stream/async_say_stream.py new file mode 100644 index 000000000..dc752d02a --- /dev/null +++ b/slack_bolt/context/say_stream/async_say_stream.py @@ -0,0 +1,74 @@ +import warnings +from typing import Optional + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_chat_stream import AsyncChatStream + +from slack_bolt.warning import ExperimentalWarning + + +class AsyncSayStream: + client: AsyncWebClient + channel: Optional[str] + recipient_team_id: Optional[str] + recipient_user_id: Optional[str] + thread_ts: Optional[str] + + def __init__( + self, + *, + client: AsyncWebClient, + channel: Optional[str] = None, + recipient_team_id: Optional[str] = None, + recipient_user_id: Optional[str] = None, + thread_ts: Optional[str] = None, + ): + self.client = client + self.channel = channel + self.recipient_team_id = recipient_team_id + self.recipient_user_id = recipient_user_id + self.thread_ts = thread_ts + + async def __call__( + self, + *, + buffer_size: Optional[int] = None, + channel: Optional[str] = None, + recipient_team_id: Optional[str] = None, + recipient_user_id: Optional[str] = None, + thread_ts: Optional[str] = None, + **kwargs, + ) -> AsyncChatStream: + """Starts a new chat stream with context. + + Warning: This is an experimental feature and may change in future versions. + """ + warnings.warn( + "say_stream is experimental and may change in future versions.", + category=ExperimentalWarning, + stacklevel=2, + ) + + channel = channel or self.channel + thread_ts = thread_ts or self.thread_ts + if channel is None: + raise ValueError("say_stream without channel here is unsupported") + if thread_ts is None: + raise ValueError("say_stream without thread_ts here is unsupported") + + if buffer_size is not None: + return await self.client.chat_stream( + buffer_size=buffer_size, + channel=channel, + recipient_team_id=recipient_team_id or self.recipient_team_id, + recipient_user_id=recipient_user_id or self.recipient_user_id, + thread_ts=thread_ts, + **kwargs, + ) + return await self.client.chat_stream( + channel=channel, + recipient_team_id=recipient_team_id or self.recipient_team_id, + recipient_user_id=recipient_user_id or self.recipient_user_id, + thread_ts=thread_ts, + **kwargs, + ) diff --git a/slack_bolt/context/say_stream/say_stream.py b/slack_bolt/context/say_stream/say_stream.py new file mode 100644 index 000000000..1e1d7985f --- /dev/null +++ b/slack_bolt/context/say_stream/say_stream.py @@ -0,0 +1,74 @@ +import warnings +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.web.chat_stream import ChatStream + +from slack_bolt.warning import ExperimentalWarning + + +class SayStream: + client: WebClient + channel: Optional[str] + recipient_team_id: Optional[str] + recipient_user_id: Optional[str] + thread_ts: Optional[str] + + def __init__( + self, + *, + client: WebClient, + channel: Optional[str] = None, + recipient_team_id: Optional[str] = None, + recipient_user_id: Optional[str] = None, + thread_ts: Optional[str] = None, + ): + self.client = client + self.channel = channel + self.recipient_team_id = recipient_team_id + self.recipient_user_id = recipient_user_id + self.thread_ts = thread_ts + + def __call__( + self, + *, + buffer_size: Optional[int] = None, + channel: Optional[str] = None, + recipient_team_id: Optional[str] = None, + recipient_user_id: Optional[str] = None, + thread_ts: Optional[str] = None, + **kwargs, + ) -> ChatStream: + """Starts a new chat stream with context. + + Warning: This is an experimental feature and may change in future versions. + """ + warnings.warn( + "say_stream is experimental and may change in future versions.", + category=ExperimentalWarning, + stacklevel=2, + ) + + channel = channel or self.channel + thread_ts = thread_ts or self.thread_ts + if channel is None: + raise ValueError("say_stream without channel here is unsupported") + if thread_ts is None: + raise ValueError("say_stream without thread_ts here is unsupported") + + if buffer_size is not None: + return self.client.chat_stream( + buffer_size=buffer_size, + channel=channel, + recipient_team_id=recipient_team_id or self.recipient_team_id, + recipient_user_id=recipient_user_id or self.recipient_user_id, + thread_ts=thread_ts, + **kwargs, + ) + return self.client.chat_stream( + channel=channel, + recipient_team_id=recipient_team_id or self.recipient_team_id, + recipient_user_id=recipient_user_id or self.recipient_user_id, + thread_ts=thread_ts, + **kwargs, + ) diff --git a/slack_bolt/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index 113e39c08..dfb242fd1 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -11,6 +11,7 @@ from slack_bolt.agent.agent import BoltAgent from slack_bolt.context.save_thread_context import SaveThreadContext from slack_bolt.context.say import Say +from slack_bolt.context.say_stream import SayStream from slack_bolt.context.set_status import SetStatus from slack_bolt.context.set_suggested_prompts import SetSuggestedPrompts from slack_bolt.context.set_title import SetTitle @@ -105,6 +106,8 @@ def handle_buttons(args): """`save_thread_context()` utility function for AI Agents & Assistants""" agent: Optional[BoltAgent] """`agent` listener argument for AI Agents & Assistants""" + say_stream: Optional[SayStream] + """`say_stream()` utility function for AI Agents & Assistants""" # middleware next: Callable[[], None] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -139,6 +142,7 @@ def __init__( get_thread_context: Optional[GetThreadContext] = None, save_thread_context: Optional[SaveThreadContext] = None, agent: Optional[BoltAgent] = None, + say_stream: Optional[SayStream] = None, # As this method is not supposed to be invoked by bolt-python users, # the naming conflict with the built-in one affects # only the internals of this method @@ -173,6 +177,7 @@ def __init__( self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context self.agent = agent + self.say_stream = say_stream self.next: Callable[[], None] = next self.next_: Callable[[], None] = next diff --git a/slack_bolt/kwargs_injection/async_args.py b/slack_bolt/kwargs_injection/async_args.py index 1f1dde024..19719e900 100644 --- a/slack_bolt/kwargs_injection/async_args.py +++ b/slack_bolt/kwargs_injection/async_args.py @@ -10,6 +10,7 @@ from slack_bolt.context.get_thread_context.async_get_thread_context import AsyncGetThreadContext from slack_bolt.context.save_thread_context.async_save_thread_context import AsyncSaveThreadContext from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream from slack_bolt.context.set_status.async_set_status import AsyncSetStatus from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts from slack_bolt.context.set_title.async_set_title import AsyncSetTitle @@ -104,6 +105,8 @@ async def handle_buttons(args): """`save_thread_context()` utility function for AI Agents & Assistants""" agent: Optional[AsyncBoltAgent] """`agent` listener argument for AI Agents & Assistants""" + say_stream: Optional[AsyncSayStream] + """`say_stream()` utility function for AI Agents & Assistants""" # middleware next: Callable[[], Awaitable[None]] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -138,6 +141,7 @@ def __init__( get_thread_context: Optional[AsyncGetThreadContext] = None, save_thread_context: Optional[AsyncSaveThreadContext] = None, agent: Optional[AsyncBoltAgent] = None, + say_stream: Optional[AsyncSayStream] = None, next: Callable[[], Awaitable[None]], **kwargs, # noqa ): @@ -169,6 +173,7 @@ def __init__( self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context self.agent = agent + self.say_stream = say_stream self.next: Callable[[], Awaitable[None]] = next self.next_: Callable[[], Awaitable[None]] = next diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index aa84b2d11..534fb6133 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -60,6 +60,7 @@ def build_async_required_kwargs( "set_suggested_prompts": request.context.set_suggested_prompts, "get_thread_context": request.context.get_thread_context, "save_thread_context": request.context.save_thread_context, + "say_stream": request.context.say_stream, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 5cd410a07..101e00099 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -59,6 +59,7 @@ def build_required_kwargs( "set_title": request.context.set_title, "set_suggested_prompts": request.context.set_suggested_prompts, "save_thread_context": request.context.save_thread_context, + "say_stream": request.context.say_stream, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function diff --git a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py index 0b43c21ce..08851c1eb 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py @@ -2,6 +2,7 @@ from slack_bolt.context.assistant.async_assistant_utilities import AsyncAssistantUtilities from slack_bolt.context.assistant.thread_context_store.async_store import AsyncAssistantThreadContextStore +from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.request.payload_utils import is_assistant_event, to_event @@ -36,4 +37,15 @@ async def async_process( req.context["set_suggested_prompts"] = assistant.set_suggested_prompts req.context["get_thread_context"] = assistant.get_thread_context req.context["save_thread_context"] = assistant.save_thread_context + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts = req.context.thread_ts or event.get("ts") + if req.context.channel_id and thread_ts: + req.context["say_stream"] = AsyncSayStream( + client=req.context.client, + channel=req.context.channel_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, + recipient_user_id=req.context.user_id, + thread_ts=thread_ts, + ) return await next() diff --git a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py index 4963ea67d..38a62c0c8 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py @@ -2,6 +2,7 @@ from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore +from slack_bolt.context.say_stream.say_stream import SayStream from slack_bolt.middleware import Middleware from slack_bolt.request.payload_utils import is_assistant_event, to_event from slack_bolt.request.request import BoltRequest @@ -30,4 +31,15 @@ def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], Bo req.context["set_suggested_prompts"] = assistant.set_suggested_prompts req.context["get_thread_context"] = assistant.get_thread_context req.context["save_thread_context"] = assistant.save_thread_context + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts = req.context.thread_ts or event.get("ts") + if req.context.channel_id and thread_ts: + req.context["say_stream"] = SayStream( + client=req.context.client, + channel=req.context.channel_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, + recipient_user_id=req.context.user_id, + thread_ts=thread_ts, + ) return next() diff --git a/tests/scenario_tests/test_events_say_stream.py b/tests/scenario_tests/test_events_say_stream.py new file mode 100644 index 000000000..75b0c612c --- /dev/null +++ b/tests/scenario_tests/test_events_say_stream.py @@ -0,0 +1,238 @@ +import json +import time +from urllib.parse import quote + +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest, BoltContext +from slack_bolt.context.say_stream.say_stream import SayStream +from slack_bolt.middleware.assistant import Assistant +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.scenario_tests.test_app import app_mention_event_body +from tests.scenario_tests.test_events_assistant import ( + thread_started_event_body, + user_message_event_body as threaded_user_message_event_body, +) +from tests.scenario_tests.test_message_bot import bot_message_event_payload, user_message_event_payload +from tests.scenario_tests.test_view_submission import body as view_submission_body +from tests.utils import remove_os_env_temporarily, restore_os_env + + +def assert_target_called(called: dict, timeout: float = 1.0): + deadline = time.time() + timeout + while called["value"] is not True and time.time() < deadline: + time.sleep(0.1) + assert called["value"] is True + + +class TestEventsSayStream: + 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_say_stream_injected_for_app_mention(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.event("app_mention") + def handle_mention(say_stream: SayStream, context: BoltContext): + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream == context.say_stream + assert say_stream.channel == "C111" + assert say_stream.thread_ts == "1595926230.009600" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id + called["value"] = True + + request = BoltRequest(body=app_mention_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_say_stream_with_org_level_install(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.event("app_mention") + def handle_mention(say_stream: SayStream, context: BoltContext): + assert context.team_id is None + assert context.enterprise_id == "E111" + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream.recipient_team_id == "E111" + called["value"] = True + + request = BoltRequest(body=org_app_mention_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_say_stream_injected_for_threaded_message(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.event("message") + def handle_message(say_stream: SayStream, context: BoltContext): + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream == context.say_stream + assert say_stream.channel == "D111" + assert say_stream.thread_ts == "1726133698.626339" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id + called["value"] = True + + request = BoltRequest(body=threaded_user_message_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_say_stream_in_user_message(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.message("") + def handle_user_message(say_stream: SayStream, context: BoltContext): + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream == context.say_stream + assert say_stream.channel == "C111" + assert say_stream.thread_ts == "1610261659.001400" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id + called["value"] = True + + request = BoltRequest(body=user_message_event_payload, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_say_stream_in_bot_message(self): + app = App(client=self.web_client) + called = {"value": False} + + @app.message("") + def handle_bot_message(say_stream: SayStream, context: BoltContext): + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream == context.say_stream + assert say_stream.channel == "C111" + assert say_stream.thread_ts == "1610261539.000900" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id + called["value"] = True + + request = BoltRequest(body=bot_message_event_payload, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_say_stream_in_assistant_thread_started(self): + app = App(client=self.web_client) + assistant = Assistant() + called = {"value": False} + + @assistant.thread_started + def start_thread(say_stream: SayStream, context: BoltContext): + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream == context.say_stream + assert say_stream.channel == "D111" + assert say_stream.thread_ts == "1726133698.626339" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id + called["value"] = True + + app.assistant(assistant) + + request = BoltRequest(body=thread_started_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_say_stream_in_assistant_user_message(self): + app = App(client=self.web_client) + assistant = Assistant() + called = {"value": False} + + @assistant.user_message + def handle_user_message(say_stream: SayStream, context: BoltContext): + assert say_stream is not None + assert isinstance(say_stream, SayStream) + assert say_stream == context.say_stream + assert say_stream.channel == "D111" + assert say_stream.thread_ts == "1726133698.626339" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id + called["value"] = True + + app.assistant(assistant) + + request = BoltRequest(body=threaded_user_message_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + def test_say_stream_is_none_for_view_submission(self): + app = App(client=self.web_client, request_verification_enabled=False) + called = {"value": False} + + @app.view("view-id") + def handle_view(ack, say_stream, context: BoltContext): + ack() + assert say_stream is None + assert context.say_stream is None + called["value"] = True + + request = BoltRequest( + body=f"payload={quote(json.dumps(view_submission_body))}", + ) + response = app.dispatch(request) + assert response.status == 200 + assert_target_called(called) + + +org_app_mention_event_body = { + "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, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": None, + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, +} diff --git a/tests/scenario_tests_async/test_events_say_stream.py b/tests/scenario_tests_async/test_events_say_stream.py new file mode 100644 index 000000000..c24bc7bfc --- /dev/null +++ b/tests/scenario_tests_async/test_events_say_stream.py @@ -0,0 +1,250 @@ +import asyncio +import json +import time +from urllib.parse import quote + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.async_app import AsyncAssistant +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.scenario_tests_async.test_app import app_mention_event_body +from tests.scenario_tests_async.test_events_assistant import user_message_event_body as threaded_user_message_event_body +from tests.scenario_tests_async.test_events_assistant import thread_started_event_body, user_message_event_body +from tests.scenario_tests_async.test_message_bot import bot_message_event_payload, user_message_event_payload +from tests.scenario_tests_async.test_view_submission import body as view_submission_body +from tests.utils import remove_os_env_temporarily, restore_os_env + + +async def assert_target_called(called: dict, timeout: float = 0.5): + deadline = time.time() + timeout + while called["value"] is not True and time.time() < deadline: + await asyncio.sleep(0.1) + assert called["value"] is True + + +class TestAsyncEventsSayStream: + 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(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_say_stream_injected_for_app_mention(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.event("app_mention") + async def handle_mention(say_stream: AsyncSayStream, context: AsyncBoltContext): + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream == context.say_stream + assert say_stream.channel == "C111" + assert say_stream.thread_ts == "1595926230.009600" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id + called["value"] = True + + request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_say_stream_with_org_level_install(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.event("app_mention") + async def handle_mention(say_stream: AsyncSayStream, context: AsyncBoltContext): + assert context.team_id is None + assert context.enterprise_id == "E111" + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream.recipient_team_id == "E111" + called["value"] = True + + request = AsyncBoltRequest(body=org_app_mention_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_say_stream_injected_for_threaded_message(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.event("message") + async def handle_message(say_stream: AsyncSayStream, context: AsyncBoltContext): + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream == context.say_stream + assert say_stream.channel == "D111" + assert say_stream.thread_ts == "1726133698.626339" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id + called["value"] = True + + request = AsyncBoltRequest(body=threaded_user_message_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_say_stream_in_user_message(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.message("") + async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltContext): + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream == context.say_stream + assert say_stream.channel == "C111" + assert say_stream.thread_ts == "1610261659.001400" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id + called["value"] = True + + request = AsyncBoltRequest(body=user_message_event_payload, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_say_stream_in_bot_message(self): + app = AsyncApp(client=self.web_client) + called = {"value": False} + + @app.message("") + async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltContext): + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream == context.say_stream + assert say_stream.channel == "C111" + assert say_stream.thread_ts == "1610261539.000900" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id + called["value"] = True + + request = AsyncBoltRequest(body=bot_message_event_payload, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_say_stream_in_assistant_thread_started(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + called = {"value": False} + + @assistant.thread_started + async def start_thread(say_stream: AsyncSayStream, context: AsyncBoltContext): + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream == context.say_stream + assert say_stream.channel == "D111" + assert say_stream.thread_ts == "1726133698.626339" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id + called["value"] = True + + app.assistant(assistant) + + request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_say_stream_in_assistant_user_message(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + called = {"value": False} + + @assistant.user_message + async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltContext): + assert say_stream is not None + assert isinstance(say_stream, AsyncSayStream) + assert say_stream == context.say_stream + assert say_stream.channel == "D111" + assert say_stream.thread_ts == "1726133698.626339" + assert say_stream.recipient_team_id == context.team_id + assert say_stream.recipient_user_id == context.user_id + called["value"] = True + + app.assistant(assistant) + + request = AsyncBoltRequest(body=user_message_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + @pytest.mark.asyncio + async def test_say_stream_is_none_for_view_submission(self): + app = AsyncApp(client=self.web_client, request_verification_enabled=False) + called = {"value": False} + + @app.view("view-id") + async def handle_view(ack, say_stream, context: AsyncBoltContext): + await ack() + assert say_stream is None + assert context.say_stream is None + called["value"] = True + + request = AsyncBoltRequest( + body=f"payload={quote(json.dumps(view_submission_body))}", + ) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called(called) + + +org_app_mention_event_body = { + "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, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": None, + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, +} diff --git a/tests/slack_bolt/context/test_say_stream.py b/tests/slack_bolt/context/test_say_stream.py new file mode 100644 index 000000000..c8f4c3a31 --- /dev/null +++ b/tests/slack_bolt/context/test_say_stream.py @@ -0,0 +1,103 @@ +import pytest +from slack_sdk import WebClient + +from slack_bolt.context.say_stream.say_stream import SayStream +from slack_bolt.warning import ExperimentalWarning +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server + + +class TestSayStream: + default_chat_stream_buffer_size = WebClient.chat_stream.__kwdefaults__["buffer_size"] + + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_missing_channel_raises(self): + say_stream = SayStream(client=self.web_client, channel=None, thread_ts="111.222") + with pytest.warns(ExperimentalWarning): + with pytest.raises(ValueError, match="channel"): + say_stream() + + def test_missing_thread_ts_raises(self): + say_stream = SayStream(client=self.web_client, channel="C111", thread_ts=None) + with pytest.warns(ExperimentalWarning): + with pytest.raises(ValueError, match="thread_ts"): + say_stream() + + def test_default_params(self): + say_stream = SayStream( + client=self.web_client, + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + ) + stream = say_stream() + + assert stream._buffer_size == self.default_chat_stream_buffer_size + assert stream._stream_args == { + "channel": "C111", + "thread_ts": "111.222", + "recipient_team_id": "T111", + "recipient_user_id": "U111", + "task_display_mode": None, + } + + def test_parameter_overrides(self): + say_stream = SayStream( + client=self.web_client, + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + ) + stream = say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") + + assert stream._buffer_size == self.default_chat_stream_buffer_size + assert stream._stream_args == { + "channel": "C222", + "thread_ts": "333.444", + "recipient_team_id": "T222", + "recipient_user_id": "U222", + "task_display_mode": None, + } + + def test_buffer_size_overrides(self): + say_stream = SayStream( + client=self.web_client, + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + ) + stream = say_stream( + buffer_size=100, + channel="C222", + thread_ts="333.444", + recipient_team_id="T222", + recipient_user_id="U222", + ) + + assert stream._buffer_size == 100 + assert stream._stream_args == { + "channel": "C222", + "thread_ts": "333.444", + "recipient_team_id": "T222", + "recipient_user_id": "U222", + "task_display_mode": None, + } + + def test_experimental_warning(self): + say_stream = SayStream( + client=self.web_client, + channel="C111", + thread_ts="111.222", + ) + with pytest.warns(ExperimentalWarning, match="say_stream is experimental"): + say_stream() diff --git a/tests/slack_bolt_async/context/test_async_say_stream.py b/tests/slack_bolt_async/context/test_async_say_stream.py new file mode 100644 index 000000000..fbc4c5c7e --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_say_stream.py @@ -0,0 +1,117 @@ +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream +from slack_bolt.warning import ExperimentalWarning +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncSayStream: + default_chat_stream_buffer_size = AsyncWebClient.chat_stream.__kwdefaults__["buffer_size"] + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_missing_channel_raises(self): + say_stream = AsyncSayStream(client=self.web_client, channel=None, thread_ts="111.222") + with pytest.warns(ExperimentalWarning): + with pytest.raises(ValueError, match="channel"): + await say_stream() + + @pytest.mark.asyncio + async def test_missing_thread_ts_raises(self): + say_stream = AsyncSayStream(client=self.web_client, channel="C111", thread_ts=None) + with pytest.warns(ExperimentalWarning): + with pytest.raises(ValueError, match="thread_ts"): + await say_stream() + + @pytest.mark.asyncio + async def test_default_params(self): + say_stream = AsyncSayStream( + client=self.web_client, + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + ) + stream = await say_stream() + + assert stream._buffer_size == self.default_chat_stream_buffer_size + assert stream._stream_args == { + "channel": "C111", + "thread_ts": "111.222", + "recipient_team_id": "T111", + "recipient_user_id": "U111", + "task_display_mode": None, + } + + @pytest.mark.asyncio + async def test_parameter_overrides(self): + say_stream = AsyncSayStream( + client=self.web_client, + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + ) + stream = await say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") + + assert stream._buffer_size == self.default_chat_stream_buffer_size + assert stream._stream_args == { + "channel": "C222", + "thread_ts": "333.444", + "recipient_team_id": "T222", + "recipient_user_id": "U222", + "task_display_mode": None, + } + + @pytest.mark.asyncio + async def test_buffer_size_overrides(self): + say_stream = AsyncSayStream( + client=self.web_client, + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + ) + stream = await say_stream( + buffer_size=100, + channel="C222", + thread_ts="333.444", + recipient_team_id="T222", + recipient_user_id="U222", + ) + + assert stream._buffer_size == 100 + assert stream._stream_args == { + "channel": "C222", + "thread_ts": "333.444", + "recipient_team_id": "T222", + "recipient_user_id": "U222", + "task_display_mode": None, + } + + @pytest.mark.asyncio + async def test_experimental_warning(self): + say_stream = AsyncSayStream( + client=self.web_client, + channel="C111", + thread_ts="111.222", + ) + with pytest.warns(ExperimentalWarning, match="say_stream is experimental"): + await say_stream() From 7aa415ff63b9ec4b5a241cb5886fe4ff9f8588eb Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 19 Mar 2026 11:44:20 -0700 Subject: [PATCH 850/865] fix: improve the robustness of the payload extract logic (#1464) --- slack_bolt/request/internals.py | 42 +++++++++++----------- tests/slack_bolt/request/test_internals.py | 25 +++++++++++++ 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 466f5daaf..15d1e7367 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -65,10 +65,10 @@ def extract_enterprise_id(payload: Dict[str, Any]) -> Optional[str]: return extract_enterprise_id(payload["authorizations"][0]) if "enterprise_id" in payload: return payload.get("enterprise_id") - if payload.get("team") is not None and "enterprise_id" in payload["team"]: + if isinstance(payload.get("team"), dict) and "enterprise_id" in payload["team"]: # In the case where the type is view_submission return payload["team"].get("enterprise_id") - if payload.get("event") is not None: + if isinstance(payload.get("event"), dict): return extract_enterprise_id(payload["event"]) return None @@ -88,13 +88,13 @@ def extract_actor_enterprise_id(payload: Dict[str, Any]) -> Optional[str]: def extract_team_id(payload: Dict[str, Any]) -> Optional[str]: - app_installed_team_id = payload.get("view", {}).get("app_installed_team_id") - if app_installed_team_id is not None: + view = payload.get("view") + if isinstance(view, dict) and view.get("app_installed_team_id") is not None: # view_submission payloads can have `view.app_installed_team_id` when a modal view that was opened # in a different workspace via some operations inside a Slack Connect channel. # Note that the same for enterprise_id does not exist. When you need to know the enterprise_id as well, # you have to run some query toward your InstallationStore to know the org where the team_id belongs to. - return app_installed_team_id + return view["app_installed_team_id"] if payload.get("team") is not None: # With org-wide installations, payload.team in interactivity payloads can be None # You need to extract either payload.user.team_id or payload.view.team_id as below @@ -109,12 +109,12 @@ def extract_team_id(payload: Dict[str, Any]) -> Optional[str]: return extract_team_id(payload["authorizations"][0]) if "team_id" in payload: return payload.get("team_id") - if payload.get("event") is not None: + if isinstance(payload.get("event"), dict): return extract_team_id(payload["event"]) - if payload.get("user") is not None: + if isinstance(payload.get("user"), dict): return payload["user"]["team_id"] - if payload.get("view") is not None: - return payload.get("view", {})["team_id"] + if isinstance(payload.get("view"), dict): + return payload["view"]["team_id"] return None @@ -169,12 +169,12 @@ def extract_user_id(payload: Dict[str, Any]) -> Optional[str]: return user.get("id") if "user_id" in payload: return payload.get("user_id") - if payload.get("event") is not None: + if isinstance(payload.get("event"), dict): return extract_user_id(payload["event"]) - if payload.get("message") is not None: + if isinstance(payload.get("message"), dict): # message_changed: body["event"]["message"] return extract_user_id(payload["message"]) - if payload.get("previous_message") is not None: + if isinstance(payload.get("previous_message"), dict): # message_deleted: body["event"]["previous_message"] return extract_user_id(payload["previous_message"]) return None @@ -202,12 +202,12 @@ def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: return channel.get("id") if "channel_id" in payload: return payload.get("channel_id") - if payload.get("event") is not None: + if isinstance(payload.get("event"), dict): return extract_channel_id(payload["event"]) - if payload.get("item") is not None: + if isinstance(payload.get("item"), dict): # reaction_added: body["event"]["item"] return extract_channel_id(payload["item"]) - if payload.get("assistant_thread") is not None: + if isinstance(payload.get("assistant_thread"), dict): # assistant_thread_started return extract_channel_id(payload["assistant_thread"]) return None @@ -217,7 +217,7 @@ def extract_thread_ts(payload: Dict[str, Any]) -> Optional[str]: thread_ts = payload.get("thread_ts") if thread_ts is not None: return thread_ts - if payload.get("event") is not None: + if isinstance(payload.get("event"), dict): return extract_thread_ts(payload["event"]) if isinstance(payload.get("assistant_thread"), dict): return extract_thread_ts(payload["assistant_thread"]) @@ -231,9 +231,9 @@ def extract_thread_ts(payload: Dict[str, Any]) -> Optional[str]: def extract_function_execution_id(payload: Dict[str, Any]) -> Optional[str]: if payload.get("function_execution_id") is not None: return payload.get("function_execution_id") - if payload.get("event") is not None: + if isinstance(payload.get("event"), dict): return extract_function_execution_id(payload["event"]) - if payload.get("function_data") is not None: + if isinstance(payload.get("function_data"), dict): return payload["function_data"].get("execution_id") return None @@ -241,15 +241,15 @@ def extract_function_execution_id(payload: Dict[str, Any]) -> Optional[str]: def extract_function_bot_access_token(payload: Dict[str, Any]) -> Optional[str]: if payload.get("bot_access_token") is not None: return payload.get("bot_access_token") - if payload.get("event") is not None: + if isinstance(payload.get("event"), dict): return payload["event"].get("bot_access_token") return None def extract_function_inputs(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: - if payload.get("event") is not None: + if isinstance(payload.get("event"), dict): return payload["event"].get("inputs") - if payload.get("function_data") is not None: + if isinstance(payload.get("function_data"), dict): return payload["function_data"].get("inputs") return None diff --git a/tests/slack_bolt/request/test_internals.py b/tests/slack_bolt/request/test_internals.py index 0b267e3de..8cccf0431 100644 --- a/tests/slack_bolt/request/test_internals.py +++ b/tests/slack_bolt/request/test_internals.py @@ -1248,3 +1248,28 @@ def test_slack_connect_patterns(self): assert extract_actor_enterprise_id(request) == actor_enterprise_id assert extract_actor_team_id(request) == actor_team_id assert extract_actor_user_id(request) == actor_user_id + + def test_extraction_functions_invalid_dict_keys(self): + invalid_payloads = { + "event": {"event": "some_event_type"}, + "user": {"user": "U12345"}, + "team": {"team": "T12345"}, + "view": {"view": "V12345"}, + "message": {"message": "some text"}, + "item": {"item": "item_id"}, + "function_data": {"function_data": "fd_123"}, + "assistant_thread": {"assistant_thread": "at_123"}, + "previous_message": {"previous_message": "old_msg"}, + } + + for _, payload in invalid_payloads.items(): + # We only verify no TypeError/AttributeError is raised and that functions which + # would try to subscript the string value return None instead of crashing. + extract_enterprise_id(payload) + extract_team_id(payload) + extract_user_id(payload) + extract_channel_id(payload) + extract_thread_ts(payload) + extract_function_execution_id(payload) + extract_function_bot_access_token(payload) + extract_function_inputs(payload) From ba7df02bdac421d9a23ebe984cc51ab33da6b693 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 19 Mar 2026 13:01:15 -0700 Subject: [PATCH 851/865] feat: surface the set_status argument to listeners if required event details are available (#1465) --- .../context/assistant/assistant_utilities.py | 8 + .../assistant/async_assistant_utilities.py | 8 + .../async_attaching_agent_kwargs.py | 7 +- .../attaching_agent_kwargs.py | 7 +- ...est_events_assistant_without_middleware.py | 6 +- .../scenario_tests/test_events_set_status.py | 171 ++++++++++++++++ ...est_events_assistant_without_middleware.py | 6 +- .../test_events_set_status.py | 183 ++++++++++++++++++ .../test_attaching_agent_kwargs.py | 18 +- .../test_async_attaching_agent_kwargs.py | 18 +- 10 files changed, 414 insertions(+), 18 deletions(-) create mode 100644 tests/scenario_tests/test_events_set_status.py create mode 100644 tests/scenario_tests_async/test_events_set_status.py diff --git a/slack_bolt/context/assistant/assistant_utilities.py b/slack_bolt/context/assistant/assistant_utilities.py index 53500efdb..42f05c94b 100644 --- a/slack_bolt/context/assistant/assistant_utilities.py +++ b/slack_bolt/context/assistant/assistant_utilities.py @@ -1,3 +1,4 @@ +import warnings from typing import Optional from slack_sdk.web import WebClient @@ -51,6 +52,13 @@ def is_valid(self) -> bool: @property def set_status(self) -> SetStatus: + warnings.warn( + "AssistantUtilities.set_status is deprecated. " + "Use the set_status argument directly in your listener function " + "or access it via context.set_status instead.", + DeprecationWarning, + stacklevel=2, + ) return SetStatus(self.client, self.channel_id, self.thread_ts) @property diff --git a/slack_bolt/context/assistant/async_assistant_utilities.py b/slack_bolt/context/assistant/async_assistant_utilities.py index 5a7324e99..b40b2619c 100644 --- a/slack_bolt/context/assistant/async_assistant_utilities.py +++ b/slack_bolt/context/assistant/async_assistant_utilities.py @@ -1,3 +1,4 @@ +import warnings from typing import Optional from slack_sdk.web.async_client import AsyncWebClient @@ -54,6 +55,13 @@ def is_valid(self) -> bool: @property def set_status(self) -> AsyncSetStatus: + warnings.warn( + "AsyncAssistantUtilities.set_status is deprecated. " + "Use the set_status argument directly in your listener function " + "or access it via context.set_status instead.", + DeprecationWarning, + stacklevel=2, + ) return AsyncSetStatus(self.client, self.channel_id, self.thread_ts) @property diff --git a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py index 08851c1eb..82f1a7671 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py @@ -3,6 +3,7 @@ from slack_bolt.context.assistant.async_assistant_utilities import AsyncAssistantUtilities from slack_bolt.context.assistant.thread_context_store.async_store import AsyncAssistantThreadContextStore from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream +from slack_bolt.context.set_status.async_set_status import AsyncSetStatus from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.request.payload_utils import is_assistant_event, to_event @@ -32,7 +33,6 @@ async def async_process( thread_context_store=self.thread_context_store, ) req.context["say"] = assistant.say - req.context["set_status"] = assistant.set_status req.context["set_title"] = assistant.set_title req.context["set_suggested_prompts"] = assistant.set_suggested_prompts req.context["get_thread_context"] = assistant.get_thread_context @@ -41,6 +41,11 @@ async def async_process( # TODO: in the future we might want to introduce a "proper" extract_ts utility thread_ts = req.context.thread_ts or event.get("ts") if req.context.channel_id and thread_ts: + req.context["set_status"] = AsyncSetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts, + ) req.context["say_stream"] = AsyncSayStream( client=req.context.client, channel=req.context.channel_id, diff --git a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py index 38a62c0c8..70f41d561 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py @@ -3,6 +3,7 @@ from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore from slack_bolt.context.say_stream.say_stream import SayStream +from slack_bolt.context.set_status.set_status import SetStatus from slack_bolt.middleware import Middleware from slack_bolt.request.payload_utils import is_assistant_event, to_event from slack_bolt.request.request import BoltRequest @@ -26,7 +27,6 @@ def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], Bo thread_context_store=self.thread_context_store, ) req.context["say"] = assistant.say - req.context["set_status"] = assistant.set_status req.context["set_title"] = assistant.set_title req.context["set_suggested_prompts"] = assistant.set_suggested_prompts req.context["get_thread_context"] = assistant.get_thread_context @@ -35,6 +35,11 @@ def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], Bo # TODO: in the future we might want to introduce a "proper" extract_ts utility thread_ts = req.context.thread_ts or event.get("ts") if req.context.channel_id and thread_ts: + req.context["set_status"] = SetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts, + ) req.context["say_stream"] = SayStream( client=req.context.client, channel=req.context.channel_id, diff --git a/tests/scenario_tests/test_events_assistant_without_middleware.py b/tests/scenario_tests/test_events_assistant_without_middleware.py index 36d86c43a..6a9381a33 100644 --- a/tests/scenario_tests/test_events_assistant_without_middleware.py +++ b/tests/scenario_tests/test_events_assistant_without_middleware.py @@ -180,7 +180,7 @@ def handle_message_event( ): assert context.thread_ts == "1726133698.626339" assert say.thread_ts == None - assert set_status is None + assert set_status is not None assert set_title is None assert set_suggested_prompts is None assert get_thread_context is None @@ -208,7 +208,7 @@ def handle_message_event( ): assert context.thread_ts == "1726133698.626339" assert say.thread_ts == None - assert set_status is None + assert set_status is not None assert set_title is None assert set_suggested_prompts is None assert get_thread_context is None @@ -236,7 +236,7 @@ def handle_message_event( ): assert context.thread_ts == "1726133698.626339" assert say.thread_ts == None - assert set_status is None + assert set_status is not None assert set_title is None assert set_suggested_prompts is None assert get_thread_context is None diff --git a/tests/scenario_tests/test_events_set_status.py b/tests/scenario_tests/test_events_set_status.py new file mode 100644 index 000000000..2dbdd38b8 --- /dev/null +++ b/tests/scenario_tests/test_events_set_status.py @@ -0,0 +1,171 @@ +import json +from threading import Event +from urllib.parse import quote + +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltContext, BoltRequest +from slack_bolt.context.set_status.set_status import SetStatus +from slack_bolt.middleware.assistant import Assistant +from tests.mock_web_api_server import ( + assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) +from tests.scenario_tests.test_app import app_mention_event_body +from tests.scenario_tests.test_events_assistant import thread_started_event_body +from tests.scenario_tests.test_events_assistant import user_message_event_body as threaded_user_message_event_body +from tests.scenario_tests.test_message_bot import bot_message_event_payload, user_message_event_payload +from tests.scenario_tests.test_view_submission import body as view_submission_body +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestEventsSetStatus: + 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_set_status_injected_for_app_mention(self): + app = App(client=self.web_client) + + @app.event("app_mention") + def handle_mention(set_status: SetStatus, context: BoltContext): + assert set_status is not None + assert isinstance(set_status, SetStatus) + assert set_status == context.set_status + assert set_status.channel_id == "C111" + assert set_status.thread_ts == "1595926230.009600" + set_status(status="Thinking...") + + request = BoltRequest(body=app_mention_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/assistant.threads.setStatus", min_count=1) + + def test_set_status_injected_for_threaded_message(self): + app = App(client=self.web_client) + + @app.event("message") + def handle_message(set_status: SetStatus, context: BoltContext): + assert set_status is not None + assert isinstance(set_status, SetStatus) + assert set_status == context.set_status + assert set_status.channel_id == "D111" + assert set_status.thread_ts == "1726133698.626339" + set_status(status="Thinking...") + + request = BoltRequest(body=threaded_user_message_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/assistant.threads.setStatus", min_count=1) + + def test_set_status_in_user_message(self): + app = App(client=self.web_client) + + @app.message("") + def handle_user_message(set_status: SetStatus, context: BoltContext): + assert set_status is not None + assert isinstance(set_status, SetStatus) + assert set_status == context.set_status + assert set_status.channel_id == "C111" + assert set_status.thread_ts == "1610261659.001400" + set_status(status="Thinking...") + + request = BoltRequest(body=user_message_event_payload, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/assistant.threads.setStatus", min_count=1) + + def test_set_status_in_bot_message(self): + app = App(client=self.web_client) + + @app.message("") + def handle_bot_message(set_status: SetStatus, context: BoltContext): + assert set_status is not None + assert isinstance(set_status, SetStatus) + assert set_status == context.set_status + assert set_status.channel_id == "C111" + assert set_status.thread_ts == "1610261539.000900" + set_status(status="Thinking...") + + request = BoltRequest(body=bot_message_event_payload, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/assistant.threads.setStatus", min_count=1) + + def test_set_status_in_assistant_thread_started(self): + app = App(client=self.web_client) + assistant = Assistant() + + @assistant.thread_started + def start_thread(set_status: SetStatus, context: BoltContext): + assert set_status is not None + assert isinstance(set_status, SetStatus) + assert set_status == context.set_status + assert set_status.channel_id == "D111" + assert set_status.thread_ts == "1726133698.626339" + set_status(status="Thinking...") + + app.assistant(assistant) + + request = BoltRequest(body=thread_started_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/assistant.threads.setStatus", min_count=1) + + def test_set_status_in_assistant_user_message(self): + app = App(client=self.web_client) + assistant = Assistant() + + @assistant.user_message + def handle_user_message(set_status: SetStatus, context: BoltContext): + assert set_status is not None + assert isinstance(set_status, SetStatus) + assert set_status == context.set_status + assert set_status.channel_id == "D111" + assert set_status.thread_ts == "1726133698.626339" + set_status(status="Thinking...") + + app.assistant(assistant) + + request = BoltRequest(body=threaded_user_message_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/assistant.threads.setStatus", min_count=1) + + def test_set_status_is_none_for_view_submission(self): + app = App(client=self.web_client, request_verification_enabled=False) + listener_called = Event() + + @app.view("view-id") + def handle_view(ack, set_status, context: BoltContext): + ack() + assert set_status is None + assert context.set_status is None + listener_called.set() + + request = BoltRequest( + body=f"payload={quote(json.dumps(view_submission_body))}", + ) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert listener_called.is_set() diff --git a/tests/scenario_tests_async/test_events_assistant_without_middleware.py b/tests/scenario_tests_async/test_events_assistant_without_middleware.py index be6c2b166..916dfd467 100644 --- a/tests/scenario_tests_async/test_events_assistant_without_middleware.py +++ b/tests/scenario_tests_async/test_events_assistant_without_middleware.py @@ -197,7 +197,7 @@ async def handle_message_event( ): assert context.thread_ts == "1726133698.626339" assert say.thread_ts == None - assert set_status is None + assert set_status is not None assert set_title is None assert set_suggested_prompts is None assert get_thread_context is None @@ -226,7 +226,7 @@ async def handle_message_event( ): assert context.thread_ts == "1726133698.626339" assert say.thread_ts == None - assert set_status is None + assert set_status is not None assert set_title is None assert set_suggested_prompts is None assert get_thread_context is None @@ -255,7 +255,7 @@ async def handle_message_event( ): assert context.thread_ts == "1726133698.626339" assert say.thread_ts == None - assert set_status is None + assert set_status is not None assert set_title is None assert set_suggested_prompts is None assert get_thread_context is None diff --git a/tests/scenario_tests_async/test_events_set_status.py b/tests/scenario_tests_async/test_events_set_status.py new file mode 100644 index 000000000..0e5be3349 --- /dev/null +++ b/tests/scenario_tests_async/test_events_set_status.py @@ -0,0 +1,183 @@ +import asyncio +import json +from urllib.parse import quote + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.async_app import AsyncAssistant +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.set_status.async_set_status import AsyncSetStatus +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + assert_auth_test_count_async, + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.scenario_tests_async.test_app import app_mention_event_body +from tests.scenario_tests_async.test_events_assistant import thread_started_event_body +from tests.scenario_tests_async.test_events_assistant import user_message_event_body as threaded_user_message_event_body +from tests.scenario_tests_async.test_message_bot import bot_message_event_payload, user_message_event_payload +from tests.scenario_tests_async.test_view_submission import body as view_submission_body +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEventsSetStatus: + 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(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_set_status_injected_for_app_mention(self): + app = AsyncApp(client=self.web_client) + + @app.event("app_mention") + async def handle_mention(set_status: AsyncSetStatus, context: AsyncBoltContext): + assert set_status is not None + assert isinstance(set_status, AsyncSetStatus) + assert set_status == context.set_status + assert set_status.channel_id == "C111" + assert set_status.thread_ts == "1595926230.009600" + await set_status(status="Thinking...") + + request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, path="/assistant.threads.setStatus", min_count=1) + + @pytest.mark.asyncio + async def test_set_status_injected_for_threaded_message(self): + app = AsyncApp(client=self.web_client) + + @app.event("message") + async def handle_message(set_status: AsyncSetStatus, context: AsyncBoltContext): + assert set_status is not None + assert isinstance(set_status, AsyncSetStatus) + assert set_status == context.set_status + assert set_status.channel_id == "D111" + assert set_status.thread_ts == "1726133698.626339" + await set_status(status="Thinking...") + + request = AsyncBoltRequest(body=threaded_user_message_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, path="/assistant.threads.setStatus", min_count=1) + + @pytest.mark.asyncio + async def test_set_status_in_user_message(self): + app = AsyncApp(client=self.web_client) + + @app.message("") + async def handle_user_message(set_status: AsyncSetStatus, context: AsyncBoltContext): + assert set_status is not None + assert isinstance(set_status, AsyncSetStatus) + assert set_status == context.set_status + assert set_status.channel_id == "C111" + assert set_status.thread_ts == "1610261659.001400" + await set_status(status="Thinking...") + + request = AsyncBoltRequest(body=user_message_event_payload, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, path="/assistant.threads.setStatus", min_count=1) + + @pytest.mark.asyncio + async def test_set_status_in_bot_message(self): + app = AsyncApp(client=self.web_client) + + @app.message("") + async def handle_user_message(set_status: AsyncSetStatus, context: AsyncBoltContext): + assert set_status is not None + assert isinstance(set_status, AsyncSetStatus) + assert set_status == context.set_status + assert set_status.channel_id == "C111" + assert set_status.thread_ts == "1610261539.000900" + await set_status(status="Thinking...") + + request = AsyncBoltRequest(body=bot_message_event_payload, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, path="/assistant.threads.setStatus", min_count=1) + + @pytest.mark.asyncio + async def test_set_status_in_assistant_thread_started(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + + @assistant.thread_started + async def start_thread(set_status: AsyncSetStatus, context: AsyncBoltContext): + assert set_status is not None + assert isinstance(set_status, AsyncSetStatus) + assert set_status == context.set_status + assert set_status.channel_id == "D111" + assert set_status.thread_ts == "1726133698.626339" + await set_status(status="Thinking...") + + app.assistant(assistant) + + request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, path="/assistant.threads.setStatus", min_count=1) + + @pytest.mark.asyncio + async def test_set_status_in_assistant_user_message(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + + @assistant.user_message + async def handle_user_message(set_status: AsyncSetStatus, context: AsyncBoltContext): + assert set_status is not None + assert isinstance(set_status, AsyncSetStatus) + assert set_status == context.set_status + assert set_status.channel_id == "D111" + assert set_status.thread_ts == "1726133698.626339" + await set_status(status="Thinking...") + + app.assistant(assistant) + + request = AsyncBoltRequest(body=threaded_user_message_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, path="/assistant.threads.setStatus", min_count=1) + + @pytest.mark.asyncio + async def test_set_status_is_none_for_view_submission(self): + app = AsyncApp(client=self.web_client, request_verification_enabled=False) + listener_called = asyncio.Event() + + @app.view("view-id") + async def handle_view(ack, set_status, context: AsyncBoltContext): + await ack() + assert set_status is None + assert context.set_status is None + listener_called.set() + + request = AsyncBoltRequest( + body=f"payload={quote(json.dumps(view_submission_body))}", + ) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + assert listener_called.is_set() diff --git a/tests/slack_bolt/middleware/attaching_agent_kwargs/test_attaching_agent_kwargs.py b/tests/slack_bolt/middleware/attaching_agent_kwargs/test_attaching_agent_kwargs.py index f56bd2e62..8e626fd0c 100644 --- a/tests/slack_bolt/middleware/attaching_agent_kwargs/test_attaching_agent_kwargs.py +++ b/tests/slack_bolt/middleware/attaching_agent_kwargs/test_attaching_agent_kwargs.py @@ -14,7 +14,7 @@ def next(): return BoltResponse(status=200) -AGENT_KWARGS = ("say", "set_status", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") +ASSISTANT_KWARGS = ("say", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") class TestAttachingAgentKwargs: @@ -26,9 +26,11 @@ def test_assistant_event_attaches_kwargs(self): resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) assert resp.status == 200 - for key in AGENT_KWARGS: + for key in ASSISTANT_KWARGS: assert key in req.context, f"{key} should be set on context" assert req.context["say"].thread_ts == "1726133698.626339" + assert "say_stream" in req.context + assert "set_status" in req.context def test_user_message_event_attaches_kwargs(self): middleware = AttachingAgentKwargs() @@ -38,9 +40,11 @@ def test_user_message_event_attaches_kwargs(self): resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) assert resp.status == 200 - for key in AGENT_KWARGS: + for key in ASSISTANT_KWARGS: assert key in req.context, f"{key} should be set on context" assert req.context["say"].thread_ts == "1726133698.626339" + assert "say_stream" in req.context + assert "set_status" in req.context def test_non_assistant_event_does_not_attach_kwargs(self): middleware = AttachingAgentKwargs() @@ -50,8 +54,10 @@ def test_non_assistant_event_does_not_attach_kwargs(self): resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) assert resp.status == 200 - for key in AGENT_KWARGS: + for key in ASSISTANT_KWARGS: assert key not in req.context, f"{key} should not be set on context" + assert "say_stream" in req.context + assert "set_status" in req.context def test_non_event_does_not_attach_kwargs(self): middleware = AttachingAgentKwargs() @@ -60,5 +66,7 @@ def test_non_event_does_not_attach_kwargs(self): resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) assert resp.status == 200 - for key in AGENT_KWARGS: + for key in ASSISTANT_KWARGS: assert key not in req.context, f"{key} should not be set on context" + assert "say_stream" not in req.context + assert "set_status" not in req.context diff --git a/tests/slack_bolt_async/middleware/attaching_agent_kwargs/test_async_attaching_agent_kwargs.py b/tests/slack_bolt_async/middleware/attaching_agent_kwargs/test_async_attaching_agent_kwargs.py index 55883e5f3..61aa0b59e 100644 --- a/tests/slack_bolt_async/middleware/attaching_agent_kwargs/test_async_attaching_agent_kwargs.py +++ b/tests/slack_bolt_async/middleware/attaching_agent_kwargs/test_async_attaching_agent_kwargs.py @@ -15,7 +15,7 @@ async def next(): return BoltResponse(status=200) -AGENT_KWARGS = ("say", "set_status", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") +ASSISTANT_KWARGS = ("say", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") class TestAsyncAttachingAgentKwargs: @@ -28,9 +28,11 @@ async def test_assistant_event_attaches_kwargs(self): resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) assert resp.status == 200 - for key in AGENT_KWARGS: + for key in ASSISTANT_KWARGS: assert key in req.context, f"{key} should be set on context" assert req.context["say"].thread_ts == "1726133698.626339" + assert "say_stream" in req.context + assert "set_status" in req.context @pytest.mark.asyncio async def test_user_message_event_attaches_kwargs(self): @@ -41,9 +43,11 @@ async def test_user_message_event_attaches_kwargs(self): resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) assert resp.status == 200 - for key in AGENT_KWARGS: + for key in ASSISTANT_KWARGS: assert key in req.context, f"{key} should be set on context" assert req.context["say"].thread_ts == "1726133698.626339" + assert "say_stream" in req.context + assert "set_status" in req.context @pytest.mark.asyncio async def test_non_assistant_event_does_not_attach_kwargs(self): @@ -54,8 +58,10 @@ async def test_non_assistant_event_does_not_attach_kwargs(self): resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) assert resp.status == 200 - for key in AGENT_KWARGS: + for key in ASSISTANT_KWARGS: assert key not in req.context, f"{key} should not be set on context" + assert "say_stream" in req.context + assert "set_status" in req.context @pytest.mark.asyncio async def test_non_event_does_not_attach_kwargs(self): @@ -65,5 +71,7 @@ async def test_non_event_does_not_attach_kwargs(self): resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) assert resp.status == 200 - for key in AGENT_KWARGS: + for key in ASSISTANT_KWARGS: assert key not in req.context, f"{key} should not be set on context" + assert "say_stream" not in req.context + assert "set_status" not in req.context From 6406058f35f3eee85539e73f87e995ee74ece2ed Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 20 Mar 2026 07:11:21 -0700 Subject: [PATCH 852/865] fix: Remove 'agent: BoltAgent' listener argument (#1466) --- AGENTS.md | 4 +- slack_bolt/__init__.py | 2 - slack_bolt/agent/__init__.py | 5 - slack_bolt/agent/agent.py | 139 ------ slack_bolt/agent/async_agent.py | 138 ------ slack_bolt/kwargs_injection/args.py | 5 - slack_bolt/kwargs_injection/async_args.py | 5 - slack_bolt/kwargs_injection/async_utils.py | 22 - slack_bolt/kwargs_injection/utils.py | 22 - tests/scenario_tests/test_events_agent.py | 162 ------- .../scenario_tests_async/test_events_agent.py | 169 -------- tests/slack_bolt/agent/__init__.py | 0 tests/slack_bolt/agent/test_agent.py | 365 ---------------- tests/slack_bolt_async/agent/__init__.py | 0 .../agent/test_async_agent.py | 399 ------------------ 15 files changed, 2 insertions(+), 1435 deletions(-) delete mode 100644 slack_bolt/agent/__init__.py delete mode 100644 slack_bolt/agent/agent.py delete mode 100644 slack_bolt/agent/async_agent.py delete mode 100644 tests/scenario_tests/test_events_agent.py delete mode 100644 tests/scenario_tests_async/test_events_agent.py delete mode 100644 tests/slack_bolt/agent/__init__.py delete mode 100644 tests/slack_bolt/agent/test_agent.py delete mode 100644 tests/slack_bolt_async/agent/__init__.py delete mode 100644 tests/slack_bolt_async/agent/test_async_agent.py diff --git a/AGENTS.md b/AGENTS.md index 57f2fa588..892a858e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,7 +152,7 @@ For FaaS environments (`process_before_response=True`), long-running handlers ex ### Kwargs Injection -Listeners receive arguments by parameter name. The framework inspects function signatures and injects matching args: `body`, `event`, `action`, `command`, `payload`, `context`, `client`, `ack`, `say`, `respond`, `logger`, `complete`, `fail`, `agent`, etc. Defined in `slack_bolt/kwargs_injection/args.py`. +Listeners receive arguments by parameter name. The framework inspects function signatures and injects matching args: `body`, `event`, `action`, `command`, `payload`, `context`, `client`, `ack`, `say`, `respond`, `logger`, `complete`, `fail`, etc. Defined in `slack_bolt/kwargs_injection/args.py`. ### Adapter System @@ -160,7 +160,7 @@ Each adapter in `slack_bolt/adapter/` converts between a web framework's request ### AI Agents & Assistants -`BoltAgent` (`slack_bolt/agent/`) provides `chat_stream()`, `set_status()`, and `set_suggested_prompts()` for AI-powered agents. `Assistant` middleware (`slack_bolt/middleware/assistant/`) handles assistant thread events. +`Assistant` middleware (`slack_bolt/middleware/assistant/`) handles assistant thread events. ## Key Development Patterns diff --git a/slack_bolt/__init__.py b/slack_bolt/__init__.py index dfe950bf2..d85453950 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -22,7 +22,6 @@ from .response import BoltResponse # AI Agents & Assistants -from .agent import BoltAgent from .middleware.assistant.assistant import ( Assistant, ) @@ -49,7 +48,6 @@ "CustomListenerMatcher", "BoltRequest", "BoltResponse", - "BoltAgent", "Assistant", "AssistantThreadContext", "AssistantThreadContextStore", diff --git a/slack_bolt/agent/__init__.py b/slack_bolt/agent/__init__.py deleted file mode 100644 index 4d751f27f..000000000 --- a/slack_bolt/agent/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .agent import BoltAgent - -__all__ = [ - "BoltAgent", -] diff --git a/slack_bolt/agent/agent.py b/slack_bolt/agent/agent.py deleted file mode 100644 index 523b0e33c..000000000 --- a/slack_bolt/agent/agent.py +++ /dev/null @@ -1,139 +0,0 @@ -from typing import Dict, List, Optional, Sequence, Union - -from slack_sdk import WebClient -from slack_sdk.web import SlackResponse -from slack_sdk.web.chat_stream import ChatStream - - -class BoltAgent: - """Agent listener argument for building AI-powered Slack agents. - - Experimental: - This API is experimental and may change in future releases. - - @app.event("app_mention") - def handle_mention(agent): - stream = agent.chat_stream() - stream.append(markdown_text="Hello!") - stream.stop() - """ - - def __init__( - self, - *, - client: WebClient, - channel_id: Optional[str] = None, - thread_ts: Optional[str] = None, - ts: Optional[str] = None, - team_id: Optional[str] = None, - user_id: Optional[str] = None, - ): - self._client = client - self._channel_id = channel_id - self._thread_ts = thread_ts - self._ts = ts - self._team_id = team_id - self._user_id = user_id - - def chat_stream( - self, - *, - channel: Optional[str] = None, - thread_ts: Optional[str] = None, - recipient_team_id: Optional[str] = None, - recipient_user_id: Optional[str] = None, - **kwargs, - ) -> ChatStream: - """Creates a ChatStream with defaults from event context. - - Each call creates a new instance. Create multiple for parallel streams. - - Args: - channel: Channel ID. Defaults to the channel from the event context. - thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. - recipient_team_id: Team ID of the recipient. Defaults to the team from the event context. - recipient_user_id: User ID of the recipient. Defaults to the user from the event context. - **kwargs: Additional arguments passed to ``WebClient.chat_stream()``. - - Returns: - A new ``ChatStream`` instance. - """ - provided = [arg for arg in (channel, thread_ts, recipient_team_id, recipient_user_id) if arg is not None] - if provided and len(provided) < 4: - raise ValueError( - "Either provide all of channel, thread_ts, recipient_team_id, and recipient_user_id, or none of them" - ) - # Argument validation is delegated to chat_stream() and the API - return self._client.chat_stream( - channel=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] - recipient_team_id=recipient_team_id or self._team_id, - recipient_user_id=recipient_user_id or self._user_id, - **kwargs, - ) - - def set_status( - self, - *, - status: str, - loading_messages: Optional[List[str]] = None, - channel_id: Optional[str] = None, - thread_ts: Optional[str] = None, - **kwargs, - ) -> SlackResponse: - """Sets the status of an assistant thread. - - Args: - status: The status text to display. - loading_messages: Optional list of loading messages to cycle through. - channel_id: Channel ID. Defaults to the channel from the event context. - thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. - **kwargs: Additional arguments passed to ``WebClient.assistant_threads_setStatus()``. - - Returns: - ``SlackResponse`` from the API call. - """ - return self._client.assistant_threads_setStatus( - channel_id=channel_id or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] - status=status, - loading_messages=loading_messages, - **kwargs, - ) - - def set_suggested_prompts( - self, - *, - prompts: Sequence[Union[str, Dict[str, str]]], - title: Optional[str] = None, - channel_id: Optional[str] = None, - thread_ts: Optional[str] = None, - **kwargs, - ) -> SlackResponse: - """Sets suggested prompts for an assistant thread. - - Args: - prompts: A sequence of prompts. Each prompt can be either a string - (used as both title and message) or a dict with 'title' and 'message' keys. - title: Optional title for the suggested prompts section. - channel_id: Channel ID. Defaults to the channel from the event context. - thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. - **kwargs: Additional arguments passed to ``WebClient.assistant_threads_setSuggestedPrompts()``. - - Returns: - ``SlackResponse`` from the API call. - """ - prompts_arg: List[Dict[str, str]] = [] - for prompt in prompts: - if isinstance(prompt, str): - prompts_arg.append({"title": prompt, "message": prompt}) - else: - prompts_arg.append(prompt) - - return self._client.assistant_threads_setSuggestedPrompts( - channel_id=channel_id or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] - prompts=prompts_arg, - title=title, - **kwargs, - ) diff --git a/slack_bolt/agent/async_agent.py b/slack_bolt/agent/async_agent.py deleted file mode 100644 index da4ec6c0a..000000000 --- a/slack_bolt/agent/async_agent.py +++ /dev/null @@ -1,138 +0,0 @@ -from typing import Dict, List, Optional, Sequence, Union - -from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient -from slack_sdk.web.async_chat_stream import AsyncChatStream - - -class AsyncBoltAgent: - """Async agent listener argument for building AI-powered Slack agents. - - Experimental: - This API is experimental and may change in future releases. - - @app.event("app_mention") - async def handle_mention(agent): - stream = await agent.chat_stream() - await stream.append(markdown_text="Hello!") - await stream.stop() - """ - - def __init__( - self, - *, - client: AsyncWebClient, - channel_id: Optional[str] = None, - thread_ts: Optional[str] = None, - ts: Optional[str] = None, - team_id: Optional[str] = None, - user_id: Optional[str] = None, - ): - self._client = client - self._channel_id = channel_id - self._thread_ts = thread_ts - self._ts = ts - self._team_id = team_id - self._user_id = user_id - - async def chat_stream( - self, - *, - channel: Optional[str] = None, - thread_ts: Optional[str] = None, - recipient_team_id: Optional[str] = None, - recipient_user_id: Optional[str] = None, - **kwargs, - ) -> AsyncChatStream: - """Creates an AsyncChatStream with defaults from event context. - - Each call creates a new instance. Create multiple for parallel streams. - - Args: - channel: Channel ID. Defaults to the channel from the event context. - thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. - recipient_team_id: Team ID of the recipient. Defaults to the team from the event context. - recipient_user_id: User ID of the recipient. Defaults to the user from the event context. - **kwargs: Additional arguments passed to ``AsyncWebClient.chat_stream()``. - - Returns: - A new ``AsyncChatStream`` instance. - """ - provided = [arg for arg in (channel, thread_ts, recipient_team_id, recipient_user_id) if arg is not None] - if provided and len(provided) < 4: - raise ValueError( - "Either provide all of channel, thread_ts, recipient_team_id, and recipient_user_id, or none of them" - ) - # Argument validation is delegated to chat_stream() and the API - return await self._client.chat_stream( - channel=channel or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] - recipient_team_id=recipient_team_id or self._team_id, - recipient_user_id=recipient_user_id or self._user_id, - **kwargs, - ) - - async def set_status( - self, - *, - status: str, - loading_messages: Optional[List[str]] = None, - channel_id: Optional[str] = None, - thread_ts: Optional[str] = None, - **kwargs, - ) -> AsyncSlackResponse: - """Sets the status of an assistant thread. - - Args: - status: The status text to display. - loading_messages: Optional list of loading messages to cycle through. - channel_id: Channel ID. Defaults to the channel from the event context. - thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. - **kwargs: Additional arguments passed to ``AsyncWebClient.assistant_threads_setStatus()``. - - Returns: - ``AsyncSlackResponse`` from the API call. - """ - return await self._client.assistant_threads_setStatus( - channel_id=channel_id or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] - status=status, - loading_messages=loading_messages, - **kwargs, - ) - - async def set_suggested_prompts( - self, - *, - prompts: Sequence[Union[str, Dict[str, str]]], - title: Optional[str] = None, - channel_id: Optional[str] = None, - thread_ts: Optional[str] = None, - **kwargs, - ) -> AsyncSlackResponse: - """Sets suggested prompts for an assistant thread. - - Args: - prompts: A sequence of prompts. Each prompt can be either a string - (used as both title and message) or a dict with 'title' and 'message' keys. - title: Optional title for the suggested prompts section. - channel_id: Channel ID. Defaults to the channel from the event context. - thread_ts: Thread timestamp. Defaults to the thread_ts from the event context. - **kwargs: Additional arguments passed to ``AsyncWebClient.assistant_threads_setSuggestedPrompts()``. - - Returns: - ``AsyncSlackResponse`` from the API call. - """ - prompts_arg: List[Dict[str, str]] = [] - for prompt in prompts: - if isinstance(prompt, str): - prompts_arg.append({"title": prompt, "message": prompt}) - else: - prompts_arg.append(prompt) - - return await self._client.assistant_threads_setSuggestedPrompts( - channel_id=channel_id or self._channel_id, # type: ignore[arg-type] - thread_ts=thread_ts or self._thread_ts or self._ts, # type: ignore[arg-type] - prompts=prompts_arg, - title=title, - **kwargs, - ) diff --git a/slack_bolt/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index dfb242fd1..4cd70176d 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -8,7 +8,6 @@ from slack_bolt.context.fail import Fail from slack_bolt.context.get_thread_context.get_thread_context import GetThreadContext from slack_bolt.context.respond import Respond -from slack_bolt.agent.agent import BoltAgent from slack_bolt.context.save_thread_context import SaveThreadContext from slack_bolt.context.say import Say from slack_bolt.context.say_stream import SayStream @@ -104,8 +103,6 @@ def handle_buttons(args): """`get_thread_context()` utility function for AI Agents & Assistants""" save_thread_context: Optional[SaveThreadContext] """`save_thread_context()` utility function for AI Agents & Assistants""" - agent: Optional[BoltAgent] - """`agent` listener argument for AI Agents & Assistants""" say_stream: Optional[SayStream] """`say_stream()` utility function for AI Agents & Assistants""" # middleware @@ -141,7 +138,6 @@ def __init__( set_suggested_prompts: Optional[SetSuggestedPrompts] = None, get_thread_context: Optional[GetThreadContext] = None, save_thread_context: Optional[SaveThreadContext] = None, - agent: Optional[BoltAgent] = None, say_stream: Optional[SayStream] = None, # As this method is not supposed to be invoked by bolt-python users, # the naming conflict with the built-in one affects @@ -176,7 +172,6 @@ def __init__( self.set_suggested_prompts = set_suggested_prompts self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context - self.agent = agent self.say_stream = say_stream self.next: Callable[[], None] = next diff --git a/slack_bolt/kwargs_injection/async_args.py b/slack_bolt/kwargs_injection/async_args.py index 19719e900..2217cfe9f 100644 --- a/slack_bolt/kwargs_injection/async_args.py +++ b/slack_bolt/kwargs_injection/async_args.py @@ -1,7 +1,6 @@ from logging import Logger from typing import Callable, Awaitable, Dict, Any, Optional -from slack_bolt.agent.async_agent import AsyncBoltAgent from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.context.complete.async_complete import AsyncComplete @@ -103,8 +102,6 @@ async def handle_buttons(args): """`get_thread_context()` utility function for AI Agents & Assistants""" save_thread_context: Optional[AsyncSaveThreadContext] """`save_thread_context()` utility function for AI Agents & Assistants""" - agent: Optional[AsyncBoltAgent] - """`agent` listener argument for AI Agents & Assistants""" say_stream: Optional[AsyncSayStream] """`say_stream()` utility function for AI Agents & Assistants""" # middleware @@ -140,7 +137,6 @@ def __init__( set_suggested_prompts: Optional[AsyncSetSuggestedPrompts] = None, get_thread_context: Optional[AsyncGetThreadContext] = None, save_thread_context: Optional[AsyncSaveThreadContext] = None, - agent: Optional[AsyncBoltAgent] = None, say_stream: Optional[AsyncSayStream] = None, next: Callable[[], Awaitable[None]], **kwargs, # noqa @@ -172,7 +168,6 @@ def __init__( self.set_suggested_prompts = set_suggested_prompts self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context - self.agent = agent self.say_stream = say_stream self.next: Callable[[], Awaitable[None]] = next diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index 534fb6133..246fd10c9 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -1,11 +1,9 @@ import inspect import logging -import warnings from typing import Callable, Dict, MutableSequence, Optional, Any from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.warning import ExperimentalWarning from .async_args import AsyncArgs from slack_bolt.request.payload_utils import ( to_options, @@ -86,26 +84,6 @@ def build_async_required_kwargs( if k not in all_available_args: all_available_args[k] = v - # Defer agent creation to avoid constructing AsyncBoltAgent on every request - if "agent" in required_arg_names: - from slack_bolt.agent.async_agent import AsyncBoltAgent - - event = request.body.get("event", {}) - - all_available_args["agent"] = AsyncBoltAgent( - client=request.context.client, - channel_id=request.context.channel_id, - thread_ts=request.context.thread_ts or event.get("thread_ts"), - ts=event.get("ts"), - team_id=request.context.team_id, - user_id=request.context.user_id, - ) - warnings.warn( - "The agent listener argument is experimental and may change in future versions.", - category=ExperimentalWarning, - stacklevel=2, # Point to the caller, not this internal helper - ) - if len(required_arg_names) > 0: # To support instance/class methods in a class for listeners/middleware, # check if the first argument is either self or cls diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 101e00099..218fbeb6e 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -1,11 +1,9 @@ import inspect import logging -import warnings from typing import Callable, Dict, MutableSequence, Optional, Any from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.warning import ExperimentalWarning from .args import Args from slack_bolt.request.payload_utils import ( to_options, @@ -85,26 +83,6 @@ def build_required_kwargs( if k not in all_available_args: all_available_args[k] = v - # Defer agent creation to avoid constructing BoltAgent on every request - if "agent" in required_arg_names: - from slack_bolt.agent.agent import BoltAgent - - event = request.body.get("event", {}) - - all_available_args["agent"] = BoltAgent( - client=request.context.client, - channel_id=request.context.channel_id, - thread_ts=request.context.thread_ts or event.get("thread_ts"), - ts=event.get("ts"), - team_id=request.context.team_id, - user_id=request.context.user_id, - ) - warnings.warn( - "The agent listener argument is experimental and may change in future versions.", - category=ExperimentalWarning, - stacklevel=2, # Point to the caller, not this internal helper - ) - if len(required_arg_names) > 0: # To support instance/class methods in a class for listeners/middleware, # check if the first argument is either self or cls diff --git a/tests/scenario_tests/test_events_agent.py b/tests/scenario_tests/test_events_agent.py deleted file mode 100644 index 667739728..000000000 --- a/tests/scenario_tests/test_events_agent.py +++ /dev/null @@ -1,162 +0,0 @@ -import json -from time import sleep - -import pytest -from slack_sdk.web import WebClient - -from slack_bolt import App, BoltRequest, BoltContext, BoltAgent -from slack_bolt.agent.agent import BoltAgent as BoltAgentDirect -from slack_bolt.warning import ExperimentalWarning -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 TestEventsAgent: - 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_agent_injected_for_app_mention(self): - app = App(client=self.web_client) - - state = {"called": False} - - def assert_target_called(): - count = 0 - while state["called"] is False and count < 20: - sleep(0.1) - count += 1 - assert state["called"] is True - state["called"] = False - - @app.event("app_mention") - def handle_mention(agent: BoltAgent, context: BoltContext): - assert agent is not None - assert isinstance(agent, BoltAgentDirect) - assert context.channel_id == "C111" - state["called"] = True - - request = BoltRequest(body=app_mention_event_body, mode="socket_mode") - response = app.dispatch(request) - assert response.status == 200 - assert_target_called() - - def test_agent_available_in_action_listener(self): - app = App(client=self.web_client) - - state = {"called": False} - - def assert_target_called(): - count = 0 - while state["called"] is False and count < 20: - sleep(0.1) - count += 1 - assert state["called"] is True - state["called"] = False - - @app.action("test_action") - def handle_action(ack, agent: BoltAgent): - ack() - assert agent is not None - assert isinstance(agent, BoltAgentDirect) - state["called"] = True - - request = BoltRequest(body=json.dumps(action_event_body), mode="socket_mode") - response = app.dispatch(request) - assert response.status == 200 - assert_target_called() - - def test_agent_kwarg_emits_experimental_warning(self): - app = App(client=self.web_client) - - state = {"called": False} - - def assert_target_called(): - count = 0 - while state["called"] is False and count < 20: - sleep(0.1) - count += 1 - assert state["called"] is True - state["called"] = False - - @app.event("app_mention") - def handle_mention(agent: BoltAgent): - state["called"] = True - - request = BoltRequest(body=app_mention_event_body, mode="socket_mode") - with pytest.warns(ExperimentalWarning, match="agent listener argument is experimental"): - response = app.dispatch(request) - assert response.status == 200 - assert_target_called() - - -# ---- Test event bodies ---- - - -def build_payload(event: dict) -> dict: - return { - "token": "verification_token", - "team_id": "T111", - "enterprise_id": "E111", - "api_app_id": "A111", - "event": event, - "type": "event_callback", - "event_id": "Ev111", - "event_time": 1599616881, - "authorizations": [ - { - "enterprise_id": "E111", - "team_id": "T111", - "user_id": "W111", - "is_bot": True, - "is_enterprise_install": False, - } - ], - } - - -app_mention_event_body = build_payload( - { - "type": "app_mention", - "user": "W222", - "text": "<@W111> hello", - "ts": "1234567890.123456", - "channel": "C111", - "event_ts": "1234567890.123456", - } -) - -action_event_body = { - "type": "block_actions", - "user": {"id": "W222", "username": "test_user", "name": "test_user", "team_id": "T111"}, - "api_app_id": "A111", - "token": "verification_token", - "container": {"type": "message", "message_ts": "1234567890.123456", "channel_id": "C111", "is_ephemeral": False}, - "channel": {"id": "C111", "name": "test-channel"}, - "team": {"id": "T111", "domain": "test"}, - "enterprise": {"id": "E111", "name": "test"}, - "trigger_id": "111.222.xxx", - "actions": [ - { - "type": "button", - "block_id": "b", - "action_id": "test_action", - "text": {"type": "plain_text", "text": "Button"}, - "action_ts": "1234567890.123456", - } - ], -} diff --git a/tests/scenario_tests_async/test_events_agent.py b/tests/scenario_tests_async/test_events_agent.py deleted file mode 100644 index 1702cdb61..000000000 --- a/tests/scenario_tests_async/test_events_agent.py +++ /dev/null @@ -1,169 +0,0 @@ -import asyncio -import json - -import pytest -from slack_sdk.web.async_client import AsyncWebClient - -from slack_bolt.agent.async_agent import AsyncBoltAgent -from slack_bolt.app.async_app import AsyncApp -from slack_bolt.context.async_context import AsyncBoltContext -from slack_bolt.request.async_request import AsyncBoltRequest -from slack_bolt.warning import ExperimentalWarning -from tests.mock_web_api_server import ( - cleanup_mock_web_api_server_async, - setup_mock_web_api_server_async, -) -from tests.utils import remove_os_env_temporarily, restore_os_env - - -class TestAsyncEventsAgent: - 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(scope="function", autouse=True) - def setup_teardown(self): - old_os_env = remove_os_env_temporarily() - setup_mock_web_api_server_async(self) - try: - yield - finally: - cleanup_mock_web_api_server_async(self) - restore_os_env(old_os_env) - - @pytest.mark.asyncio - async def test_agent_injected_for_app_mention(self): - app = AsyncApp(client=self.web_client) - - state = {"called": False} - - async def assert_target_called(): - count = 0 - while state["called"] is False and count < 20: - await asyncio.sleep(0.1) - count += 1 - assert state["called"] is True - state["called"] = False - - @app.event("app_mention") - async def handle_mention(agent: AsyncBoltAgent, context: AsyncBoltContext): - assert agent is not None - assert isinstance(agent, AsyncBoltAgent) - assert context.channel_id == "C111" - state["called"] = True - - request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") - response = await app.async_dispatch(request) - assert response.status == 200 - await assert_target_called() - - @pytest.mark.asyncio - async def test_agent_available_in_action_listener(self): - app = AsyncApp(client=self.web_client) - - state = {"called": False} - - async def assert_target_called(): - count = 0 - while state["called"] is False and count < 20: - await asyncio.sleep(0.1) - count += 1 - assert state["called"] is True - state["called"] = False - - @app.action("test_action") - async def handle_action(ack, agent: AsyncBoltAgent): - await ack() - assert agent is not None - assert isinstance(agent, AsyncBoltAgent) - state["called"] = True - - request = AsyncBoltRequest(body=json.dumps(action_event_body), mode="socket_mode") - response = await app.async_dispatch(request) - assert response.status == 200 - await assert_target_called() - - @pytest.mark.asyncio - async def test_agent_kwarg_emits_experimental_warning(self): - app = AsyncApp(client=self.web_client) - - state = {"called": False} - - async def assert_target_called(): - count = 0 - while state["called"] is False and count < 20: - await asyncio.sleep(0.1) - count += 1 - assert state["called"] is True - state["called"] = False - - @app.event("app_mention") - async def handle_mention(agent: AsyncBoltAgent): - state["called"] = True - - request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") - with pytest.warns(ExperimentalWarning, match="agent listener argument is experimental"): - response = await app.async_dispatch(request) - assert response.status == 200 - await assert_target_called() - - -# ---- Test event bodies ---- - - -def build_payload(event: dict) -> dict: - return { - "token": "verification_token", - "team_id": "T111", - "enterprise_id": "E111", - "api_app_id": "A111", - "event": event, - "type": "event_callback", - "event_id": "Ev111", - "event_time": 1599616881, - "authorizations": [ - { - "enterprise_id": "E111", - "team_id": "T111", - "user_id": "W111", - "is_bot": True, - "is_enterprise_install": False, - } - ], - } - - -app_mention_event_body = build_payload( - { - "type": "app_mention", - "user": "W222", - "text": "<@W111> hello", - "ts": "1234567890.123456", - "channel": "C111", - "event_ts": "1234567890.123456", - } -) - -action_event_body = { - "type": "block_actions", - "user": {"id": "W222", "username": "test_user", "name": "test_user", "team_id": "T111"}, - "api_app_id": "A111", - "token": "verification_token", - "container": {"type": "message", "message_ts": "1234567890.123456", "channel_id": "C111", "is_ephemeral": False}, - "channel": {"id": "C111", "name": "test-channel"}, - "team": {"id": "T111", "domain": "test"}, - "enterprise": {"id": "E111", "name": "test"}, - "trigger_id": "111.222.xxx", - "actions": [ - { - "type": "button", - "block_id": "b", - "action_id": "test_action", - "text": {"type": "plain_text", "text": "Button"}, - "action_ts": "1234567890.123456", - } - ], -} diff --git a/tests/slack_bolt/agent/__init__.py b/tests/slack_bolt/agent/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/slack_bolt/agent/test_agent.py b/tests/slack_bolt/agent/test_agent.py deleted file mode 100644 index 76ac7d17b..000000000 --- a/tests/slack_bolt/agent/test_agent.py +++ /dev/null @@ -1,365 +0,0 @@ -from unittest.mock import MagicMock - -import pytest -from slack_sdk.web import WebClient -from slack_sdk.web.chat_stream import ChatStream - -from slack_bolt.agent.agent import BoltAgent - - -class TestBoltAgent: - def test_chat_stream_uses_context_defaults(self): - """BoltAgent.chat_stream() passes context defaults to WebClient.chat_stream().""" - client = MagicMock(spec=WebClient) - client.chat_stream.return_value = MagicMock(spec=ChatStream) - - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - stream = agent.chat_stream() - - client.chat_stream.assert_called_once_with( - channel="C111", - thread_ts="1234567890.123456", - recipient_team_id="T111", - recipient_user_id="W222", - ) - assert stream is not None - - def test_chat_stream_overrides_context_defaults(self): - """Explicit kwargs to chat_stream() override context defaults.""" - client = MagicMock(spec=WebClient) - client.chat_stream.return_value = MagicMock(spec=ChatStream) - - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - stream = agent.chat_stream( - channel="C999", - thread_ts="9999999999.999999", - recipient_team_id="T999", - recipient_user_id="U999", - ) - - client.chat_stream.assert_called_once_with( - channel="C999", - thread_ts="9999999999.999999", - recipient_team_id="T999", - recipient_user_id="U999", - ) - assert stream is not None - - def test_chat_stream_rejects_partial_overrides(self): - """Passing only some of the four context args raises ValueError.""" - client = MagicMock(spec=WebClient) - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - with pytest.raises(ValueError, match="Either provide all of"): - agent.chat_stream(channel="C999") - - def test_chat_stream_passes_extra_kwargs(self): - """Extra kwargs are forwarded to WebClient.chat_stream().""" - client = MagicMock(spec=WebClient) - client.chat_stream.return_value = MagicMock(spec=ChatStream) - - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - agent.chat_stream(buffer_size=512) - - client.chat_stream.assert_called_once_with( - channel="C111", - thread_ts="1234567890.123456", - recipient_team_id="T111", - recipient_user_id="W222", - buffer_size=512, - ) - - def test_chat_stream_falls_back_to_ts(self): - """When thread_ts is not set, chat_stream() falls back to ts.""" - client = MagicMock(spec=WebClient) - client.chat_stream.return_value = MagicMock(spec=ChatStream) - - agent = BoltAgent( - client=client, - channel_id="C111", - team_id="T111", - ts="1111111111.111111", - user_id="W222", - ) - stream = agent.chat_stream() - - client.chat_stream.assert_called_once_with( - channel="C111", - thread_ts="1111111111.111111", - recipient_team_id="T111", - recipient_user_id="W222", - ) - assert stream is not None - - def test_chat_stream_prefers_thread_ts_over_ts(self): - """thread_ts takes priority over ts.""" - client = MagicMock(spec=WebClient) - client.chat_stream.return_value = MagicMock(spec=ChatStream) - - agent = BoltAgent( - client=client, - channel_id="C111", - team_id="T111", - thread_ts="1234567890.123456", - ts="1111111111.111111", - user_id="W222", - ) - stream = agent.chat_stream() - - client.chat_stream.assert_called_once_with( - channel="C111", - thread_ts="1234567890.123456", - recipient_team_id="T111", - recipient_user_id="W222", - ) - assert stream is not None - - def test_set_status_uses_context_defaults(self): - """BoltAgent.set_status() passes context defaults to WebClient.assistant_threads_setStatus().""" - client = MagicMock(spec=WebClient) - client.assistant_threads_setStatus.return_value = MagicMock() - - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - agent.set_status(status="Thinking...") - - client.assistant_threads_setStatus.assert_called_once_with( - channel_id="C111", - thread_ts="1234567890.123456", - status="Thinking...", - loading_messages=None, - ) - - def test_set_status_with_loading_messages(self): - """BoltAgent.set_status() forwards loading_messages.""" - client = MagicMock(spec=WebClient) - client.assistant_threads_setStatus.return_value = MagicMock() - - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - agent.set_status( - status="Thinking...", - loading_messages=["Sitting...", "Waiting..."], - ) - - client.assistant_threads_setStatus.assert_called_once_with( - channel_id="C111", - thread_ts="1234567890.123456", - status="Thinking...", - loading_messages=["Sitting...", "Waiting..."], - ) - - def test_set_status_overrides_context_defaults(self): - """Explicit channel_id/thread_ts override context defaults.""" - client = MagicMock(spec=WebClient) - client.assistant_threads_setStatus.return_value = MagicMock() - - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - agent.set_status( - status="Thinking...", - channel_id="C999", - thread_ts="9999999999.999999", - ) - - client.assistant_threads_setStatus.assert_called_once_with( - channel_id="C999", - thread_ts="9999999999.999999", - status="Thinking...", - loading_messages=None, - ) - - def test_set_status_passes_extra_kwargs(self): - """Extra kwargs are forwarded to WebClient.assistant_threads_setStatus().""" - client = MagicMock(spec=WebClient) - client.assistant_threads_setStatus.return_value = MagicMock() - - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - agent.set_status(status="Thinking...", token="xoxb-override") - - client.assistant_threads_setStatus.assert_called_once_with( - channel_id="C111", - thread_ts="1234567890.123456", - status="Thinking...", - loading_messages=None, - token="xoxb-override", - ) - - def test_set_status_requires_status(self): - """set_status() raises TypeError when status is not provided.""" - client = MagicMock(spec=WebClient) - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - with pytest.raises(TypeError): - agent.set_status() - - def test_set_suggested_prompts_uses_context_defaults(self): - """BoltAgent.set_suggested_prompts() passes context defaults to WebClient.assistant_threads_setSuggestedPrompts().""" - client = MagicMock(spec=WebClient) - client.assistant_threads_setSuggestedPrompts.return_value = MagicMock() - - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - agent.set_suggested_prompts(prompts=["What can you do?", "Help me write code"]) - - client.assistant_threads_setSuggestedPrompts.assert_called_once_with( - channel_id="C111", - thread_ts="1234567890.123456", - prompts=[ - {"title": "What can you do?", "message": "What can you do?"}, - {"title": "Help me write code", "message": "Help me write code"}, - ], - title=None, - ) - - def test_set_suggested_prompts_with_dict_prompts(self): - """BoltAgent.set_suggested_prompts() accepts dict prompts with title and message.""" - client = MagicMock(spec=WebClient) - client.assistant_threads_setSuggestedPrompts.return_value = MagicMock() - - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - agent.set_suggested_prompts( - prompts=[ - {"title": "Short title", "message": "A much longer message for this prompt"}, - ], - title="Suggestions", - ) - - client.assistant_threads_setSuggestedPrompts.assert_called_once_with( - channel_id="C111", - thread_ts="1234567890.123456", - prompts=[ - {"title": "Short title", "message": "A much longer message for this prompt"}, - ], - title="Suggestions", - ) - - def test_set_suggested_prompts_overrides_context_defaults(self): - """Explicit channel_id/thread_ts override context defaults.""" - client = MagicMock(spec=WebClient) - client.assistant_threads_setSuggestedPrompts.return_value = MagicMock() - - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - agent.set_suggested_prompts( - prompts=["Hello"], - channel_id="C999", - thread_ts="9999999999.999999", - ) - - client.assistant_threads_setSuggestedPrompts.assert_called_once_with( - channel_id="C999", - thread_ts="9999999999.999999", - prompts=[{"title": "Hello", "message": "Hello"}], - title=None, - ) - - def test_set_suggested_prompts_passes_extra_kwargs(self): - """Extra kwargs are forwarded to WebClient.assistant_threads_setSuggestedPrompts().""" - client = MagicMock(spec=WebClient) - client.assistant_threads_setSuggestedPrompts.return_value = MagicMock() - - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - agent.set_suggested_prompts(prompts=["Hello"], token="xoxb-override") - - client.assistant_threads_setSuggestedPrompts.assert_called_once_with( - channel_id="C111", - thread_ts="1234567890.123456", - prompts=[{"title": "Hello", "message": "Hello"}], - title=None, - token="xoxb-override", - ) - - def test_set_suggested_prompts_requires_prompts(self): - """set_suggested_prompts() raises TypeError when prompts is not provided.""" - client = MagicMock(spec=WebClient) - agent = BoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - with pytest.raises(TypeError): - agent.set_suggested_prompts() - - def test_import_from_slack_bolt(self): - from slack_bolt import BoltAgent as ImportedBoltAgent - - assert ImportedBoltAgent is BoltAgent - - def test_import_from_agent_module(self): - from slack_bolt.agent import BoltAgent as ImportedBoltAgent - - assert ImportedBoltAgent is BoltAgent diff --git a/tests/slack_bolt_async/agent/__init__.py b/tests/slack_bolt_async/agent/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/slack_bolt_async/agent/test_async_agent.py b/tests/slack_bolt_async/agent/test_async_agent.py deleted file mode 100644 index 3ed8ef0b4..000000000 --- a/tests/slack_bolt_async/agent/test_async_agent.py +++ /dev/null @@ -1,399 +0,0 @@ -from unittest.mock import MagicMock - -import pytest -from slack_sdk.web.async_client import AsyncWebClient -from slack_sdk.web.async_chat_stream import AsyncChatStream - -from slack_bolt.agent.async_agent import AsyncBoltAgent - - -def _make_async_chat_stream_mock(): - mock_stream = MagicMock(spec=AsyncChatStream) - call_tracker = MagicMock() - - async def fake_chat_stream(**kwargs): - call_tracker(**kwargs) - return mock_stream - - return fake_chat_stream, call_tracker, mock_stream - - -def _make_async_api_mock(): - mock_response = MagicMock() - call_tracker = MagicMock() - - async def fake_api_call(**kwargs): - call_tracker(**kwargs) - return mock_response - - return fake_api_call, call_tracker, mock_response - - -class TestAsyncBoltAgent: - @pytest.mark.asyncio - async def test_chat_stream_uses_context_defaults(self): - """AsyncBoltAgent.chat_stream() passes context defaults to AsyncWebClient.chat_stream().""" - client = MagicMock(spec=AsyncWebClient) - client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - stream = await agent.chat_stream() - - call_tracker.assert_called_once_with( - channel="C111", - thread_ts="1234567890.123456", - recipient_team_id="T111", - recipient_user_id="W222", - ) - assert stream is not None - - @pytest.mark.asyncio - async def test_chat_stream_overrides_context_defaults(self): - """Explicit kwargs to chat_stream() override context defaults.""" - client = MagicMock(spec=AsyncWebClient) - client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - stream = await agent.chat_stream( - channel="C999", - thread_ts="9999999999.999999", - recipient_team_id="T999", - recipient_user_id="U999", - ) - - call_tracker.assert_called_once_with( - channel="C999", - thread_ts="9999999999.999999", - recipient_team_id="T999", - recipient_user_id="U999", - ) - assert stream is not None - - @pytest.mark.asyncio - async def test_chat_stream_rejects_partial_overrides(self): - """Passing only some of the four context args raises ValueError.""" - client = MagicMock(spec=AsyncWebClient) - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - with pytest.raises(ValueError, match="Either provide all of"): - await agent.chat_stream(channel="C999") - - @pytest.mark.asyncio - async def test_chat_stream_passes_extra_kwargs(self): - """Extra kwargs are forwarded to AsyncWebClient.chat_stream().""" - client = MagicMock(spec=AsyncWebClient) - client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - await agent.chat_stream(buffer_size=512) - - call_tracker.assert_called_once_with( - channel="C111", - thread_ts="1234567890.123456", - recipient_team_id="T111", - recipient_user_id="W222", - buffer_size=512, - ) - - @pytest.mark.asyncio - async def test_chat_stream_falls_back_to_ts(self): - """When thread_ts is not set, chat_stream() falls back to ts.""" - client = MagicMock(spec=AsyncWebClient) - client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - team_id="T111", - ts="1111111111.111111", - user_id="W222", - ) - stream = await agent.chat_stream() - - call_tracker.assert_called_once_with( - channel="C111", - thread_ts="1111111111.111111", - recipient_team_id="T111", - recipient_user_id="W222", - ) - assert stream is not None - - @pytest.mark.asyncio - async def test_chat_stream_prefers_thread_ts_over_ts(self): - """thread_ts takes priority over ts.""" - client = MagicMock(spec=AsyncWebClient) - client.chat_stream, call_tracker, _ = _make_async_chat_stream_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - team_id="T111", - thread_ts="1234567890.123456", - ts="1111111111.111111", - user_id="W222", - ) - stream = await agent.chat_stream() - - call_tracker.assert_called_once_with( - channel="C111", - thread_ts="1234567890.123456", - recipient_team_id="T111", - recipient_user_id="W222", - ) - assert stream is not None - - @pytest.mark.asyncio - async def test_set_status_uses_context_defaults(self): - """AsyncBoltAgent.set_status() passes context defaults to AsyncWebClient.assistant_threads_setStatus().""" - client = MagicMock(spec=AsyncWebClient) - client.assistant_threads_setStatus, call_tracker, _ = _make_async_api_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - await agent.set_status(status="Thinking...") - - call_tracker.assert_called_once_with( - channel_id="C111", - thread_ts="1234567890.123456", - status="Thinking...", - loading_messages=None, - ) - - @pytest.mark.asyncio - async def test_set_status_with_loading_messages(self): - """AsyncBoltAgent.set_status() forwards loading_messages.""" - client = MagicMock(spec=AsyncWebClient) - client.assistant_threads_setStatus, call_tracker, _ = _make_async_api_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - await agent.set_status( - status="Thinking...", - loading_messages=["Sitting...", "Waiting..."], - ) - - call_tracker.assert_called_once_with( - channel_id="C111", - thread_ts="1234567890.123456", - status="Thinking...", - loading_messages=["Sitting...", "Waiting..."], - ) - - @pytest.mark.asyncio - async def test_set_status_overrides_context_defaults(self): - """Explicit channel_id/thread_ts override context defaults.""" - client = MagicMock(spec=AsyncWebClient) - client.assistant_threads_setStatus, call_tracker, _ = _make_async_api_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - await agent.set_status( - status="Thinking...", - channel_id="C999", - thread_ts="9999999999.999999", - ) - - call_tracker.assert_called_once_with( - channel_id="C999", - thread_ts="9999999999.999999", - status="Thinking...", - loading_messages=None, - ) - - @pytest.mark.asyncio - async def test_set_status_passes_extra_kwargs(self): - """Extra kwargs are forwarded to AsyncWebClient.assistant_threads_setStatus().""" - client = MagicMock(spec=AsyncWebClient) - client.assistant_threads_setStatus, call_tracker, _ = _make_async_api_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - await agent.set_status(status="Thinking...", token="xoxb-override") - - call_tracker.assert_called_once_with( - channel_id="C111", - thread_ts="1234567890.123456", - status="Thinking...", - loading_messages=None, - token="xoxb-override", - ) - - @pytest.mark.asyncio - async def test_set_status_requires_status(self): - """set_status() raises TypeError when status is not provided.""" - client = MagicMock(spec=AsyncWebClient) - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - with pytest.raises(TypeError): - await agent.set_status() - - @pytest.mark.asyncio - async def test_set_suggested_prompts_uses_context_defaults(self): - """AsyncBoltAgent.set_suggested_prompts() passes context defaults to AsyncWebClient.assistant_threads_setSuggestedPrompts().""" - client = MagicMock(spec=AsyncWebClient) - client.assistant_threads_setSuggestedPrompts, call_tracker, _ = _make_async_api_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - await agent.set_suggested_prompts(prompts=["What can you do?", "Help me write code"]) - - call_tracker.assert_called_once_with( - channel_id="C111", - thread_ts="1234567890.123456", - prompts=[ - {"title": "What can you do?", "message": "What can you do?"}, - {"title": "Help me write code", "message": "Help me write code"}, - ], - title=None, - ) - - @pytest.mark.asyncio - async def test_set_suggested_prompts_with_dict_prompts(self): - """AsyncBoltAgent.set_suggested_prompts() accepts dict prompts with title and message.""" - client = MagicMock(spec=AsyncWebClient) - client.assistant_threads_setSuggestedPrompts, call_tracker, _ = _make_async_api_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - await agent.set_suggested_prompts( - prompts=[ - {"title": "Short title", "message": "A much longer message for this prompt"}, - ], - title="Suggestions", - ) - - call_tracker.assert_called_once_with( - channel_id="C111", - thread_ts="1234567890.123456", - prompts=[ - {"title": "Short title", "message": "A much longer message for this prompt"}, - ], - title="Suggestions", - ) - - @pytest.mark.asyncio - async def test_set_suggested_prompts_overrides_context_defaults(self): - """Explicit channel_id/thread_ts override context defaults.""" - client = MagicMock(spec=AsyncWebClient) - client.assistant_threads_setSuggestedPrompts, call_tracker, _ = _make_async_api_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - await agent.set_suggested_prompts( - prompts=["Hello"], - channel_id="C999", - thread_ts="9999999999.999999", - ) - - call_tracker.assert_called_once_with( - channel_id="C999", - thread_ts="9999999999.999999", - prompts=[{"title": "Hello", "message": "Hello"}], - title=None, - ) - - @pytest.mark.asyncio - async def test_set_suggested_prompts_passes_extra_kwargs(self): - """Extra kwargs are forwarded to AsyncWebClient.assistant_threads_setSuggestedPrompts().""" - client = MagicMock(spec=AsyncWebClient) - client.assistant_threads_setSuggestedPrompts, call_tracker, _ = _make_async_api_mock() - - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - await agent.set_suggested_prompts(prompts=["Hello"], token="xoxb-override") - - call_tracker.assert_called_once_with( - channel_id="C111", - thread_ts="1234567890.123456", - prompts=[{"title": "Hello", "message": "Hello"}], - title=None, - token="xoxb-override", - ) - - @pytest.mark.asyncio - async def test_set_suggested_prompts_requires_prompts(self): - """set_suggested_prompts() raises TypeError when prompts is not provided.""" - client = MagicMock(spec=AsyncWebClient) - agent = AsyncBoltAgent( - client=client, - channel_id="C111", - thread_ts="1234567890.123456", - team_id="T111", - user_id="W222", - ) - with pytest.raises(TypeError): - await agent.set_suggested_prompts() - - @pytest.mark.asyncio - async def test_import_from_agent_module(self): - from slack_bolt.agent.async_agent import AsyncBoltAgent as ImportedAsyncBoltAgent - - assert ImportedAsyncBoltAgent is AsyncBoltAgent From 6e57716ad35e2e9ef229a1276c42890ae4b10428 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 23 Mar 2026 07:34:02 -0700 Subject: [PATCH 853/865] chore: replace sleep-based polling with Event synchronization in tests (#1467) --- tests/scenario_tests/test_events_assistant.py | 99 ++++++------- ...est_events_assistant_without_middleware.py | 59 ++++---- .../scenario_tests/test_events_say_stream.py | 72 ++++------ .../test_events_assistant.py | 133 ++++++++---------- ...est_events_assistant_without_middleware.py | 74 +++++----- .../test_events_say_stream.py | 71 ++++------ 6 files changed, 222 insertions(+), 286 deletions(-) diff --git a/tests/scenario_tests/test_events_assistant.py b/tests/scenario_tests/test_events_assistant.py index 5bc270d86..a970c9fa4 100644 --- a/tests/scenario_tests/test_events_assistant.py +++ b/tests/scenario_tests/test_events_assistant.py @@ -1,27 +1,16 @@ -import time -from time import sleep +from threading import Event from typing import Callable from slack_sdk.web import WebClient -from slack_bolt import App, BoltRequest, Assistant, Say, SetSuggestedPrompts, SetStatus, BoltContext +from slack_bolt import App, Assistant, BoltContext, BoltRequest, Say, SetStatus, SetSuggestedPrompts from slack_bolt.middleware import Middleware from slack_bolt.request import BoltRequest as BoltRequestType from slack_bolt.response import BoltResponse -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server from tests.utils import remove_os_env_temporarily, restore_os_env -def assert_target_called(called: dict, timeout: float = 0.5): - deadline = time.time() + timeout - while called["value"] is not True and time.time() < deadline: - time.sleep(0.1) - assert called["value"] is True - - class TestEventsAssistant: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" @@ -41,7 +30,7 @@ def teardown_method(self): def test_thread_started(self): app = App(client=self.web_client) assistant = Assistant() - called = {"value": False} + listener_called = Event() @assistant.thread_started def start_thread(say: Say, set_suggested_prompts: SetSuggestedPrompts, set_status: SetStatus, context: BoltContext): @@ -54,37 +43,37 @@ def start_thread(say: Say, set_suggested_prompts: SetSuggestedPrompts, set_statu set_suggested_prompts( prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}], title="foo" ) - called["value"] = True + listener_called.set() app.assistant(assistant) request = BoltRequest(body=thread_started_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_thread_context_changed(self): app = App(client=self.web_client) assistant = Assistant() - called = {"value": False} + listener_called = Event() @assistant.thread_context_changed def handle_thread_context_changed(context: BoltContext): assert context.channel_id == "D111" assert context.thread_ts == "1726133698.626339" - called["value"] = True + listener_called.set() app.assistant(assistant) request = BoltRequest(body=thread_context_changed_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_user_message(self): app = App(client=self.web_client) assistant = Assistant() - called = {"value": False} + listener_called = Event() @assistant.user_message def handle_user_message(say: Say, set_status: SetStatus, context: BoltContext): @@ -94,7 +83,7 @@ def handle_user_message(say: Say, set_status: SetStatus, context: BoltContext): try: set_status("is typing...") say("Here you are!") - called["value"] = True + listener_called.set() except Exception as e: say(f"Oops, something went wrong (error: {e})") @@ -103,12 +92,12 @@ def handle_user_message(say: Say, set_status: SetStatus, context: BoltContext): request = BoltRequest(body=user_message_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_user_message_with_assistant_thread(self): app = App(client=self.web_client) assistant = Assistant() - called = {"value": False} + listener_called = Event() @assistant.user_message def handle_user_message(say: Say, set_status: SetStatus, context: BoltContext): @@ -118,7 +107,7 @@ def handle_user_message(say: Say, set_status: SetStatus, context: BoltContext): try: set_status("is typing...") say("Here you are!") - called["value"] = True + listener_called.set() except Exception as e: say(f"Oops, something went wrong (error: {e})") @@ -127,77 +116,77 @@ def handle_user_message(say: Say, set_status: SetStatus, context: BoltContext): request = BoltRequest(body=user_message_event_body_with_assistant_thread, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_message_changed(self): app = App(client=self.web_client) assistant = Assistant() - called = {"value": False} + listener_called = Event() @assistant.user_message def handle_user_message(): - called["value"] = True + listener_called.set() @assistant.bot_message def handle_bot_message(): - called["value"] = True + listener_called.set() app.assistant(assistant) request = BoltRequest(body=message_changed_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert called["value"] is False + assert listener_called.wait(timeout=0.1) is False def test_channel_user_message_ignored(self): app = App(client=self.web_client) assistant = Assistant() - called = {"value": False} + listener_called = Event() @assistant.user_message def handle_user_message(): - called["value"] = True + listener_called.set() @assistant.bot_message def handle_bot_message(): - called["value"] = True + listener_called.set() app.assistant(assistant) request = BoltRequest(body=channel_user_message_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 404 - assert called["value"] is False + assert listener_called.wait(timeout=0.1) is False def test_channel_message_changed_ignored(self): app = App(client=self.web_client) assistant = Assistant() - called = {"value": False} + listener_called = Event() @assistant.user_message def handle_user_message(): - called["value"] = True + listener_called.set() @assistant.bot_message def handle_bot_message(): - called["value"] = True + listener_called.set() app.assistant(assistant) request = BoltRequest(body=channel_message_changed_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 404 - assert called["value"] is False + assert listener_called.wait(timeout=0.1) is False def test_assistant_with_custom_listener_middleware(self): app = App(client=self.web_client) assistant = Assistant() - handler_called = {"value": False} - middleware_called = {"value": False} + listener_called = Event() + middleware_called = Event() class TestMiddleware(Middleware): def process(self, *, req: BoltRequestType, resp: BoltResponse, next: Callable[[], BoltResponse]): - middleware_called["value"] = True + middleware_called.set() # Verify assistant utilities are available assert req.context.get("set_status") is not None assert req.context.get("set_title") is not None @@ -208,52 +197,52 @@ def process(self, *, req: BoltRequestType, resp: BoltResponse, next: Callable[[] @assistant.thread_started(middleware=[TestMiddleware()]) def start_thread(): - handler_called["value"] = True + listener_called.set() @assistant.user_message(middleware=[TestMiddleware()]) def handle_user_message(): - handler_called["value"] = True + listener_called.set() app.assistant(assistant) request = BoltRequest(body=thread_started_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(handler_called) - assert_target_called(middleware_called) + assert listener_called.wait(timeout=0.1) is True + assert middleware_called.wait(timeout=0.1) is True - handler_called = {"value": False} - middleware_called = {"value": False} + listener_called.clear() + middleware_called.clear() request = BoltRequest(body=user_message_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(handler_called) - assert_target_called(middleware_called) + assert listener_called.wait(timeout=0.1) is True + assert middleware_called.wait(timeout=0.1) is True def test_assistant_custom_middleware_can_short_circuit(self): app = App(client=self.web_client) assistant = Assistant() - handler_called = {"value": False} - middleware_called = {"value": False} + listener_called = Event() + middleware_called = Event() class BlockingMiddleware(Middleware): def process(self, *, req: BoltRequestType, resp: BoltResponse, next: Callable[[], BoltResponse]): - middleware_called["value"] = True + middleware_called.set() # Intentionally not calling next() to short-circuit return BoltResponse(status=200) @assistant.thread_started(middleware=[BlockingMiddleware()]) def start_thread(say: Say, context: BoltContext): - handler_called["value"] = True + listener_called.set() app.assistant(assistant) request = BoltRequest(body=thread_started_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(middleware_called) - assert handler_called["value"] is False + assert middleware_called.wait(timeout=0.1) is True + assert listener_called.wait(timeout=0.1) is False def build_payload(event: dict) -> dict: diff --git a/tests/scenario_tests/test_events_assistant_without_middleware.py b/tests/scenario_tests/test_events_assistant_without_middleware.py index 6a9381a33..c95f16f99 100644 --- a/tests/scenario_tests/test_events_assistant_without_middleware.py +++ b/tests/scenario_tests/test_events_assistant_without_middleware.py @@ -1,14 +1,11 @@ +from threading import Event + from slack_sdk.web import WebClient -from slack_bolt import App, BoltRequest, Say, SetStatus, SetTitle, SaveThreadContext, BoltContext +from slack_bolt import App, BoltContext, BoltRequest, SaveThreadContext, Say, SetStatus, SetSuggestedPrompts, SetTitle from slack_bolt.context.get_thread_context.get_thread_context import GetThreadContext -from slack_bolt.context.set_suggested_prompts.set_suggested_prompts import SetSuggestedPrompts -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server from tests.scenario_tests.test_events_assistant import ( - assert_target_called, channel_message_changed_event_body, channel_user_message_event_body, message_changed_event_body, @@ -38,7 +35,7 @@ def teardown_method(self): def test_thread_started(self): app = App(client=self.web_client) - called = {"value": False} + listener_called = Event() @app.event("assistant_thread_started") def handle_assistant_thread_started( @@ -60,16 +57,16 @@ def handle_assistant_thread_started( assert save_thread_context is not None say("Hi, how can I help you today?") set_suggested_prompts(prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}]) - called["value"] = True + listener_called.set() request = BoltRequest(body=thread_started_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_thread_context_changed(self): app = App(client=self.web_client) - called = {"value": False} + listener_called = Event() @app.event("assistant_thread_context_changed") def handle_assistant_thread_context_changed( @@ -89,16 +86,16 @@ def handle_assistant_thread_context_changed( assert set_suggested_prompts is not None assert get_thread_context is not None assert save_thread_context is not None - called["value"] = True + listener_called.set() request = BoltRequest(body=thread_context_changed_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_user_message(self): app = App(client=self.web_client) - called = {"value": False} + listener_called = Event() @app.message("") def handle_message( @@ -121,18 +118,18 @@ def handle_message( try: set_status("is typing...") say("Here you are!") - called["value"] = True + listener_called.set() except Exception as e: say(f"Oops, something went wrong (error: {e})") request = BoltRequest(body=user_message_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_user_message_with_assistant_thread(self): app = App(client=self.web_client) - called = {"value": False} + listener_called = Event() @app.message("") def handle_message( @@ -155,18 +152,18 @@ def handle_message( try: set_status("is typing...") say("Here you are!") - called["value"] = True + listener_called.set() except Exception as e: say(f"Oops, something went wrong (error: {e})") request = BoltRequest(body=user_message_event_body_with_assistant_thread, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_message_changed(self): app = App(client=self.web_client) - called = {"value": False} + listener_called = Event() @app.event("message") def handle_message_event( @@ -185,16 +182,16 @@ def handle_message_event( assert set_suggested_prompts is None assert get_thread_context is None assert save_thread_context is None - called["value"] = True + listener_called.set() request = BoltRequest(body=message_changed_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_channel_user_message(self): app = App(client=self.web_client) - called = {"value": False} + listener_called = Event() @app.event("message") def handle_message_event( @@ -213,16 +210,16 @@ def handle_message_event( assert set_suggested_prompts is None assert get_thread_context is None assert save_thread_context is None - called["value"] = True + listener_called.set() request = BoltRequest(body=channel_user_message_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_channel_message_changed(self): app = App(client=self.web_client) - called = {"value": False} + listener_called = Event() @app.event("message") def handle_message_event( @@ -241,17 +238,17 @@ def handle_message_event( assert set_suggested_prompts is None assert get_thread_context is None assert save_thread_context is None - called["value"] = True + listener_called.set() request = BoltRequest(body=channel_message_changed_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_assistant_events_agent_kwargs_disabled(self): app = App(client=self.web_client, attaching_agent_kwargs_enabled=False) - called = {"value": False} + listener_called = Event() @app.event("assistant_thread_started") def start_thread(context: BoltContext): @@ -260,9 +257,9 @@ def start_thread(context: BoltContext): assert context.get("set_suggested_prompts") is None assert context.get("get_thread_context") is None assert context.get("save_thread_context") is None - called["value"] = True + listener_called.set() request = BoltRequest(body=thread_started_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True diff --git a/tests/scenario_tests/test_events_say_stream.py b/tests/scenario_tests/test_events_say_stream.py index 75b0c612c..e0ab66aab 100644 --- a/tests/scenario_tests/test_events_say_stream.py +++ b/tests/scenario_tests/test_events_say_stream.py @@ -1,33 +1,19 @@ import json -import time +from threading import Event from urllib.parse import quote from slack_sdk.web import WebClient -from slack_bolt import App, BoltRequest, BoltContext -from slack_bolt.context.say_stream.say_stream import SayStream -from slack_bolt.middleware.assistant import Assistant -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) +from slack_bolt import App, Assistant, BoltContext, BoltRequest, SayStream +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server from tests.scenario_tests.test_app import app_mention_event_body -from tests.scenario_tests.test_events_assistant import ( - thread_started_event_body, - user_message_event_body as threaded_user_message_event_body, -) +from tests.scenario_tests.test_events_assistant import thread_started_event_body +from tests.scenario_tests.test_events_assistant import user_message_event_body as threaded_user_message_event_body from tests.scenario_tests.test_message_bot import bot_message_event_payload, user_message_event_payload from tests.scenario_tests.test_view_submission import body as view_submission_body from tests.utils import remove_os_env_temporarily, restore_os_env -def assert_target_called(called: dict, timeout: float = 1.0): - deadline = time.time() + timeout - while called["value"] is not True and time.time() < deadline: - time.sleep(0.1) - assert called["value"] is True - - class TestEventsSayStream: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" @@ -46,7 +32,7 @@ def teardown_method(self): def test_say_stream_injected_for_app_mention(self): app = App(client=self.web_client) - called = {"value": False} + listener_called = Event() @app.event("app_mention") def handle_mention(say_stream: SayStream, context: BoltContext): @@ -57,16 +43,16 @@ def handle_mention(say_stream: SayStream, context: BoltContext): assert say_stream.thread_ts == "1595926230.009600" assert say_stream.recipient_team_id == context.team_id assert say_stream.recipient_user_id == context.user_id - called["value"] = True + listener_called.set() request = BoltRequest(body=app_mention_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_say_stream_with_org_level_install(self): app = App(client=self.web_client) - called = {"value": False} + listener_called = Event() @app.event("app_mention") def handle_mention(say_stream: SayStream, context: BoltContext): @@ -75,16 +61,16 @@ def handle_mention(say_stream: SayStream, context: BoltContext): assert say_stream is not None assert isinstance(say_stream, SayStream) assert say_stream.recipient_team_id == "E111" - called["value"] = True + listener_called.set() request = BoltRequest(body=org_app_mention_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_say_stream_injected_for_threaded_message(self): app = App(client=self.web_client) - called = {"value": False} + listener_called = Event() @app.event("message") def handle_message(say_stream: SayStream, context: BoltContext): @@ -95,16 +81,16 @@ def handle_message(say_stream: SayStream, context: BoltContext): assert say_stream.thread_ts == "1726133698.626339" assert say_stream.recipient_team_id == context.team_id assert say_stream.recipient_user_id == context.user_id - called["value"] = True + listener_called.set() request = BoltRequest(body=threaded_user_message_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_say_stream_in_user_message(self): app = App(client=self.web_client) - called = {"value": False} + listener_called = Event() @app.message("") def handle_user_message(say_stream: SayStream, context: BoltContext): @@ -115,16 +101,16 @@ def handle_user_message(say_stream: SayStream, context: BoltContext): assert say_stream.thread_ts == "1610261659.001400" assert say_stream.recipient_team_id == context.team_id assert say_stream.recipient_user_id == context.user_id - called["value"] = True + listener_called.set() request = BoltRequest(body=user_message_event_payload, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_say_stream_in_bot_message(self): app = App(client=self.web_client) - called = {"value": False} + listener_called = Event() @app.message("") def handle_bot_message(say_stream: SayStream, context: BoltContext): @@ -135,17 +121,17 @@ def handle_bot_message(say_stream: SayStream, context: BoltContext): assert say_stream.thread_ts == "1610261539.000900" assert say_stream.recipient_team_id == context.team_id assert say_stream.recipient_user_id == context.user_id - called["value"] = True + listener_called.set() request = BoltRequest(body=bot_message_event_payload, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_say_stream_in_assistant_thread_started(self): app = App(client=self.web_client) assistant = Assistant() - called = {"value": False} + listener_called = Event() @assistant.thread_started def start_thread(say_stream: SayStream, context: BoltContext): @@ -156,19 +142,19 @@ def start_thread(say_stream: SayStream, context: BoltContext): assert say_stream.thread_ts == "1726133698.626339" assert say_stream.recipient_team_id == context.team_id assert say_stream.recipient_user_id == context.user_id - called["value"] = True + listener_called.set() app.assistant(assistant) request = BoltRequest(body=thread_started_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_say_stream_in_assistant_user_message(self): app = App(client=self.web_client) assistant = Assistant() - called = {"value": False} + listener_called = Event() @assistant.user_message def handle_user_message(say_stream: SayStream, context: BoltContext): @@ -179,32 +165,32 @@ def handle_user_message(say_stream: SayStream, context: BoltContext): assert say_stream.thread_ts == "1726133698.626339" assert say_stream.recipient_team_id == context.team_id assert say_stream.recipient_user_id == context.user_id - called["value"] = True + listener_called.set() app.assistant(assistant) request = BoltRequest(body=threaded_user_message_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True def test_say_stream_is_none_for_view_submission(self): app = App(client=self.web_client, request_verification_enabled=False) - called = {"value": False} + listener_called = Event() @app.view("view-id") def handle_view(ack, say_stream, context: BoltContext): ack() assert say_stream is None assert context.say_stream is None - called["value"] = True + listener_called.set() request = BoltRequest( body=f"payload={quote(json.dumps(view_submission_body))}", ) response = app.dispatch(request) assert response.status == 200 - assert_target_called(called) + assert listener_called.wait(timeout=0.1) is True org_app_mention_event_body = { diff --git a/tests/scenario_tests_async/test_events_assistant.py b/tests/scenario_tests_async/test_events_assistant.py index 87b337536..9b2e43eb1 100644 --- a/tests/scenario_tests_async/test_events_assistant.py +++ b/tests/scenario_tests_async/test_events_assistant.py @@ -1,33 +1,24 @@ import asyncio -import time from typing import Awaitable, Callable, Optional import pytest from slack_sdk.web.async_client import AsyncWebClient -from slack_bolt.app.async_app import AsyncApp -from slack_bolt.context.async_context import AsyncBoltContext -from slack_bolt.context.say.async_say import AsyncSay -from slack_bolt.context.set_status.async_set_status import AsyncSetStatus -from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts -from slack_bolt.middleware.assistant.async_assistant import AsyncAssistant +from slack_bolt.async_app import ( + AsyncApp, + AsyncAssistant, + AsyncBoltContext, + AsyncBoltRequest, + AsyncSay, + AsyncSetStatus, + AsyncSetSuggestedPrompts, +) from slack_bolt.middleware.async_middleware import AsyncMiddleware -from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse -from tests.mock_web_api_server import ( - cleanup_mock_web_api_server_async, - setup_mock_web_api_server_async, -) +from tests.mock_web_api_server import cleanup_mock_web_api_server_async, setup_mock_web_api_server_async from tests.utils import remove_os_env_temporarily, restore_os_env -async def assert_target_called(called: dict, timeout: float = 0.5): - deadline = time.time() + timeout - while called["value"] is not True and time.time() < deadline: - await asyncio.sleep(0.1) - assert called["value"] is True - - class TestAsyncEventsAssistant: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" @@ -51,7 +42,7 @@ def setup_teardown(self): async def test_thread_started(self): app = AsyncApp(client=self.web_client) assistant = AsyncAssistant() - called = {"value": False} + listener_called = asyncio.Event() @assistant.thread_started async def start_thread( @@ -72,39 +63,39 @@ async def start_thread( prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}], title="foo", ) - called["value"] = True + listener_called.set() app.assistant(assistant) request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_thread_context_changed(self): app = AsyncApp(client=self.web_client) assistant = AsyncAssistant() - called = {"value": False} + listener_called = asyncio.Event() @assistant.thread_context_changed async def handle_thread_context_changed(context: AsyncBoltContext): assert context.channel_id == "D111" assert context.thread_ts == "1726133698.626339" - called["value"] = True + listener_called.set() app.assistant(assistant) request = AsyncBoltRequest(body=thread_context_changed_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_user_message(self): app = AsyncApp(client=self.web_client) assistant = AsyncAssistant() - called = {"value": False} + listener_called = asyncio.Event() @assistant.user_message async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context: AsyncBoltContext): @@ -114,7 +105,7 @@ async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context try: await set_status("is typing...") await say("Here you are!") - called["value"] = True + listener_called.set() except Exception as e: await say(f"Oops, something went wrong (error: {e})") @@ -123,13 +114,13 @@ async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context request = AsyncBoltRequest(body=user_message_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_user_message_with_assistant_thread(self): app = AsyncApp(client=self.web_client) assistant = AsyncAssistant() - called = {"value": False} + listener_called = asyncio.Event() @assistant.user_message async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context: AsyncBoltContext): @@ -139,7 +130,7 @@ async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context try: await set_status("is typing...") await say("Here you are!") - called["value"] = True + listener_called.set() except Exception as e: await say(f"Oops, something went wrong (error: {e})") @@ -148,84 +139,78 @@ async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context request = AsyncBoltRequest(body=user_message_event_body_with_assistant_thread, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_message_changed(self): app = AsyncApp(client=self.web_client) assistant = AsyncAssistant() - called = {"value": False} + listener_called = asyncio.Event() @assistant.user_message async def handle_user_message(): - called["value"] = True + listener_called.set() @assistant.bot_message async def handle_bot_message(): - called["value"] = True + listener_called.set() app.assistant(assistant) request = AsyncBoltRequest(body=message_changed_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - assert called["value"] is False + await asyncio.sleep(0.1) + assert not listener_called.is_set() @pytest.mark.asyncio async def test_channel_user_message_ignored(self): app = AsyncApp(client=self.web_client) assistant = AsyncAssistant() - called = {"value": False} + listener_called = asyncio.Event() @assistant.user_message async def handle_user_message(): - called["value"] = True + listener_called.set() @assistant.bot_message async def handle_bot_message(): - called["value"] = True + listener_called.set() app.assistant(assistant) request = AsyncBoltRequest(body=channel_user_message_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 404 - assert called["value"] is False + await asyncio.sleep(0.1) + assert not listener_called.is_set() @pytest.mark.asyncio async def test_channel_message_changed_ignored(self): app = AsyncApp(client=self.web_client) assistant = AsyncAssistant() - called = {"value": False} + listener_called = asyncio.Event() @assistant.user_message async def handle_user_message(): - called["value"] = True + listener_called.set() @assistant.bot_message async def handle_bot_message(): - called["value"] = True + listener_called.set() app.assistant(assistant) request = AsyncBoltRequest(body=channel_message_changed_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 404 - assert called["value"] is False + await asyncio.sleep(0.1) + assert not listener_called.is_set() @pytest.mark.asyncio async def test_assistant_events_kwargs_disabled(self): app = AsyncApp(client=self.web_client, attaching_agent_kwargs_enabled=False) - - state = {"called": False} - - async def assert_target_called(): - count = 0 - while state["called"] is False and count < 20: - await asyncio.sleep(0.1) - count += 1 - assert state["called"] is True - state["called"] = False + listener_called = asyncio.Event() @app.event("assistant_thread_started") async def start_thread(context: AsyncBoltContext): @@ -234,27 +219,19 @@ async def start_thread(context: AsyncBoltContext): assert context.get("set_suggested_prompts") is None assert context.get("get_thread_context") is None assert context.get("save_thread_context") is None - state["called"] = True + listener_called.set() request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called() + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_assistant_with_custom_listener_middleware(self): app = AsyncApp(client=self.web_client) assistant = AsyncAssistant() - - state = {"called": False, "middleware_called": False} - - async def assert_target_called(): - count = 0 - while state["called"] is False and count < 20: - await asyncio.sleep(0.1) - count += 1 - assert state["called"] is True - state["called"] = False + listener_called = asyncio.Event() + middleware_called = asyncio.Event() class TestAsyncMiddleware(AsyncMiddleware): async def async_process( @@ -264,7 +241,7 @@ async def async_process( resp: BoltResponse, next: Callable[[], Awaitable[BoltResponse]], ) -> Optional[BoltResponse]: - state["middleware_called"] = True + middleware_called.set() # Verify assistant utilities are available (set by _AsyncAssistantMiddleware before this) assert req.context.get("set_status") is not None assert req.context.get("set_title") is not None @@ -282,7 +259,7 @@ async def start_thread(say: AsyncSay, set_suggested_prompts: AsyncSetSuggestedPr await set_suggested_prompts( prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}] ) - state["called"] = True + listener_called.set() @assistant.user_message(middleware=[TestAsyncMiddleware()]) async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context: AsyncBoltContext): @@ -291,29 +268,30 @@ async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context assert say.thread_ts == context.thread_ts await set_status("is typing...") await say("Here you are!") - state["called"] = True + listener_called.set() app.assistant(assistant) request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called() - assert state["middleware_called"] is True - state["middleware_called"] = False + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True + assert (await asyncio.wait_for(middleware_called.wait(), timeout=0.1)) is True + + listener_called.clear() + middleware_called.clear() request = AsyncBoltRequest(body=user_message_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called() - assert state["middleware_called"] is True + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True + assert (await asyncio.wait_for(middleware_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_assistant_custom_middleware_can_short_circuit(self): app = AsyncApp(client=self.web_client) assistant = AsyncAssistant() - - state = {"handler_called": False} + listener_called = asyncio.Event() class BlockingAsyncMiddleware(AsyncMiddleware): async def async_process( @@ -328,14 +306,15 @@ async def async_process( @assistant.thread_started(middleware=[BlockingAsyncMiddleware()]) async def start_thread(say: AsyncSay, context: AsyncBoltContext): - state["handler_called"] = True + listener_called.set() app.assistant(assistant) request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - assert state["handler_called"] is False + await asyncio.sleep(0.1) + assert not listener_called.is_set() def build_payload(event: dict) -> dict: diff --git a/tests/scenario_tests_async/test_events_assistant_without_middleware.py b/tests/scenario_tests_async/test_events_assistant_without_middleware.py index 916dfd467..4e82cb2c1 100644 --- a/tests/scenario_tests_async/test_events_assistant_without_middleware.py +++ b/tests/scenario_tests_async/test_events_assistant_without_middleware.py @@ -1,21 +1,21 @@ +import asyncio + import pytest from slack_sdk.web.async_client import AsyncWebClient -from slack_bolt.app.async_app import AsyncApp -from slack_bolt.context.async_context import AsyncBoltContext -from slack_bolt.context.say.async_say import AsyncSay -from slack_bolt.context.set_status.async_set_status import AsyncSetStatus -from slack_bolt.context.set_title.async_set_title import AsyncSetTitle -from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts -from slack_bolt.context.get_thread_context.async_get_thread_context import AsyncGetThreadContext -from slack_bolt.context.save_thread_context.async_save_thread_context import AsyncSaveThreadContext -from slack_bolt.request.async_request import AsyncBoltRequest -from tests.mock_web_api_server import ( - cleanup_mock_web_api_server_async, - setup_mock_web_api_server_async, +from slack_bolt.async_app import ( + AsyncApp, + AsyncBoltContext, + AsyncBoltRequest, + AsyncGetThreadContext, + AsyncSaveThreadContext, + AsyncSay, + AsyncSetStatus, + AsyncSetSuggestedPrompts, + AsyncSetTitle, ) +from tests.mock_web_api_server import cleanup_mock_web_api_server_async, setup_mock_web_api_server_async from tests.scenario_tests_async.test_events_assistant import ( - assert_target_called, channel_message_changed_event_body, channel_user_message_event_body, message_changed_event_body, @@ -49,7 +49,7 @@ def setup_teardown(self): @pytest.mark.asyncio async def test_thread_started(self): app = AsyncApp(client=self.web_client) - called = {"value": False} + listener_called = asyncio.Event() @app.event("assistant_thread_started") async def handle_assistant_thread_started( @@ -73,17 +73,17 @@ async def handle_assistant_thread_started( await set_suggested_prompts( prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}] ) - called["value"] = True + listener_called.set() request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_thread_context_changed(self): app = AsyncApp(client=self.web_client) - called = {"value": False} + listener_called = asyncio.Event() @app.event("assistant_thread_context_changed") async def handle_assistant_thread_context_changed( @@ -103,17 +103,17 @@ async def handle_assistant_thread_context_changed( assert set_suggested_prompts is not None assert get_thread_context is not None assert save_thread_context is not None - called["value"] = True + listener_called.set() request = AsyncBoltRequest(body=thread_context_changed_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_user_message(self): app = AsyncApp(client=self.web_client) - called = {"value": False} + listener_called = asyncio.Event() @app.message("") async def handle_message( @@ -136,19 +136,19 @@ async def handle_message( try: await set_status("is typing...") await say("Here you are!") - called["value"] = True + listener_called.set() except Exception as e: await say(f"Oops, something went wrong (error: {e})") request = AsyncBoltRequest(body=user_message_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_user_message_with_assistant_thread(self): app = AsyncApp(client=self.web_client) - called = {"value": False} + listener_called = asyncio.Event() @app.message("") async def handle_message( @@ -171,19 +171,19 @@ async def handle_message( try: await set_status("is typing...") await say("Here you are!") - called["value"] = True + listener_called.set() except Exception as e: await say(f"Oops, something went wrong (error: {e})") request = AsyncBoltRequest(body=user_message_event_body_with_assistant_thread, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_message_changed(self): app = AsyncApp(client=self.web_client) - called = {"value": False} + listener_called = asyncio.Event() @app.event("message") async def handle_message_event( @@ -202,17 +202,17 @@ async def handle_message_event( assert set_suggested_prompts is None assert get_thread_context is None assert save_thread_context is None - called["value"] = True + listener_called.set() request = AsyncBoltRequest(body=message_changed_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_channel_user_message(self): app = AsyncApp(client=self.web_client) - called = {"value": False} + listener_called = asyncio.Event() @app.event("message") async def handle_message_event( @@ -231,17 +231,17 @@ async def handle_message_event( assert set_suggested_prompts is None assert get_thread_context is None assert save_thread_context is None - called["value"] = True + listener_called.set() request = AsyncBoltRequest(body=channel_user_message_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_channel_message_changed(self): app = AsyncApp(client=self.web_client) - called = {"value": False} + listener_called = asyncio.Event() @app.event("message") async def handle_message_event( @@ -260,18 +260,18 @@ async def handle_message_event( assert set_suggested_prompts is None assert get_thread_context is None assert save_thread_context is None - called["value"] = True + listener_called.set() request = AsyncBoltRequest(body=channel_message_changed_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_assistant_events_agent_kwargs_disabled(self): app = AsyncApp(client=self.web_client, attaching_agent_kwargs_enabled=False) - called = {"value": False} + listener_called = asyncio.Event() @app.event("assistant_thread_started") async def start_thread(context: AsyncBoltContext): @@ -280,9 +280,9 @@ async def start_thread(context: AsyncBoltContext): assert context.get("set_suggested_prompts") is None assert context.get("get_thread_context") is None assert context.get("save_thread_context") is None - called["value"] = True + listener_called.set() request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True diff --git a/tests/scenario_tests_async/test_events_say_stream.py b/tests/scenario_tests_async/test_events_say_stream.py index c24bc7bfc..6abcfad88 100644 --- a/tests/scenario_tests_async/test_events_say_stream.py +++ b/tests/scenario_tests_async/test_events_say_stream.py @@ -1,35 +1,20 @@ import asyncio import json -import time from urllib.parse import quote import pytest from slack_sdk.web.async_client import AsyncWebClient -from slack_bolt.app.async_app import AsyncApp -from slack_bolt.async_app import AsyncAssistant -from slack_bolt.context.async_context import AsyncBoltContext -from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream -from slack_bolt.request.async_request import AsyncBoltRequest -from tests.mock_web_api_server import ( - cleanup_mock_web_api_server_async, - setup_mock_web_api_server_async, -) +from slack_bolt.async_app import AsyncApp, AsyncAssistant, AsyncBoltContext, AsyncBoltRequest, AsyncSayStream +from tests.mock_web_api_server import cleanup_mock_web_api_server_async, setup_mock_web_api_server_async from tests.scenario_tests_async.test_app import app_mention_event_body +from tests.scenario_tests_async.test_events_assistant import thread_started_event_body from tests.scenario_tests_async.test_events_assistant import user_message_event_body as threaded_user_message_event_body -from tests.scenario_tests_async.test_events_assistant import thread_started_event_body, user_message_event_body from tests.scenario_tests_async.test_message_bot import bot_message_event_payload, user_message_event_payload from tests.scenario_tests_async.test_view_submission import body as view_submission_body from tests.utils import remove_os_env_temporarily, restore_os_env -async def assert_target_called(called: dict, timeout: float = 0.5): - deadline = time.time() + timeout - while called["value"] is not True and time.time() < deadline: - await asyncio.sleep(0.1) - assert called["value"] is True - - class TestAsyncEventsSayStream: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" @@ -51,7 +36,7 @@ def setup_teardown(self): @pytest.mark.asyncio async def test_say_stream_injected_for_app_mention(self): app = AsyncApp(client=self.web_client) - called = {"value": False} + listener_called = asyncio.Event() @app.event("app_mention") async def handle_mention(say_stream: AsyncSayStream, context: AsyncBoltContext): @@ -62,17 +47,17 @@ async def handle_mention(say_stream: AsyncSayStream, context: AsyncBoltContext): assert say_stream.thread_ts == "1595926230.009600" assert say_stream.recipient_team_id == context.team_id assert say_stream.recipient_user_id == context.user_id - called["value"] = True + listener_called.set() request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_say_stream_with_org_level_install(self): app = AsyncApp(client=self.web_client) - called = {"value": False} + listener_called = asyncio.Event() @app.event("app_mention") async def handle_mention(say_stream: AsyncSayStream, context: AsyncBoltContext): @@ -81,17 +66,17 @@ async def handle_mention(say_stream: AsyncSayStream, context: AsyncBoltContext): assert say_stream is not None assert isinstance(say_stream, AsyncSayStream) assert say_stream.recipient_team_id == "E111" - called["value"] = True + listener_called.set() request = AsyncBoltRequest(body=org_app_mention_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_say_stream_injected_for_threaded_message(self): app = AsyncApp(client=self.web_client) - called = {"value": False} + listener_called = asyncio.Event() @app.event("message") async def handle_message(say_stream: AsyncSayStream, context: AsyncBoltContext): @@ -102,17 +87,17 @@ async def handle_message(say_stream: AsyncSayStream, context: AsyncBoltContext): assert say_stream.thread_ts == "1726133698.626339" assert say_stream.recipient_team_id == context.team_id assert say_stream.recipient_user_id == context.user_id - called["value"] = True + listener_called.set() request = AsyncBoltRequest(body=threaded_user_message_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_say_stream_in_user_message(self): app = AsyncApp(client=self.web_client) - called = {"value": False} + listener_called = asyncio.Event() @app.message("") async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltContext): @@ -123,17 +108,17 @@ async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltCont assert say_stream.thread_ts == "1610261659.001400" assert say_stream.recipient_team_id == context.team_id assert say_stream.recipient_user_id == context.user_id - called["value"] = True + listener_called.set() request = AsyncBoltRequest(body=user_message_event_payload, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_say_stream_in_bot_message(self): app = AsyncApp(client=self.web_client) - called = {"value": False} + listener_called = asyncio.Event() @app.message("") async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltContext): @@ -144,18 +129,18 @@ async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltCont assert say_stream.thread_ts == "1610261539.000900" assert say_stream.recipient_team_id == context.team_id assert say_stream.recipient_user_id == context.user_id - called["value"] = True + listener_called.set() request = AsyncBoltRequest(body=bot_message_event_payload, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_say_stream_in_assistant_thread_started(self): app = AsyncApp(client=self.web_client) assistant = AsyncAssistant() - called = {"value": False} + listener_called = asyncio.Event() @assistant.thread_started async def start_thread(say_stream: AsyncSayStream, context: AsyncBoltContext): @@ -166,20 +151,20 @@ async def start_thread(say_stream: AsyncSayStream, context: AsyncBoltContext): assert say_stream.thread_ts == "1726133698.626339" assert say_stream.recipient_team_id == context.team_id assert say_stream.recipient_user_id == context.user_id - called["value"] = True + listener_called.set() app.assistant(assistant) request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_say_stream_in_assistant_user_message(self): app = AsyncApp(client=self.web_client) assistant = AsyncAssistant() - called = {"value": False} + listener_called = asyncio.Event() @assistant.user_message async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltContext): @@ -190,33 +175,33 @@ async def handle_user_message(say_stream: AsyncSayStream, context: AsyncBoltCont assert say_stream.thread_ts == "1726133698.626339" assert say_stream.recipient_team_id == context.team_id assert say_stream.recipient_user_id == context.user_id - called["value"] = True + listener_called.set() app.assistant(assistant) - request = AsyncBoltRequest(body=user_message_event_body, mode="socket_mode") + request = AsyncBoltRequest(body=threaded_user_message_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio async def test_say_stream_is_none_for_view_submission(self): app = AsyncApp(client=self.web_client, request_verification_enabled=False) - called = {"value": False} + listener_called = asyncio.Event() @app.view("view-id") async def handle_view(ack, say_stream, context: AsyncBoltContext): await ack() assert say_stream is None assert context.say_stream is None - called["value"] = True + listener_called.set() request = AsyncBoltRequest( body=f"payload={quote(json.dumps(view_submission_body))}", ) response = await app.async_dispatch(request) assert response.status == 200 - await assert_target_called(called) + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True org_app_mention_event_body = { From 98a8f593c7b4cde4834338d5c0ce89686c7168cf Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 23 Mar 2026 09:07:40 -0700 Subject: [PATCH 854/865] chore: fix test warnings across test suite (#1468) --- pyproject.toml | 6 +----- tests/adapter_tests/django/conftest.py | 6 ++++++ tests/adapter_tests/django/test_django.py | 2 -- tests/adapter_tests/starlette/test_fastapi.py | 8 ++++---- tests/adapter_tests/starlette/test_starlette.py | 6 +++--- tests/adapter_tests_async/test_async_fastapi.py | 8 ++++---- tests/adapter_tests_async/test_async_starlette.py | 6 +++--- tests/scenario_tests/test_app.py | 10 +++++----- tests/scenario_tests/test_lazy.py | 4 ++-- tests/scenario_tests_async/test_app.py | 10 +++++----- tests/scenario_tests_async/test_lazy.py | 4 ++-- 11 files changed, 35 insertions(+), 35 deletions(-) create mode 100644 tests/adapter_tests/django/conftest.py diff --git a/pyproject.toml b/pyproject.toml index a5c12548b..88842d0d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,11 +46,7 @@ log_file = "logs/pytest.log" log_file_level = "DEBUG" log_format = "%(asctime)s %(levelname)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" -filterwarnings = [ - "ignore:\"@coroutine\" decorator is deprecated since Python 3.8, use \"async def\" instead:DeprecationWarning", - "ignore:The loop argument is deprecated since Python 3.8, and scheduled for removal in Python 3.10.:DeprecationWarning", - "ignore:Unknown config option. asyncio_mode:pytest.PytestConfigWarning", # ignore warning when asyncio_mode is set but pytest-asyncio is not installed -] +filterwarnings = [] asyncio_mode = "auto" [tool.mypy] diff --git a/tests/adapter_tests/django/conftest.py b/tests/adapter_tests/django/conftest.py new file mode 100644 index 000000000..b2697fe2b --- /dev/null +++ b/tests/adapter_tests/django/conftest.py @@ -0,0 +1,6 @@ +import os + +import django + +os.environ["DJANGO_SETTINGS_MODULE"] = "tests.adapter_tests.django.test_django_settings" +django.setup() diff --git a/tests/adapter_tests/django/test_django.py b/tests/adapter_tests/django/test_django.py index cb14f966d..f31a46411 100644 --- a/tests/adapter_tests/django/test_django.py +++ b/tests/adapter_tests/django/test_django.py @@ -1,5 +1,4 @@ import json -import os from time import time from urllib.parse import quote @@ -29,7 +28,6 @@ class TestDjango(TestCase): base_url=mock_api_server_base_url, ) - os.environ["DJANGO_SETTINGS_MODULE"] = "tests.adapter_tests.django.test_django_settings" rf = RequestFactory() def setUp(self): diff --git a/tests/adapter_tests/starlette/test_fastapi.py b/tests/adapter_tests/starlette/test_fastapi.py index 64e633fe2..f91b9897e 100644 --- a/tests/adapter_tests/starlette/test_fastapi.py +++ b/tests/adapter_tests/starlette/test_fastapi.py @@ -94,7 +94,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 @@ -138,7 +138,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 @@ -182,7 +182,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 @@ -254,7 +254,7 @@ async def endpoint(req: Request, foo: str = Depends(get_foo)): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 diff --git a/tests/adapter_tests/starlette/test_starlette.py b/tests/adapter_tests/starlette/test_starlette.py index 8c6154b3b..18066a9d2 100644 --- a/tests/adapter_tests/starlette/test_starlette.py +++ b/tests/adapter_tests/starlette/test_starlette.py @@ -97,7 +97,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 @@ -143,7 +143,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 @@ -189,7 +189,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 diff --git a/tests/adapter_tests_async/test_async_fastapi.py b/tests/adapter_tests_async/test_async_fastapi.py index ea9308842..e0175d3fa 100644 --- a/tests/adapter_tests_async/test_async_fastapi.py +++ b/tests/adapter_tests_async/test_async_fastapi.py @@ -94,7 +94,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 @@ -138,7 +138,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 @@ -182,7 +182,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 @@ -255,7 +255,7 @@ async def endpoint(req: Request, foo: str = Depends(get_foo)): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 diff --git a/tests/adapter_tests_async/test_async_starlette.py b/tests/adapter_tests_async/test_async_starlette.py index 7e9a18a58..849c75168 100644 --- a/tests/adapter_tests_async/test_async_starlette.py +++ b/tests/adapter_tests_async/test_async_starlette.py @@ -97,7 +97,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 @@ -143,7 +143,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 @@ -189,7 +189,7 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( "/slack/events", - data=body, + content=body, headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index 9bbad6d9a..9fe6f423f 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -1,7 +1,7 @@ import logging import time from concurrent.futures import Executor -from ssl import SSLContext +import ssl import pytest from slack_sdk import WebClient @@ -236,12 +236,12 @@ def test_none_body_no_middleware(self): assert response.body == '{"error": "unhandled request"}' def test_proxy_ssl_for_respond(self): - ssl = SSLContext() + ssl_context = ssl.create_default_context() web_client = WebClient( token=self.valid_token, base_url=self.mock_api_server_base_url, proxy="http://proxy-host:9000/", - ssl=ssl, + ssl=ssl_context, ) app = App( signing_secret="valid", @@ -257,9 +257,9 @@ def test_proxy_ssl_for_respond(self): @app.event("app_mention") def handle(context: BoltContext, respond): assert context.respond.proxy == "http://proxy-host:9000/" - assert context.respond.ssl == ssl + assert context.respond.ssl == ssl_context assert respond.proxy == "http://proxy-host:9000/" - assert respond.ssl == ssl + assert respond.ssl == ssl_context result["called"] = True req = BoltRequest(body=app_mention_event_body, headers={}, mode="socket_mode") diff --git a/tests/scenario_tests/test_lazy.py b/tests/scenario_tests/test_lazy.py index d9e88b280..3b2aefbea 100644 --- a/tests/scenario_tests/test_lazy.py +++ b/tests/scenario_tests/test_lazy.py @@ -156,11 +156,11 @@ def async2(context, say): @app.middleware def set_ssl_context(context, next_): - from ssl import SSLContext + import ssl context["foo"] = "FOO" # This causes an error when starting lazy listener executions - context["ssl_context"] = SSLContext() + context["ssl_context"] = ssl.create_default_context() next_() # 2021-12-13 11:14:29 ERROR Failed to run a middleware middleware (error: cannot pickle 'SSLContext' object) diff --git a/tests/scenario_tests_async/test_app.py b/tests/scenario_tests_async/test_app.py index e27dbd3b3..6f3fb34f8 100644 --- a/tests/scenario_tests_async/test_app.py +++ b/tests/scenario_tests_async/test_app.py @@ -1,6 +1,6 @@ import asyncio import logging -from ssl import SSLContext +import ssl import pytest from slack_sdk import WebClient @@ -185,14 +185,14 @@ def test_installation_store_conflicts(self): @pytest.mark.asyncio async def test_proxy_ssl_for_respond(self): - ssl = SSLContext() + ssl_ctx = ssl.create_default_context() app = AsyncApp( signing_secret="valid", client=AsyncWebClient( token=self.valid_token, base_url=self.mock_api_server_base_url, proxy="http://proxy-host:9000/", - ssl=ssl, + ssl=ssl_ctx, ), authorize=my_authorize, ) @@ -202,9 +202,9 @@ async def test_proxy_ssl_for_respond(self): @app.event("app_mention") async def handle(context: AsyncBoltContext, respond): assert context.respond.proxy == "http://proxy-host:9000/" - assert context.respond.ssl == ssl + assert context.respond.ssl == ssl_ctx assert respond.proxy == "http://proxy-host:9000/" - assert respond.ssl == ssl + assert respond.ssl == ssl_ctx result["called"] = True req = AsyncBoltRequest(body=app_mention_event_body, headers={}, mode="socket_mode") diff --git a/tests/scenario_tests_async/test_lazy.py b/tests/scenario_tests_async/test_lazy.py index 7bf780e08..8c4182f45 100644 --- a/tests/scenario_tests_async/test_lazy.py +++ b/tests/scenario_tests_async/test_lazy.py @@ -138,11 +138,11 @@ async def async2(context, say): @app.middleware async def set_ssl_context(context, next_): - from ssl import SSLContext + import ssl context["foo"] = "FOO" # This causes an error when starting lazy listener executions - context["ssl_context"] = SSLContext() + context["ssl_context"] = ssl.create_default_context() await next_() # 2021-12-13 11:52:46 ERROR Failed to run a middleware function (error: cannot pickle 'SSLContext' object) From f11dbfbd06bf284a2ea4c65a4064f781c18fe5e0 Mon Sep 17 00:00:00 2001 From: Ale Mercado <104795114+srtaalej@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:41:24 -0400 Subject: [PATCH 855/865] fix(assistant): get_thread_context calls store.find() for user_message events (#1453) Co-authored-by: William Bergamin --- .../async_get_thread_context.py | 10 +++---- .../get_thread_context/get_thread_context.py | 10 +++---- tests/scenario_tests/test_events_assistant.py | 25 ++++++++++++++++++ .../test_events_assistant.py | 26 +++++++++++++++++++ 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/slack_bolt/context/get_thread_context/async_get_thread_context.py b/slack_bolt/context/get_thread_context/async_get_thread_context.py index cb8683a10..03f7c6076 100644 --- a/slack_bolt/context/get_thread_context/async_get_thread_context.py +++ b/slack_bolt/context/get_thread_context/async_get_thread_context.py @@ -31,14 +31,10 @@ async def __call__(self) -> Optional[AssistantThreadContext]: if self.thread_context_loaded is True: return self._thread_context - if self.payload.get("assistant_thread") is not None: + thread = self.payload.get("assistant_thread") + if isinstance(thread, dict) and thread.get("context", {}).get("channel_id") is not None: # assistant_thread_started - thread = self.payload["assistant_thread"] - self._thread_context = ( - AssistantThreadContext(thread["context"]) - if thread.get("context", {}).get("channel_id") is not None - else None - ) + self._thread_context = AssistantThreadContext(thread["context"]) # for this event, the context will never be changed self.thread_context_loaded = True elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: diff --git a/slack_bolt/context/get_thread_context/get_thread_context.py b/slack_bolt/context/get_thread_context/get_thread_context.py index 0a77d2d9f..b9c9751e1 100644 --- a/slack_bolt/context/get_thread_context/get_thread_context.py +++ b/slack_bolt/context/get_thread_context/get_thread_context.py @@ -31,14 +31,10 @@ def __call__(self) -> Optional[AssistantThreadContext]: if self.thread_context_loaded is True: return self._thread_context - if self.payload.get("assistant_thread") is not None: + thread = self.payload.get("assistant_thread") + if isinstance(thread, dict) and thread.get("context", {}).get("channel_id") is not None: # assistant_thread_started - thread = self.payload["assistant_thread"] - self._thread_context = ( - AssistantThreadContext(thread["context"]) - if thread.get("context", {}).get("channel_id") is not None - else None - ) + self._thread_context = AssistantThreadContext(thread["context"]) # for this event, the context will never be changed self.thread_context_loaded = True elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: diff --git a/tests/scenario_tests/test_events_assistant.py b/tests/scenario_tests/test_events_assistant.py index a970c9fa4..a1c3f1343 100644 --- a/tests/scenario_tests/test_events_assistant.py +++ b/tests/scenario_tests/test_events_assistant.py @@ -133,6 +133,12 @@ def handle_bot_message(): app.assistant(assistant) + request = BoltRequest(body=user_message_event_body_with_action_token, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert listener_called.wait(timeout=0.1) is True + listener_called.clear() + request = BoltRequest(body=message_changed_event_body, mode="socket_mode") response = app.dispatch(request) assert response.status == 200 @@ -332,6 +338,25 @@ def build_payload(event: dict) -> dict: } ) +user_message_event_body_with_action_token = build_payload( + { + "user": "W222", + "type": "message", + "ts": "1726133700.887259", + "text": "When Slack was released?", + "team": "T111", + "user_team": "T111", + "source_team": "T222", + "user_profile": {}, + "thread_ts": "1726133698.626339", + "parent_user_id": "W222", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + "assistant_thread": {"action_token": "10647138185092.960436384805.afce3599"}, + } +) + message_changed_event_body = build_payload( { "type": "message", diff --git a/tests/scenario_tests_async/test_events_assistant.py b/tests/scenario_tests_async/test_events_assistant.py index 9b2e43eb1..9ccd80c11 100644 --- a/tests/scenario_tests_async/test_events_assistant.py +++ b/tests/scenario_tests_async/test_events_assistant.py @@ -157,6 +157,13 @@ async def handle_bot_message(): app.assistant(assistant) + request = AsyncBoltRequest(body=user_message_event_body_with_action_token, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await asyncio.sleep(0.1) + assert listener_called.is_set() + listener_called.clear() + request = AsyncBoltRequest(body=message_changed_event_body, mode="socket_mode") response = await app.async_dispatch(request) assert response.status == 200 @@ -405,6 +412,25 @@ def build_payload(event: dict) -> dict: ) +user_message_event_body_with_action_token = build_payload( + { + "user": "W222", + "type": "message", + "ts": "1726133700.887259", + "text": "When Slack was released?", + "team": "T111", + "user_team": "T111", + "source_team": "T222", + "user_profile": {}, + "thread_ts": "1726133698.626339", + "parent_user_id": "W222", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + "assistant_thread": {"action_token": "10647138185092.960436384805.afce3599"}, + } +) + message_changed_event_body = build_payload( { "type": "message", From 89088857d958c0ba34d036e90bc28db879c76ad5 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 26 Mar 2026 13:36:54 -0700 Subject: [PATCH 856/865] chore: improve type checking behavior (#1470) --- .github/workflows/ci-build.yml | 15 +++++++++++++-- slack_bolt/app/async_server.py | 11 +++++++---- slack_bolt/context/async_context.py | 7 +++++-- slack_bolt/context/context.py | 7 +++++-- .../listener/async_listener_error_handler.py | 7 ++++--- slack_bolt/listener/listener_error_handler.py | 7 ++++--- .../middleware/async_middleware_error_handler.py | 7 ++++--- slack_bolt/middleware/middleware_error_handler.py | 7 ++++--- slack_bolt/oauth/async_callback_options.py | 9 ++++++--- slack_bolt/oauth/callback_options.py | 9 ++++++--- 10 files changed, 58 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 6555a6531..6c4cd5a6a 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -44,8 +44,19 @@ jobs: uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.LATEST_SUPPORTED_PY }} - - name: Run mypy verification - run: ./scripts/run_mypy.sh + - name: Install synchronous dependencies + run: | + pip install -U pip + pip install -U . + pip install -r requirements/tools.txt + - name: Type check synchronous modules + run: mypy --config-file pyproject.toml --exclude "async_|/adapter/" + - name: Install async and adapter dependencies + run: | + pip install -r requirements/async.txt + pip install -r requirements/adapter.txt + - name: Type check all modules + run: mypy --config-file pyproject.toml unittest: name: Unit tests diff --git a/slack_bolt/app/async_server.py b/slack_bolt/app/async_server.py index 998cd5a4b..f21d35932 100644 --- a/slack_bolt/app/async_server.py +++ b/slack_bolt/app/async_server.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +from typing import Optional, TYPE_CHECKING from aiohttp import web @@ -7,19 +7,22 @@ from slack_bolt.response import BoltResponse from slack_bolt.util.utils import get_boot_message +if TYPE_CHECKING: + from slack_bolt.app.async_app import AsyncApp + class AsyncSlackAppServer: port: int path: str host: str - bolt_app: "AsyncApp" # type: ignore[name-defined] + bolt_app: "AsyncApp" web_app: web.Application def __init__( self, port: int, path: str, - app: "AsyncApp", # type: ignore[name-defined] + app: "AsyncApp", host: Optional[str] = None, ): """Standalone AIOHTTP Web Server. @@ -34,7 +37,7 @@ def __init__( self.port = port self.path = path self.host = host if host is not None else "0.0.0.0" - self.bolt_app: "AsyncApp" = app # type: ignore[name-defined] + self.bolt_app: "AsyncApp" = app self.web_app = web.Application() self._bolt_oauth_flow = self.bolt_app.oauth_flow if self._bolt_oauth_flow: diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index 33f260d38..94b2b5cbe 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, TYPE_CHECKING from slack_sdk.web.async_client import AsyncWebClient @@ -16,6 +16,9 @@ from slack_bolt.context.set_title.async_set_title import AsyncSetTitle from slack_bolt.util.utils import create_copy +if TYPE_CHECKING: + from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner + class AsyncBoltContext(BaseContext): """Context object associated with a request from Slack.""" @@ -42,7 +45,7 @@ def to_copyable(self) -> "AsyncBoltContext": # The return type is intentionally string to avoid circular imports @property - def listener_runner(self) -> "AsyncioListenerRunner": # type: ignore[name-defined] + def listener_runner(self) -> "AsyncioListenerRunner": """The properly configured listener_runner that is available for middleware/listeners.""" return self["listener_runner"] diff --git a/slack_bolt/context/context.py b/slack_bolt/context/context.py index 6184d5083..b101460a5 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, TYPE_CHECKING from slack_sdk import WebClient @@ -16,6 +16,9 @@ from slack_bolt.context.set_title import SetTitle from slack_bolt.util.utils import create_copy +if TYPE_CHECKING: + from slack_bolt.listener.thread_runner import ThreadListenerRunner + class BoltContext(BaseContext): """Context object associated with a request from Slack.""" @@ -43,7 +46,7 @@ def to_copyable(self) -> "BoltContext": # The return type is intentionally string to avoid circular imports @property - def listener_runner(self) -> "ThreadListenerRunner": # type: ignore[name-defined] + def listener_runner(self) -> "ThreadListenerRunner": """The properly configured listener_runner that is available for middleware/listeners.""" return self["listener_runner"] diff --git a/slack_bolt/listener/async_listener_error_handler.py b/slack_bolt/listener/async_listener_error_handler.py index 88f4b3510..b1a73458e 100644 --- a/slack_bolt/listener/async_listener_error_handler.py +++ b/slack_bolt/listener/async_listener_error_handler.py @@ -48,9 +48,10 @@ async def handle( ) returned_response = await self.func(**kwargs) if returned_response is not None and isinstance(returned_response, BoltResponse): - response.status = returned_response.status # type: ignore[union-attr] - response.headers = returned_response.headers # type: ignore[union-attr] - response.body = returned_response.body # type: ignore[union-attr] + assert response is not None, "response must be provided when returning a BoltResponse from an error handler" + response.status = returned_response.status + response.headers = returned_response.headers + response.body = returned_response.body class AsyncDefaultListenerErrorHandler(AsyncListenerErrorHandler): diff --git a/slack_bolt/listener/listener_error_handler.py b/slack_bolt/listener/listener_error_handler.py index 0ad98f738..7dd6d066b 100644 --- a/slack_bolt/listener/listener_error_handler.py +++ b/slack_bolt/listener/listener_error_handler.py @@ -48,9 +48,10 @@ def handle( ) returned_response = self.func(**kwargs) if returned_response is not None and isinstance(returned_response, BoltResponse): - response.status = returned_response.status # type: ignore[union-attr] - response.headers = returned_response.headers # type: ignore[union-attr] - response.body = returned_response.body # type: ignore[union-attr] + assert response is not None, "response must be provided when returning a BoltResponse from an error handler" + response.status = returned_response.status + response.headers = returned_response.headers + response.body = returned_response.body class DefaultListenerErrorHandler(ListenerErrorHandler): diff --git a/slack_bolt/middleware/async_middleware_error_handler.py b/slack_bolt/middleware/async_middleware_error_handler.py index 1957d3ab6..932b0770b 100644 --- a/slack_bolt/middleware/async_middleware_error_handler.py +++ b/slack_bolt/middleware/async_middleware_error_handler.py @@ -48,9 +48,10 @@ async def handle( ) returned_response = await self.func(**kwargs) if returned_response is not None and isinstance(returned_response, BoltResponse): - response.status = returned_response.status # type: ignore[union-attr] - response.headers = returned_response.headers # type: ignore[union-attr] - response.body = returned_response.body # type: ignore[union-attr] + assert response is not None, "response must be provided when returning a BoltResponse from an error handler" + response.status = returned_response.status + response.headers = returned_response.headers + response.body = returned_response.body class AsyncDefaultMiddlewareErrorHandler(AsyncMiddlewareErrorHandler): diff --git a/slack_bolt/middleware/middleware_error_handler.py b/slack_bolt/middleware/middleware_error_handler.py index fe57e400c..5919414bb 100644 --- a/slack_bolt/middleware/middleware_error_handler.py +++ b/slack_bolt/middleware/middleware_error_handler.py @@ -48,9 +48,10 @@ def handle( ) returned_response = self.func(**kwargs) if returned_response is not None and isinstance(returned_response, BoltResponse): - response.status = returned_response.status # type: ignore[union-attr] - response.headers = returned_response.headers # type: ignore[union-attr] - response.body = returned_response.body # type: ignore[union-attr] + assert response is not None, "response must be provided when returning a BoltResponse from an error handler" + response.status = returned_response.status + response.headers = returned_response.headers + response.body = returned_response.body class DefaultMiddlewareErrorHandler(MiddlewareErrorHandler): diff --git a/slack_bolt/oauth/async_callback_options.py b/slack_bolt/oauth/async_callback_options.py index 88518d7e8..e1c2b2e4c 100644 --- a/slack_bolt/oauth/async_callback_options.py +++ b/slack_bolt/oauth/async_callback_options.py @@ -1,6 +1,6 @@ import logging from logging import Logger -from typing import Optional, Callable, Awaitable +from typing import Optional, Callable, Awaitable, TYPE_CHECKING from slack_sdk.oauth import RedirectUriPageRenderer, OAuthStateUtils from slack_sdk.oauth.installation_store import Installation @@ -9,6 +9,9 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse +if TYPE_CHECKING: + from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings + class AsyncSuccessArgs: def __init__( @@ -16,7 +19,7 @@ def __init__( *, request: AsyncBoltRequest, installation: Installation, - settings: "AsyncOAuthSettings", # type: ignore[name-defined] + settings: "AsyncOAuthSettings", default: "AsyncCallbackOptions", ): """The arguments for a success function. @@ -41,7 +44,7 @@ def __init__( reason: str, error: Optional[Exception] = None, suggested_status_code: int, - settings: "AsyncOAuthSettings", # type: ignore[name-defined] + settings: "AsyncOAuthSettings", default: "AsyncCallbackOptions", ): """The arguments for a failure function. diff --git a/slack_bolt/oauth/callback_options.py b/slack_bolt/oauth/callback_options.py index f267ed154..09584a365 100644 --- a/slack_bolt/oauth/callback_options.py +++ b/slack_bolt/oauth/callback_options.py @@ -1,6 +1,6 @@ import logging from logging import Logger -from typing import Optional, Callable +from typing import Optional, Callable, TYPE_CHECKING from slack_sdk.oauth import RedirectUriPageRenderer, OAuthStateUtils from slack_sdk.oauth.installation_store import Installation @@ -9,6 +9,9 @@ from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse +if TYPE_CHECKING: + from slack_bolt.oauth.oauth_settings import OAuthSettings + class SuccessArgs: def __init__( @@ -16,7 +19,7 @@ def __init__( *, request: BoltRequest, installation: Installation, - settings: "OAuthSettings", # type: ignore[name-defined] + settings: "OAuthSettings", default: "CallbackOptions", ): """The arguments for a success function. @@ -41,7 +44,7 @@ def __init__( reason: str, error: Optional[Exception] = None, suggested_status_code: int, - settings: "OAuthSettings", # type: ignore[name-defined] + settings: "OAuthSettings", default: "CallbackOptions", ): """The arguments for a failure function. From 9d0e0af36109393456b9663e539d9fa642d5711b Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 1 Apr 2026 14:55:30 -0400 Subject: [PATCH 857/865] refactor: rename AttachingAgentKwargs middleware to AttachingConversationKwargs (#1473) --- slack_bolt/app/app.py | 14 +++++++------- slack_bolt/app/async_app.py | 14 +++++++------- slack_bolt/kwargs_injection/args.py | 2 +- slack_bolt/middleware/__init__.py | 4 ++-- slack_bolt/middleware/assistant/assistant.py | 4 ++-- slack_bolt/middleware/assistant/async_assistant.py | 6 ++++-- slack_bolt/middleware/async_builtins.py | 4 ++-- .../middleware/attaching_agent_kwargs/__init__.py | 5 ----- .../attaching_conversation_kwargs/__init__.py | 5 +++++ .../async_attaching_conversation_kwargs.py} | 2 +- .../attaching_conversation_kwargs.py} | 2 +- .../test_events_assistant_without_middleware.py | 4 ++-- .../scenario_tests_async/test_events_assistant.py | 2 +- .../test_events_assistant_without_middleware.py | 4 ++-- .../__init__.py | 0 .../test_attaching_conversation_kwargs.py} | 12 ++++++------ .../__init__.py | 0 .../test_async_attaching_conversation_kwargs.py} | 14 ++++++++------ 18 files changed, 51 insertions(+), 47 deletions(-) delete mode 100644 slack_bolt/middleware/attaching_agent_kwargs/__init__.py create mode 100644 slack_bolt/middleware/attaching_conversation_kwargs/__init__.py rename slack_bolt/middleware/{attaching_agent_kwargs/async_attaching_agent_kwargs.py => attaching_conversation_kwargs/async_attaching_conversation_kwargs.py} (97%) rename slack_bolt/middleware/{attaching_agent_kwargs/attaching_agent_kwargs.py => attaching_conversation_kwargs/attaching_conversation_kwargs.py} (98%) rename tests/slack_bolt/middleware/{attaching_agent_kwargs => attaching_conversation_kwargs}/__init__.py (100%) rename tests/slack_bolt/middleware/{attaching_agent_kwargs/test_attaching_agent_kwargs.py => attaching_conversation_kwargs/test_attaching_conversation_kwargs.py} (88%) rename tests/slack_bolt_async/middleware/{attaching_agent_kwargs => attaching_conversation_kwargs}/__init__.py (100%) rename tests/slack_bolt_async/middleware/{attaching_agent_kwargs/test_async_attaching_agent_kwargs.py => attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py} (87%) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 566eb82d7..0af27913c 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -69,7 +69,7 @@ IgnoringSelfEvents, CustomMiddleware, AttachingFunctionToken, - AttachingAgentKwargs, + AttachingConversationKwargs, ) from slack_bolt.middleware.assistant import Assistant from slack_bolt.middleware.message_listener_matches import MessageListenerMatches @@ -133,7 +133,7 @@ def __init__( listener_executor: Optional[Executor] = None, # for AI Agents & Assistants assistant_thread_context_store: Optional[AssistantThreadContextStore] = None, - attaching_agent_kwargs_enabled: bool = True, + attaching_conversation_kwargs_enabled: bool = True, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -354,7 +354,7 @@ def message_hello(message, say): listener_executor = ThreadPoolExecutor(max_workers=5) self._assistant_thread_context_store = assistant_thread_context_store - self._attaching_agent_kwargs_enabled = attaching_agent_kwargs_enabled + self._attaching_conversation_kwargs_enabled = attaching_conversation_kwargs_enabled self._process_before_response = process_before_response self._listener_runner = ThreadListenerRunner( @@ -844,8 +844,8 @@ def ask_for_introduction(event, say): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, base_logger=self._base_logger) - if self._attaching_agent_kwargs_enabled: - middleware.insert(0, AttachingAgentKwargs(self._assistant_thread_context_store)) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__ @@ -903,8 +903,8 @@ def __call__(*args, **kwargs): primary_matcher = builtin_matchers.message_event( keyword=keyword, constraints=constraints, base_logger=self._base_logger ) - if self._attaching_agent_kwargs_enabled: - middleware.insert(0, AttachingAgentKwargs(self._assistant_thread_context_store)) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 9cd8c911f..cc94f9e15 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -86,7 +86,7 @@ AsyncIgnoringSelfEvents, AsyncUrlVerification, AsyncAttachingFunctionToken, - AsyncAttachingAgentKwargs, + AsyncAttachingConversationKwargs, ) from slack_bolt.middleware.async_custom_middleware import ( AsyncMiddleware, @@ -142,7 +142,7 @@ def __init__( verification_token: Optional[str] = None, # for AI Agents & Assistants assistant_thread_context_store: Optional[AsyncAssistantThreadContextStore] = None, - attaching_agent_kwargs_enabled: bool = True, + attaching_conversation_kwargs_enabled: bool = True, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -363,7 +363,7 @@ async def message_hello(message, say): # async function self._async_listeners: List[AsyncListener] = [] self._assistant_thread_context_store = assistant_thread_context_store - self._attaching_agent_kwargs_enabled = attaching_agent_kwargs_enabled + self._attaching_conversation_kwargs_enabled = attaching_conversation_kwargs_enabled self._process_before_response = process_before_response self._async_listener_runner = AsyncioListenerRunner( @@ -872,8 +872,8 @@ async def ask_for_introduction(event, say): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, True, base_logger=self._base_logger) - if self._attaching_agent_kwargs_enabled: - middleware.insert(0, AsyncAttachingAgentKwargs(self._assistant_thread_context_store)) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AsyncAttachingConversationKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__ @@ -934,8 +934,8 @@ def __call__(*args, **kwargs): asyncio=True, base_logger=self._base_logger, ) - if self._attaching_agent_kwargs_enabled: - middleware.insert(0, AsyncAttachingAgentKwargs(self._assistant_thread_context_store)) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AsyncAttachingConversationKwargs(self._assistant_thread_context_store)) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) diff --git a/slack_bolt/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index 4cd70176d..f2b4099d6 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -104,7 +104,7 @@ def handle_buttons(args): save_thread_context: Optional[SaveThreadContext] """`save_thread_context()` utility function for AI Agents & Assistants""" say_stream: Optional[SayStream] - """`say_stream()` utility function for AI Agents & Assistants""" + """`say_stream()` utility function for conversations, AI Agents & Assistants""" # middleware next: Callable[[], None] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" diff --git a/slack_bolt/middleware/__init__.py b/slack_bolt/middleware/__init__.py index 7b51fb239..c28ffd78d 100644 --- a/slack_bolt/middleware/__init__.py +++ b/slack_bolt/middleware/__init__.py @@ -17,7 +17,7 @@ from .ssl_check import SslCheck from .url_verification import UrlVerification from .attaching_function_token import AttachingFunctionToken -from .attaching_agent_kwargs import AttachingAgentKwargs +from .attaching_conversation_kwargs import AttachingConversationKwargs builtin_middleware_classes = [ SslCheck, @@ -42,6 +42,6 @@ "SslCheck", "UrlVerification", "AttachingFunctionToken", - "AttachingAgentKwargs", + "AttachingConversationKwargs", "builtin_middleware_classes", ] diff --git a/slack_bolt/middleware/assistant/assistant.py b/slack_bolt/middleware/assistant/assistant.py index 9696e826e..ad842f94d 100644 --- a/slack_bolt/middleware/assistant/assistant.py +++ b/slack_bolt/middleware/assistant/assistant.py @@ -7,7 +7,7 @@ from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore from slack_bolt.listener_matcher.builtins import build_listener_matcher -from slack_bolt.middleware.attaching_agent_kwargs import AttachingAgentKwargs +from slack_bolt.middleware.attaching_conversation_kwargs import AttachingConversationKwargs from slack_bolt.request.request import BoltRequest from slack_bolt.response.response import BoltResponse from slack_bolt.listener_matcher import CustomListenerMatcher @@ -272,7 +272,7 @@ def build_listener( return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] - middleware.insert(0, AttachingAgentKwargs(self.thread_context_store)) + middleware.insert(0, AttachingConversationKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) diff --git a/slack_bolt/middleware/assistant/async_assistant.py b/slack_bolt/middleware/assistant/async_assistant.py index d841e2de0..588de8b41 100644 --- a/slack_bolt/middleware/assistant/async_assistant.py +++ b/slack_bolt/middleware/assistant/async_assistant.py @@ -8,7 +8,9 @@ from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner from slack_bolt.listener_matcher.builtins import build_listener_matcher -from slack_bolt.middleware.attaching_agent_kwargs.async_attaching_agent_kwargs import AsyncAttachingAgentKwargs +from slack_bolt.middleware.attaching_conversation_kwargs.async_attaching_conversation_kwargs import ( + AsyncAttachingConversationKwargs, +) from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from slack_bolt.error import BoltError @@ -301,7 +303,7 @@ def build_listener( return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] - middleware.insert(0, AsyncAttachingAgentKwargs(self.thread_context_store)) + middleware.insert(0, AsyncAttachingConversationKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) diff --git a/slack_bolt/middleware/async_builtins.py b/slack_bolt/middleware/async_builtins.py index 755b55c20..8de07fb88 100644 --- a/slack_bolt/middleware/async_builtins.py +++ b/slack_bolt/middleware/async_builtins.py @@ -10,7 +10,7 @@ AsyncMessageListenerMatches, ) from .attaching_function_token.async_attaching_function_token import AsyncAttachingFunctionToken -from .attaching_agent_kwargs.async_attaching_agent_kwargs import AsyncAttachingAgentKwargs +from .attaching_conversation_kwargs.async_attaching_conversation_kwargs import AsyncAttachingConversationKwargs __all__ = [ "AsyncIgnoringSelfEvents", @@ -19,5 +19,5 @@ "AsyncUrlVerification", "AsyncMessageListenerMatches", "AsyncAttachingFunctionToken", - "AsyncAttachingAgentKwargs", + "AsyncAttachingConversationKwargs", ] diff --git a/slack_bolt/middleware/attaching_agent_kwargs/__init__.py b/slack_bolt/middleware/attaching_agent_kwargs/__init__.py deleted file mode 100644 index 98926fc14..000000000 --- a/slack_bolt/middleware/attaching_agent_kwargs/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .attaching_agent_kwargs import AttachingAgentKwargs - -__all__ = [ - "AttachingAgentKwargs", -] diff --git a/slack_bolt/middleware/attaching_conversation_kwargs/__init__.py b/slack_bolt/middleware/attaching_conversation_kwargs/__init__.py new file mode 100644 index 000000000..ec72e0037 --- /dev/null +++ b/slack_bolt/middleware/attaching_conversation_kwargs/__init__.py @@ -0,0 +1,5 @@ +from .attaching_conversation_kwargs import AttachingConversationKwargs + +__all__ = [ + "AttachingConversationKwargs", +] diff --git a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py similarity index 97% rename from slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py rename to slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py index 82f1a7671..315ec2a50 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py @@ -10,7 +10,7 @@ from slack_bolt.response import BoltResponse -class AsyncAttachingAgentKwargs(AsyncMiddleware): +class AsyncAttachingConversationKwargs(AsyncMiddleware): thread_context_store: Optional[AsyncAssistantThreadContextStore] diff --git a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py similarity index 98% rename from slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py rename to slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py index 70f41d561..33847fd56 100644 --- a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py +++ b/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py @@ -10,7 +10,7 @@ from slack_bolt.response.response import BoltResponse -class AttachingAgentKwargs(Middleware): +class AttachingConversationKwargs(Middleware): thread_context_store: Optional[AssistantThreadContextStore] diff --git a/tests/scenario_tests/test_events_assistant_without_middleware.py b/tests/scenario_tests/test_events_assistant_without_middleware.py index c95f16f99..18072c05e 100644 --- a/tests/scenario_tests/test_events_assistant_without_middleware.py +++ b/tests/scenario_tests/test_events_assistant_without_middleware.py @@ -245,8 +245,8 @@ def handle_message_event( assert response.status == 200 assert listener_called.wait(timeout=0.1) is True - def test_assistant_events_agent_kwargs_disabled(self): - app = App(client=self.web_client, attaching_agent_kwargs_enabled=False) + def test_assistant_events_conversation_kwargs_disabled(self): + app = App(client=self.web_client, attaching_conversation_kwargs_enabled=False) listener_called = Event() diff --git a/tests/scenario_tests_async/test_events_assistant.py b/tests/scenario_tests_async/test_events_assistant.py index 9ccd80c11..edc77ecf3 100644 --- a/tests/scenario_tests_async/test_events_assistant.py +++ b/tests/scenario_tests_async/test_events_assistant.py @@ -216,7 +216,7 @@ async def handle_bot_message(): @pytest.mark.asyncio async def test_assistant_events_kwargs_disabled(self): - app = AsyncApp(client=self.web_client, attaching_agent_kwargs_enabled=False) + app = AsyncApp(client=self.web_client, attaching_conversation_kwargs_enabled=False) listener_called = asyncio.Event() @app.event("assistant_thread_started") diff --git a/tests/scenario_tests_async/test_events_assistant_without_middleware.py b/tests/scenario_tests_async/test_events_assistant_without_middleware.py index 4e82cb2c1..d72b09b04 100644 --- a/tests/scenario_tests_async/test_events_assistant_without_middleware.py +++ b/tests/scenario_tests_async/test_events_assistant_without_middleware.py @@ -268,8 +268,8 @@ async def handle_message_event( assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True @pytest.mark.asyncio - async def test_assistant_events_agent_kwargs_disabled(self): - app = AsyncApp(client=self.web_client, attaching_agent_kwargs_enabled=False) + async def test_assistant_events_conversation_kwargs_disabled(self): + app = AsyncApp(client=self.web_client, attaching_conversation_kwargs_enabled=False) listener_called = asyncio.Event() diff --git a/tests/slack_bolt/middleware/attaching_agent_kwargs/__init__.py b/tests/slack_bolt/middleware/attaching_conversation_kwargs/__init__.py similarity index 100% rename from tests/slack_bolt/middleware/attaching_agent_kwargs/__init__.py rename to tests/slack_bolt/middleware/attaching_conversation_kwargs/__init__.py diff --git a/tests/slack_bolt/middleware/attaching_agent_kwargs/test_attaching_agent_kwargs.py b/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py similarity index 88% rename from tests/slack_bolt/middleware/attaching_agent_kwargs/test_attaching_agent_kwargs.py rename to tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py index 8e626fd0c..b7785eb50 100644 --- a/tests/slack_bolt/middleware/attaching_agent_kwargs/test_attaching_agent_kwargs.py +++ b/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py @@ -1,6 +1,6 @@ from slack_sdk import WebClient -from slack_bolt.middleware.attaching_agent_kwargs import AttachingAgentKwargs +from slack_bolt.middleware.attaching_conversation_kwargs import AttachingConversationKwargs from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from tests.scenario_tests.test_events_assistant import ( @@ -17,9 +17,9 @@ def next(): ASSISTANT_KWARGS = ("say", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") -class TestAttachingAgentKwargs: +class TestAttachingConversationKwargs: def test_assistant_event_attaches_kwargs(self): - middleware = AttachingAgentKwargs() + middleware = AttachingConversationKwargs() req = BoltRequest(body=thread_started_event_body, mode="socket_mode") req.context["client"] = WebClient(token="xoxb-test") @@ -33,7 +33,7 @@ def test_assistant_event_attaches_kwargs(self): assert "set_status" in req.context def test_user_message_event_attaches_kwargs(self): - middleware = AttachingAgentKwargs() + middleware = AttachingConversationKwargs() req = BoltRequest(body=user_message_event_body, mode="socket_mode") req.context["client"] = WebClient(token="xoxb-test") @@ -47,7 +47,7 @@ def test_user_message_event_attaches_kwargs(self): assert "set_status" in req.context def test_non_assistant_event_does_not_attach_kwargs(self): - middleware = AttachingAgentKwargs() + middleware = AttachingConversationKwargs() req = BoltRequest(body=channel_user_message_event_body, mode="socket_mode") req.context["client"] = WebClient(token="xoxb-test") @@ -60,7 +60,7 @@ def test_non_assistant_event_does_not_attach_kwargs(self): assert "set_status" in req.context def test_non_event_does_not_attach_kwargs(self): - middleware = AttachingAgentKwargs() + middleware = AttachingConversationKwargs() req = BoltRequest(body="payload={}", headers={}) resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) diff --git a/tests/slack_bolt_async/middleware/attaching_agent_kwargs/__init__.py b/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/__init__.py similarity index 100% rename from tests/slack_bolt_async/middleware/attaching_agent_kwargs/__init__.py rename to tests/slack_bolt_async/middleware/attaching_conversation_kwargs/__init__.py diff --git a/tests/slack_bolt_async/middleware/attaching_agent_kwargs/test_async_attaching_agent_kwargs.py b/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py similarity index 87% rename from tests/slack_bolt_async/middleware/attaching_agent_kwargs/test_async_attaching_agent_kwargs.py rename to tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py index 61aa0b59e..a00b35cd3 100644 --- a/tests/slack_bolt_async/middleware/attaching_agent_kwargs/test_async_attaching_agent_kwargs.py +++ b/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py @@ -1,7 +1,9 @@ import pytest from slack_sdk.web.async_client import AsyncWebClient -from slack_bolt.middleware.attaching_agent_kwargs.async_attaching_agent_kwargs import AsyncAttachingAgentKwargs +from slack_bolt.middleware.attaching_conversation_kwargs.async_attaching_conversation_kwargs import ( + AsyncAttachingConversationKwargs, +) from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from tests.scenario_tests_async.test_events_assistant import ( @@ -18,10 +20,10 @@ async def next(): ASSISTANT_KWARGS = ("say", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") -class TestAsyncAttachingAgentKwargs: +class TestAsyncAttachingConversationKwargs: @pytest.mark.asyncio async def test_assistant_event_attaches_kwargs(self): - middleware = AsyncAttachingAgentKwargs() + middleware = AsyncAttachingConversationKwargs() req = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") req.context["client"] = AsyncWebClient(token="xoxb-test") @@ -36,7 +38,7 @@ async def test_assistant_event_attaches_kwargs(self): @pytest.mark.asyncio async def test_user_message_event_attaches_kwargs(self): - middleware = AsyncAttachingAgentKwargs() + middleware = AsyncAttachingConversationKwargs() req = AsyncBoltRequest(body=user_message_event_body, mode="socket_mode") req.context["client"] = AsyncWebClient(token="xoxb-test") @@ -51,7 +53,7 @@ async def test_user_message_event_attaches_kwargs(self): @pytest.mark.asyncio async def test_non_assistant_event_does_not_attach_kwargs(self): - middleware = AsyncAttachingAgentKwargs() + middleware = AsyncAttachingConversationKwargs() req = AsyncBoltRequest(body=channel_user_message_event_body, mode="socket_mode") req.context["client"] = AsyncWebClient(token="xoxb-test") @@ -65,7 +67,7 @@ async def test_non_assistant_event_does_not_attach_kwargs(self): @pytest.mark.asyncio async def test_non_event_does_not_attach_kwargs(self): - middleware = AsyncAttachingAgentKwargs() + middleware = AsyncAttachingConversationKwargs() req = AsyncBoltRequest(body="payload={}", headers={}) resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) From 4dee16d96ca41a62af417e3345e2fa4e2d813a99 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:43:41 +0000 Subject: [PATCH 858/865] chore(deps): bump actions/download-artifact from 8.0.0 to 8.0.1 (#1474) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pypi-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 7ec974574..dfc224c83 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -52,7 +52,7 @@ jobs: steps: - name: Retrieve dist folder - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-dist path: dist/ @@ -76,7 +76,7 @@ jobs: steps: - name: Retrieve dist folder - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-dist path: dist/ From 3f9d3761dba44a1c259055d56a046ba8e462a8c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:34:37 -0400 Subject: [PATCH 859/865] chore(deps): bump codecov/codecov-action from 5.5.2 to 6.0.0 (#1475) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 6c4cd5a6a..324ff7d80 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -126,7 +126,7 @@ jobs: pytest tests/scenario_tests_async/ --junitxml=reports/test_scenario_async.xml - name: Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: directory: ./reports/ fail_ci_if_error: true @@ -162,7 +162,7 @@ jobs: run: | pytest --cov=./slack_bolt/ --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: fail_ci_if_error: true report_type: coverage From 13a6dff9d6682593982604e587b7340dcc2e9d60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:39:18 -0400 Subject: [PATCH 860/865] chore(deps): bump slackapi/slack-github-action from 2.1.1 to 3.0.1 (#1476) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 324ff7d80..6d504ea83 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -179,7 +179,7 @@ jobs: if: ${{ !success() && github.ref == 'refs/heads/main' && github.event_name != 'workflow_dispatch' }} steps: - name: Send notifications of failing tests - uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 + uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 with: errors: true webhook: ${{ secrets.SLACK_REGRESSION_FAILURES_WEBHOOK_URL }} From dbe1590498a80903b5b5ce559b89c4640e84c775 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:46:04 -0400 Subject: [PATCH 861/865] chore(deps): bump dependabot/fetch-metadata from 2.5.0 to 3.0.0 (#1477) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dependencies.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 824d57701..9666057aa 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Collect metadata id: metadata - uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 + uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 # v3.0.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Approve From 064ef2e83ad9035827e1267243ee56130e5b12fd Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 6 Apr 2026 17:36:31 -0400 Subject: [PATCH 862/865] chore: remove experiment around say_stream (#1471) Co-authored-by: Eden Zimbelman --- .../context/say_stream/async_say_stream.py | 14 +------------ slack_bolt/context/say_stream/say_stream.py | 14 +------------ slack_bolt/warning/__init__.py | 7 ------- tests/slack_bolt/context/test_say_stream.py | 20 ++++-------------- .../context/test_async_say_stream.py | 21 ++++--------------- 5 files changed, 10 insertions(+), 66 deletions(-) delete mode 100644 slack_bolt/warning/__init__.py diff --git a/slack_bolt/context/say_stream/async_say_stream.py b/slack_bolt/context/say_stream/async_say_stream.py index dc752d02a..af776891b 100644 --- a/slack_bolt/context/say_stream/async_say_stream.py +++ b/slack_bolt/context/say_stream/async_say_stream.py @@ -1,11 +1,8 @@ -import warnings from typing import Optional from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_chat_stream import AsyncChatStream -from slack_bolt.warning import ExperimentalWarning - class AsyncSayStream: client: AsyncWebClient @@ -39,16 +36,7 @@ async def __call__( thread_ts: Optional[str] = None, **kwargs, ) -> AsyncChatStream: - """Starts a new chat stream with context. - - Warning: This is an experimental feature and may change in future versions. - """ - warnings.warn( - "say_stream is experimental and may change in future versions.", - category=ExperimentalWarning, - stacklevel=2, - ) - + """Starts a new chat stream with context.""" channel = channel or self.channel thread_ts = thread_ts or self.thread_ts if channel is None: diff --git a/slack_bolt/context/say_stream/say_stream.py b/slack_bolt/context/say_stream/say_stream.py index 1e1d7985f..b6a5ca797 100644 --- a/slack_bolt/context/say_stream/say_stream.py +++ b/slack_bolt/context/say_stream/say_stream.py @@ -1,11 +1,8 @@ -import warnings from typing import Optional from slack_sdk import WebClient from slack_sdk.web.chat_stream import ChatStream -from slack_bolt.warning import ExperimentalWarning - class SayStream: client: WebClient @@ -39,16 +36,7 @@ def __call__( thread_ts: Optional[str] = None, **kwargs, ) -> ChatStream: - """Starts a new chat stream with context. - - Warning: This is an experimental feature and may change in future versions. - """ - warnings.warn( - "say_stream is experimental and may change in future versions.", - category=ExperimentalWarning, - stacklevel=2, - ) - + """Starts a new chat stream with context.""" channel = channel or self.channel thread_ts = thread_ts or self.thread_ts if channel is None: diff --git a/slack_bolt/warning/__init__.py b/slack_bolt/warning/__init__.py deleted file mode 100644 index 4991f4cd9..000000000 --- a/slack_bolt/warning/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Bolt specific warning types.""" - - -class ExperimentalWarning(FutureWarning): - """Warning for features that are still in experimental phase.""" - - pass diff --git a/tests/slack_bolt/context/test_say_stream.py b/tests/slack_bolt/context/test_say_stream.py index c8f4c3a31..29d244a65 100644 --- a/tests/slack_bolt/context/test_say_stream.py +++ b/tests/slack_bolt/context/test_say_stream.py @@ -2,7 +2,6 @@ from slack_sdk import WebClient from slack_bolt.context.say_stream.say_stream import SayStream -from slack_bolt.warning import ExperimentalWarning from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server @@ -20,15 +19,13 @@ def teardown_method(self): def test_missing_channel_raises(self): say_stream = SayStream(client=self.web_client, channel=None, thread_ts="111.222") - with pytest.warns(ExperimentalWarning): - with pytest.raises(ValueError, match="channel"): - say_stream() + with pytest.raises(ValueError, match="channel"): + say_stream() def test_missing_thread_ts_raises(self): say_stream = SayStream(client=self.web_client, channel="C111", thread_ts=None) - with pytest.warns(ExperimentalWarning): - with pytest.raises(ValueError, match="thread_ts"): - say_stream() + with pytest.raises(ValueError, match="thread_ts"): + say_stream() def test_default_params(self): say_stream = SayStream( @@ -92,12 +89,3 @@ def test_buffer_size_overrides(self): "recipient_user_id": "U222", "task_display_mode": None, } - - def test_experimental_warning(self): - say_stream = SayStream( - client=self.web_client, - channel="C111", - thread_ts="111.222", - ) - with pytest.warns(ExperimentalWarning, match="say_stream is experimental"): - say_stream() diff --git a/tests/slack_bolt_async/context/test_async_say_stream.py b/tests/slack_bolt_async/context/test_async_say_stream.py index fbc4c5c7e..016549bd6 100644 --- a/tests/slack_bolt_async/context/test_async_say_stream.py +++ b/tests/slack_bolt_async/context/test_async_say_stream.py @@ -2,7 +2,6 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream -from slack_bolt.warning import ExperimentalWarning from tests.mock_web_api_server import ( cleanup_mock_web_api_server, setup_mock_web_api_server, @@ -29,16 +28,14 @@ def setup_teardown(self): @pytest.mark.asyncio async def test_missing_channel_raises(self): say_stream = AsyncSayStream(client=self.web_client, channel=None, thread_ts="111.222") - with pytest.warns(ExperimentalWarning): - with pytest.raises(ValueError, match="channel"): - await say_stream() + with pytest.raises(ValueError, match="channel"): + await say_stream() @pytest.mark.asyncio async def test_missing_thread_ts_raises(self): say_stream = AsyncSayStream(client=self.web_client, channel="C111", thread_ts=None) - with pytest.warns(ExperimentalWarning): - with pytest.raises(ValueError, match="thread_ts"): - await say_stream() + with pytest.raises(ValueError, match="thread_ts"): + await say_stream() @pytest.mark.asyncio async def test_default_params(self): @@ -105,13 +102,3 @@ async def test_buffer_size_overrides(self): "recipient_user_id": "U222", "task_display_mode": None, } - - @pytest.mark.asyncio - async def test_experimental_warning(self): - say_stream = AsyncSayStream( - client=self.web_client, - channel="C111", - thread_ts="111.222", - ) - with pytest.warns(ExperimentalWarning, match="say_stream is experimental"): - await say_stream() From c64d69d2b64801602c849aa56e0ba2d4161e1f98 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 6 Apr 2026 15:49:13 -0700 Subject: [PATCH 863/865] chore(release): version 1.28.0 (#1480) --- docs/reference/app/app.html | 30 ++- docs/reference/app/async_app.html | 34 ++- docs/reference/app/async_server.html | 6 +- docs/reference/app/index.html | 30 ++- docs/reference/async_app.html | 229 ++++++++++++++---- .../authorization/authorize_result.html | 4 +- docs/reference/authorization/index.html | 4 +- .../assistant/assistant_utilities.html | 14 ++ .../assistant/async_assistant_utilities.html | 14 ++ .../thread_context_store/file/index.html | 2 +- docs/reference/context/async_context.html | 25 +- docs/reference/context/base_context.html | 1 + docs/reference/context/context.html | 25 +- .../async_get_thread_context.html | 10 +- .../get_thread_context.html | 10 +- .../context/get_thread_context/index.html | 10 +- docs/reference/context/index.html | 30 ++- .../context/say_stream/async_say_stream.html | 174 +++++++++++++ docs/reference/context/say_stream/index.html | 191 +++++++++++++++ .../context/say_stream/say_stream.html | 174 +++++++++++++ docs/reference/error/index.html | 2 +- docs/reference/index.html | 212 +++++++++++++--- docs/reference/kwargs_injection/args.html | 11 +- .../kwargs_injection/async_args.html | 11 +- .../kwargs_injection/async_utils.html | 5 +- docs/reference/kwargs_injection/index.html | 16 +- docs/reference/kwargs_injection/utils.html | 5 +- .../async_listener_error_handler.html | 7 +- .../listener/listener_error_handler.html | 7 +- docs/reference/logger/messages.html | 4 +- .../middleware/assistant/assistant.html | 43 ++-- .../middleware/assistant/async_assistant.html | 59 +++-- .../reference/middleware/assistant/index.html | 43 ++-- docs/reference/middleware/async_builtins.html | 82 +++++++ .../middleware/async_middleware.html | 1 + .../async_middleware_error_handler.html | 7 +- .../async_attaching_conversation_kwargs.html | 155 ++++++++++++ .../attaching_conversation_kwargs.html | 149 ++++++++++++ .../attaching_conversation_kwargs/index.html | 166 +++++++++++++ docs/reference/middleware/index.html | 82 +++++++ docs/reference/middleware/middleware.html | 1 + .../middleware/middleware_error_handler.html | 7 +- .../oauth/async_callback_options.html | 4 +- .../reference/oauth/async_oauth_settings.html | 2 +- docs/reference/oauth/callback_options.html | 4 +- docs/reference/oauth/oauth_settings.html | 2 +- docs/reference/request/internals.html | 75 +++--- slack_bolt/version.py | 2 +- 48 files changed, 1868 insertions(+), 313 deletions(-) create mode 100644 docs/reference/context/say_stream/async_say_stream.html create mode 100644 docs/reference/context/say_stream/index.html create mode 100644 docs/reference/context/say_stream/say_stream.html create mode 100644 docs/reference/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.html create mode 100644 docs/reference/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.html create mode 100644 docs/reference/middleware/attaching_conversation_kwargs/index.html diff --git a/docs/reference/app/app.html b/docs/reference/app/app.html index c91d020ef..bf0d5ee00 100644 --- a/docs/reference/app/app.html +++ b/docs/reference/app/app.html @@ -48,7 +48,7 @@

    Classes

    class App -(*,
    logger: logging.Logger | None = None,
    name: str | None = None,
    process_before_response: bool = False,
    raise_error_for_unhandled_request: bool = False,
    signing_secret: str | None = None,
    token: str | None = None,
    token_verification_enabled: bool = True,
    client: slack_sdk.web.client.WebClient | None = None,
    before_authorize: Middleware | Callable[..., Any] | None = None,
    authorize: Callable[..., AuthorizeResult] | None = None,
    user_facing_authorize_error_message: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None = None,
    installation_store_bot_only: bool | None = None,
    request_verification_enabled: bool = True,
    ignoring_self_events_enabled: bool = True,
    ignoring_self_assistant_message_events_enabled: bool = True,
    ssl_check_enabled: bool = True,
    url_verification_enabled: bool = True,
    attaching_function_token_enabled: bool = True,
    oauth_settings: OAuthSettings | None = None,
    oauth_flow: OAuthFlow | None = None,
    verification_token: str | None = None,
    listener_executor: concurrent.futures._base.Executor | None = None,
    assistant_thread_context_store: AssistantThreadContextStore | None = None)
    +(*,
    logger: logging.Logger | None = None,
    name: str | None = None,
    process_before_response: bool = False,
    raise_error_for_unhandled_request: bool = False,
    signing_secret: str | None = None,
    token: str | None = None,
    token_verification_enabled: bool = True,
    client: slack_sdk.web.client.WebClient | None = None,
    before_authorize: Middleware | Callable[..., Any] | None = None,
    authorize: Callable[..., AuthorizeResult] | None = None,
    user_facing_authorize_error_message: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None = None,
    installation_store_bot_only: bool | None = None,
    request_verification_enabled: bool = True,
    ignoring_self_events_enabled: bool = True,
    ignoring_self_assistant_message_events_enabled: bool = True,
    ssl_check_enabled: bool = True,
    url_verification_enabled: bool = True,
    attaching_function_token_enabled: bool = True,
    oauth_settings: OAuthSettings | None = None,
    oauth_flow: OAuthFlow | None = None,
    verification_token: str | None = None,
    listener_executor: concurrent.futures._base.Executor | None = None,
    assistant_thread_context_store: AssistantThreadContextStore | None = None,
    attaching_conversation_kwargs_enabled: bool = True)
    @@ -95,6 +95,7 @@

    Classes

    listener_executor: Optional[Executor] = None, # for AI Agents & Assistants assistant_thread_context_store: Optional[AssistantThreadContextStore] = None, + attaching_conversation_kwargs_enabled: bool = True, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -315,6 +316,7 @@

    Classes

    listener_executor = ThreadPoolExecutor(max_workers=5) self._assistant_thread_context_store = assistant_thread_context_store + self._attaching_conversation_kwargs_enabled = attaching_conversation_kwargs_enabled self._process_before_response = process_before_response self._listener_runner = ThreadListenerRunner( @@ -799,10 +801,13 @@

    Classes

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, base_logger=self._base_logger) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__ @@ -860,6 +865,8 @@

    Classes

    primary_matcher = builtin_matchers.message_event( keyword=keyword, constraints=constraints, base_logger=self._base_logger ) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) @@ -1356,20 +1363,6 @@

    Classes

    # It is intended for apps that start lazy listeners from their custom global middleware. req.context["listener_runner"] = self.listener_runner - # For AI Agents & Assistants - if is_assistant_event(req.body): - assistant = AssistantUtilities( - payload=to_event(req.body), # type:ignore[arg-type] - context=req.context, - thread_context_store=self._assistant_thread_context_store, - ) - req.context["say"] = assistant.say - req.context["set_status"] = assistant.set_status - req.context["set_title"] = assistant.set_title - req.context["set_suggested_prompts"] = assistant.set_suggested_prompts - req.context["get_thread_context"] = assistant.get_thread_context - req.context["save_thread_context"] = assistant.save_thread_context - @staticmethod def _to_listener_functions( kwargs: dict, @@ -1415,7 +1408,7 @@

    Classes

    CustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, # type:ignore[arg-type] + lazy_functions=functions, # type: ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, @@ -2203,10 +2196,13 @@

    Args

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, base_logger=self._base_logger) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__ @@ -2410,6 +2406,8 @@

    Args

    primary_matcher = builtin_matchers.message_event( keyword=keyword, constraints=constraints, base_logger=self._base_logger ) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) diff --git a/docs/reference/app/async_app.html b/docs/reference/app/async_app.html index 9cbc801d0..cf4c651cb 100644 --- a/docs/reference/app/async_app.html +++ b/docs/reference/app/async_app.html @@ -48,7 +48,7 @@

    Classes

    class AsyncApp -(*,
    logger: logging.Logger | None = None,
    name: str | None = None,
    process_before_response: bool = False,
    raise_error_for_unhandled_request: bool = False,
    signing_secret: str | None = None,
    token: str | None = None,
    client: slack_sdk.web.async_client.AsyncWebClient | None = None,
    before_authorize: AsyncMiddleware | Callable[..., Awaitable[Any]] | None = None,
    authorize: Callable[..., Awaitable[AuthorizeResult]] | None = None,
    user_facing_authorize_error_message: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None = None,
    installation_store_bot_only: bool | None = None,
    request_verification_enabled: bool = True,
    ignoring_self_events_enabled: bool = True,
    ignoring_self_assistant_message_events_enabled: bool = True,
    ssl_check_enabled: bool = True,
    url_verification_enabled: bool = True,
    attaching_function_token_enabled: bool = True,
    oauth_settings: AsyncOAuthSettings | None = None,
    oauth_flow: AsyncOAuthFlow | None = None,
    verification_token: str | None = None,
    assistant_thread_context_store: AsyncAssistantThreadContextStore | None = None)
    +(*,
    logger: logging.Logger | None = None,
    name: str | None = None,
    process_before_response: bool = False,
    raise_error_for_unhandled_request: bool = False,
    signing_secret: str | None = None,
    token: str | None = None,
    client: slack_sdk.web.async_client.AsyncWebClient | None = None,
    before_authorize: AsyncMiddleware | Callable[..., Awaitable[Any]] | None = None,
    authorize: Callable[..., Awaitable[AuthorizeResult]] | None = None,
    user_facing_authorize_error_message: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None = None,
    installation_store_bot_only: bool | None = None,
    request_verification_enabled: bool = True,
    ignoring_self_events_enabled: bool = True,
    ignoring_self_assistant_message_events_enabled: bool = True,
    ssl_check_enabled: bool = True,
    url_verification_enabled: bool = True,
    attaching_function_token_enabled: bool = True,
    oauth_settings: AsyncOAuthSettings | None = None,
    oauth_flow: AsyncOAuthFlow | None = None,
    verification_token: str | None = None,
    assistant_thread_context_store: AsyncAssistantThreadContextStore | None = None,
    attaching_conversation_kwargs_enabled: bool = True)
    @@ -92,6 +92,7 @@

    Classes

    verification_token: Optional[str] = None, # for AI Agents & Assistants assistant_thread_context_store: Optional[AsyncAssistantThreadContextStore] = None, + attaching_conversation_kwargs_enabled: bool = True, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -312,6 +313,7 @@

    Classes

    self._async_listeners: List[AsyncListener] = [] self._assistant_thread_context_store = assistant_thread_context_store + self._attaching_conversation_kwargs_enabled = attaching_conversation_kwargs_enabled self._process_before_response = process_before_response self._async_listener_runner = AsyncioListenerRunner( @@ -565,7 +567,7 @@

    Classes

    self._framework_logger.debug(debug_checking_listener(listener_name)) if await listener.async_matches(req=req, resp=resp): # type: ignore[arg-type] # run all the middleware attached to this listener first - (middleware_resp, next_was_not_called) = await listener.run_async_middleware( + middleware_resp, next_was_not_called = await listener.run_async_middleware( req=req, resp=resp # type: ignore[arg-type] ) if next_was_not_called: @@ -815,10 +817,13 @@

    Classes

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, True, base_logger=self._base_logger) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AsyncAttachingConversationKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__ @@ -879,6 +884,8 @@

    Classes

    asyncio=True, base_logger=self._base_logger, ) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AsyncAttachingConversationKwargs(self._assistant_thread_context_store)) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) @@ -1380,20 +1387,6 @@

    Classes

    # It is intended for apps that start lazy listeners from their custom global middleware. req.context["listener_runner"] = self.listener_runner - # For AI Agents & Assistants - if is_assistant_event(req.body): - assistant = AsyncAssistantUtilities( - payload=to_event(req.body), # type:ignore[arg-type] - context=req.context, - thread_context_store=self._assistant_thread_context_store, - ) - req.context["say"] = assistant.say - req.context["set_status"] = assistant.set_status - req.context["set_title"] = assistant.set_title - req.context["set_suggested_prompts"] = assistant.set_suggested_prompts - req.context["get_thread_context"] = assistant.get_thread_context - req.context["save_thread_context"] = assistant.save_thread_context - @staticmethod def _to_listener_functions( kwargs: dict, @@ -1444,7 +1437,7 @@

    Classes

    AsyncCustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, # type:ignore[arg-type] + lazy_functions=functions, # type: ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, @@ -1794,7 +1787,7 @@

    Args

    self._framework_logger.debug(debug_checking_listener(listener_name)) if await listener.async_matches(req=req, resp=resp): # type: ignore[arg-type] # run all the middleware attached to this listener first - (middleware_resp, next_was_not_called) = await listener.run_async_middleware( + middleware_resp, next_was_not_called = await listener.run_async_middleware( req=req, resp=resp # type: ignore[arg-type] ) if next_was_not_called: @@ -2243,10 +2236,13 @@

    Args

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, True, base_logger=self._base_logger) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AsyncAttachingConversationKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__
    @@ -2454,6 +2450,8 @@

    Args

    asyncio=True, base_logger=self._base_logger, ) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AsyncAttachingConversationKwargs(self._assistant_thread_context_store)) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) diff --git a/docs/reference/app/async_server.html b/docs/reference/app/async_server.html index b95b8a2b3..5eefe90dd 100644 --- a/docs/reference/app/async_server.html +++ b/docs/reference/app/async_server.html @@ -59,14 +59,14 @@

    Classes

    port: int path: str host: str - bolt_app: "AsyncApp" # type: ignore[name-defined] + bolt_app: "AsyncApp" web_app: web.Application def __init__( self, port: int, path: str, - app: "AsyncApp", # type: ignore[name-defined] + app: "AsyncApp", host: Optional[str] = None, ): """Standalone AIOHTTP Web Server. @@ -81,7 +81,7 @@

    Classes

    self.port = port self.path = path self.host = host if host is not None else "0.0.0.0" - self.bolt_app: "AsyncApp" = app # type: ignore[name-defined] + self.bolt_app: "AsyncApp" = app self.web_app = web.Application() self._bolt_oauth_flow = self.bolt_app.oauth_flow if self._bolt_oauth_flow: diff --git a/docs/reference/app/index.html b/docs/reference/app/index.html index 8821e5af9..32e006944 100644 --- a/docs/reference/app/index.html +++ b/docs/reference/app/index.html @@ -67,7 +67,7 @@

    Classes

    class App -(*,
    logger: logging.Logger | None = None,
    name: str | None = None,
    process_before_response: bool = False,
    raise_error_for_unhandled_request: bool = False,
    signing_secret: str | None = None,
    token: str | None = None,
    token_verification_enabled: bool = True,
    client: slack_sdk.web.client.WebClient | None = None,
    before_authorize: Middleware | Callable[..., Any] | None = None,
    authorize: Callable[..., AuthorizeResult] | None = None,
    user_facing_authorize_error_message: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None = None,
    installation_store_bot_only: bool | None = None,
    request_verification_enabled: bool = True,
    ignoring_self_events_enabled: bool = True,
    ignoring_self_assistant_message_events_enabled: bool = True,
    ssl_check_enabled: bool = True,
    url_verification_enabled: bool = True,
    attaching_function_token_enabled: bool = True,
    oauth_settings: OAuthSettings | None = None,
    oauth_flow: OAuthFlow | None = None,
    verification_token: str | None = None,
    listener_executor: concurrent.futures._base.Executor | None = None,
    assistant_thread_context_store: AssistantThreadContextStore | None = None)
    +(*,
    logger: logging.Logger | None = None,
    name: str | None = None,
    process_before_response: bool = False,
    raise_error_for_unhandled_request: bool = False,
    signing_secret: str | None = None,
    token: str | None = None,
    token_verification_enabled: bool = True,
    client: slack_sdk.web.client.WebClient | None = None,
    before_authorize: Middleware | Callable[..., Any] | None = None,
    authorize: Callable[..., AuthorizeResult] | None = None,
    user_facing_authorize_error_message: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None = None,
    installation_store_bot_only: bool | None = None,
    request_verification_enabled: bool = True,
    ignoring_self_events_enabled: bool = True,
    ignoring_self_assistant_message_events_enabled: bool = True,
    ssl_check_enabled: bool = True,
    url_verification_enabled: bool = True,
    attaching_function_token_enabled: bool = True,
    oauth_settings: OAuthSettings | None = None,
    oauth_flow: OAuthFlow | None = None,
    verification_token: str | None = None,
    listener_executor: concurrent.futures._base.Executor | None = None,
    assistant_thread_context_store: AssistantThreadContextStore | None = None,
    attaching_conversation_kwargs_enabled: bool = True)
    @@ -114,6 +114,7 @@

    Classes

    listener_executor: Optional[Executor] = None, # for AI Agents & Assistants assistant_thread_context_store: Optional[AssistantThreadContextStore] = None, + attaching_conversation_kwargs_enabled: bool = True, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -334,6 +335,7 @@

    Classes

    listener_executor = ThreadPoolExecutor(max_workers=5) self._assistant_thread_context_store = assistant_thread_context_store + self._attaching_conversation_kwargs_enabled = attaching_conversation_kwargs_enabled self._process_before_response = process_before_response self._listener_runner = ThreadListenerRunner( @@ -818,10 +820,13 @@

    Classes

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, base_logger=self._base_logger) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__ @@ -879,6 +884,8 @@

    Classes

    primary_matcher = builtin_matchers.message_event( keyword=keyword, constraints=constraints, base_logger=self._base_logger ) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) @@ -1375,20 +1382,6 @@

    Classes

    # It is intended for apps that start lazy listeners from their custom global middleware. req.context["listener_runner"] = self.listener_runner - # For AI Agents & Assistants - if is_assistant_event(req.body): - assistant = AssistantUtilities( - payload=to_event(req.body), # type:ignore[arg-type] - context=req.context, - thread_context_store=self._assistant_thread_context_store, - ) - req.context["say"] = assistant.say - req.context["set_status"] = assistant.set_status - req.context["set_title"] = assistant.set_title - req.context["set_suggested_prompts"] = assistant.set_suggested_prompts - req.context["get_thread_context"] = assistant.get_thread_context - req.context["save_thread_context"] = assistant.save_thread_context - @staticmethod def _to_listener_functions( kwargs: dict, @@ -1434,7 +1427,7 @@

    Classes

    CustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, # type:ignore[arg-type] + lazy_functions=functions, # type: ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, @@ -2222,10 +2215,13 @@

    Args

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, base_logger=self._base_logger) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__ @@ -2429,6 +2425,8 @@

    Args

    primary_matcher = builtin_matchers.message_event( keyword=keyword, constraints=constraints, base_logger=self._base_logger ) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) diff --git a/docs/reference/async_app.html b/docs/reference/async_app.html index 8fd975be9..3494ec289 100644 --- a/docs/reference/async_app.html +++ b/docs/reference/async_app.html @@ -139,7 +139,7 @@

    Class variables

    class AsyncApp -(*,
    logger: logging.Logger | None = None,
    name: str | None = None,
    process_before_response: bool = False,
    raise_error_for_unhandled_request: bool = False,
    signing_secret: str | None = None,
    token: str | None = None,
    client: slack_sdk.web.async_client.AsyncWebClient | None = None,
    before_authorize: AsyncMiddleware | Callable[..., Awaitable[Any]] | None = None,
    authorize: Callable[..., Awaitable[AuthorizeResult]] | None = None,
    user_facing_authorize_error_message: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None = None,
    installation_store_bot_only: bool | None = None,
    request_verification_enabled: bool = True,
    ignoring_self_events_enabled: bool = True,
    ignoring_self_assistant_message_events_enabled: bool = True,
    ssl_check_enabled: bool = True,
    url_verification_enabled: bool = True,
    attaching_function_token_enabled: bool = True,
    oauth_settings: AsyncOAuthSettings | None = None,
    oauth_flow: AsyncOAuthFlow | None = None,
    verification_token: str | None = None,
    assistant_thread_context_store: AsyncAssistantThreadContextStore | None = None)
    +(*,
    logger: logging.Logger | None = None,
    name: str | None = None,
    process_before_response: bool = False,
    raise_error_for_unhandled_request: bool = False,
    signing_secret: str | None = None,
    token: str | None = None,
    client: slack_sdk.web.async_client.AsyncWebClient | None = None,
    before_authorize: AsyncMiddleware | Callable[..., Awaitable[Any]] | None = None,
    authorize: Callable[..., Awaitable[AuthorizeResult]] | None = None,
    user_facing_authorize_error_message: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None = None,
    installation_store_bot_only: bool | None = None,
    request_verification_enabled: bool = True,
    ignoring_self_events_enabled: bool = True,
    ignoring_self_assistant_message_events_enabled: bool = True,
    ssl_check_enabled: bool = True,
    url_verification_enabled: bool = True,
    attaching_function_token_enabled: bool = True,
    oauth_settings: AsyncOAuthSettings | None = None,
    oauth_flow: AsyncOAuthFlow | None = None,
    verification_token: str | None = None,
    assistant_thread_context_store: AsyncAssistantThreadContextStore | None = None,
    attaching_conversation_kwargs_enabled: bool = True)
    @@ -183,6 +183,7 @@

    Class variables

    verification_token: Optional[str] = None, # for AI Agents & Assistants assistant_thread_context_store: Optional[AsyncAssistantThreadContextStore] = None, + attaching_conversation_kwargs_enabled: bool = True, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -403,6 +404,7 @@

    Class variables

    self._async_listeners: List[AsyncListener] = [] self._assistant_thread_context_store = assistant_thread_context_store + self._attaching_conversation_kwargs_enabled = attaching_conversation_kwargs_enabled self._process_before_response = process_before_response self._async_listener_runner = AsyncioListenerRunner( @@ -656,7 +658,7 @@

    Class variables

    self._framework_logger.debug(debug_checking_listener(listener_name)) if await listener.async_matches(req=req, resp=resp): # type: ignore[arg-type] # run all the middleware attached to this listener first - (middleware_resp, next_was_not_called) = await listener.run_async_middleware( + middleware_resp, next_was_not_called = await listener.run_async_middleware( req=req, resp=resp # type: ignore[arg-type] ) if next_was_not_called: @@ -906,10 +908,13 @@

    Class variables

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, True, base_logger=self._base_logger) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AsyncAttachingConversationKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__ @@ -970,6 +975,8 @@

    Class variables

    asyncio=True, base_logger=self._base_logger, ) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AsyncAttachingConversationKwargs(self._assistant_thread_context_store)) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) @@ -1471,20 +1478,6 @@

    Class variables

    # It is intended for apps that start lazy listeners from their custom global middleware. req.context["listener_runner"] = self.listener_runner - # For AI Agents & Assistants - if is_assistant_event(req.body): - assistant = AsyncAssistantUtilities( - payload=to_event(req.body), # type:ignore[arg-type] - context=req.context, - thread_context_store=self._assistant_thread_context_store, - ) - req.context["say"] = assistant.say - req.context["set_status"] = assistant.set_status - req.context["set_title"] = assistant.set_title - req.context["set_suggested_prompts"] = assistant.set_suggested_prompts - req.context["get_thread_context"] = assistant.get_thread_context - req.context["save_thread_context"] = assistant.save_thread_context - @staticmethod def _to_listener_functions( kwargs: dict, @@ -1535,7 +1528,7 @@

    Class variables

    AsyncCustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, # type:ignore[arg-type] + lazy_functions=functions, # type: ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, @@ -1885,7 +1878,7 @@

    Args

    self._framework_logger.debug(debug_checking_listener(listener_name)) if await listener.async_matches(req=req, resp=resp): # type: ignore[arg-type] # run all the middleware attached to this listener first - (middleware_resp, next_was_not_called) = await listener.run_async_middleware( + middleware_resp, next_was_not_called = await listener.run_async_middleware( req=req, resp=resp # type: ignore[arg-type] ) if next_was_not_called: @@ -2334,10 +2327,13 @@

    Args

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, True, base_logger=self._base_logger) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AsyncAttachingConversationKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__ @@ -2545,6 +2541,8 @@

    Args

    asyncio=True, base_logger=self._base_logger, ) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AsyncAttachingConversationKwargs(self._assistant_thread_context_store)) middleware.insert(0, AsyncMessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) @@ -3285,7 +3283,7 @@

    Args

    func=is_assistant_thread_started_event, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -3294,7 +3292,7 @@

    Args

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3331,7 +3329,7 @@

    Args

    func=is_user_message_event_in_assistant_thread, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -3340,7 +3338,7 @@

    Args

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3377,7 +3375,7 @@

    Args

    func=is_bot_message_event_in_assistant_thread, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -3386,7 +3384,7 @@

    Args

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3423,7 +3421,7 @@

    Args

    func=is_assistant_thread_context_changed_event, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -3432,7 +3430,7 @@

    Args

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3460,14 +3458,14 @@

    Args

    primary_matcher: Union[Callable[..., bool], AsyncListenerMatcher], custom_matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]], ): - return [primary_matcher] + (custom_matchers or []) # type:ignore[operator] + return [primary_matcher] + (custom_matchers or []) # type: ignore[operator] @staticmethod async def default_thread_context_changed(save_thread_context: AsyncSaveThreadContext, payload: dict): new_context: dict = payload["assistant_thread"]["context"] await save_thread_context(new_context) - async def async_process( # type:ignore[return] + async def async_process( # type: ignore[return] self, *, req: AsyncBoltRequest, @@ -3487,6 +3485,15 @@

    Args

    if listeners is not None: for listener in listeners: if listener is not None and await listener.async_matches(req=req, resp=resp): + middleware_resp, next_was_not_called = await listener.run_async_middleware(req=req, resp=resp) + if next_was_not_called: + if middleware_resp is not None: + return middleware_resp + # The listener middleware didn't call next(). + # Skip this listener and try the next one. + continue + if middleware_resp is not None: + resp = middleware_resp return await listener_runner.run( request=req, response=resp, @@ -3506,13 +3513,14 @@

    Args

    middleware: Optional[List[AsyncMiddleware]] = None, base_logger: Optional[Logger] = None, ) -> AsyncListener: - if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] - listener_or_functions = [listener_or_functions] # type:ignore[list-item] + if isinstance(listener_or_functions, Callable): # type: ignore[arg-type] + listener_or_functions = [listener_or_functions] # type: ignore[list-item] if isinstance(listener_or_functions, AsyncListener): return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] + middleware.insert(0, AsyncAttachingConversationKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) @@ -3524,7 +3532,7 @@

    Args

    else: listener_matchers.append( build_listener_matcher( - func=matcher, # type:ignore[arg-type] + func=matcher, # type: ignore[arg-type] asyncio=True, base_logger=base_logger, ) @@ -3599,7 +3607,7 @@

    Methods

    func=is_bot_message_event_in_assistant_thread, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -3608,7 +3616,7 @@

    Methods

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3648,13 +3656,14 @@

    Methods

    middleware: Optional[List[AsyncMiddleware]] = None, base_logger: Optional[Logger] = None, ) -> AsyncListener: - if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] - listener_or_functions = [listener_or_functions] # type:ignore[list-item] + if isinstance(listener_or_functions, Callable): # type: ignore[arg-type] + listener_or_functions = [listener_or_functions] # type: ignore[list-item] if isinstance(listener_or_functions, AsyncListener): return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] + middleware.insert(0, AsyncAttachingConversationKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) @@ -3666,7 +3675,7 @@

    Methods

    else: listener_matchers.append( build_listener_matcher( - func=matcher, # type:ignore[arg-type] + func=matcher, # type: ignore[arg-type] asyncio=True, base_logger=base_logger, ) @@ -3707,7 +3716,7 @@

    Methods

    func=is_assistant_thread_context_changed_event, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -3716,7 +3725,7 @@

    Methods

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3763,7 +3772,7 @@

    Methods

    func=is_assistant_thread_started_event, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -3772,7 +3781,7 @@

    Methods

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3819,7 +3828,7 @@

    Methods

    func=is_user_message_event_in_assistant_thread, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -3828,7 +3837,7 @@

    Methods

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3898,7 +3907,7 @@

    Inherited members

    # The return type is intentionally string to avoid circular imports @property - def listener_runner(self) -> "AsyncioListenerRunner": # type: ignore[name-defined] + def listener_runner(self) -> "AsyncioListenerRunner": """The properly configured listener_runner that is available for middleware/listeners.""" return self["listener_runner"] @@ -3967,7 +3976,7 @@

    Inherited members

    Callable `say()` function """ if "say" not in self: - self["say"] = AsyncSay(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) + self["say"] = AsyncSay(client=self.client, channel=self.channel_id) return self["say"] @property @@ -4060,6 +4069,10 @@

    Inherited members

    def get_thread_context(self) -> Optional[AsyncGetThreadContext]: return self.get("get_thread_context") + @property + def say_stream(self) -> Optional[AsyncSayStream]: + return self.get("say_stream") + @property def save_thread_context(self) -> Optional[AsyncSaveThreadContext]: return self.get("save_thread_context") @@ -4275,7 +4288,7 @@

    Returns

    Expand source code
    @property
    -def listener_runner(self) -> "AsyncioListenerRunner":  # type: ignore[name-defined]
    +def listener_runner(self) -> "AsyncioListenerRunner":
         """The properly configured listener_runner that is available for middleware/listeners."""
         return self["listener_runner"]
    @@ -4365,7 +4378,7 @@

    Returns

    Callable `say()` function """ if "say" not in self: - self["say"] = AsyncSay(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) + self["say"] = AsyncSay(client=self.client, channel=self.channel_id) return self["say"]

    say() function for this request.

    @@ -4383,6 +4396,18 @@

    Returns

    Returns

    Callable say() function

    +
    prop say_streamAsyncSayStream | None
    +
    +
    + +Expand source code + +
    @property
    +def say_stream(self) -> Optional[AsyncSayStream]:
    +    return self.get("say_stream")
    +
    +
    +
    prop set_statusAsyncSetStatus | None
    @@ -4742,14 +4767,10 @@

    Inherited members

    if self.thread_context_loaded is True: return self._thread_context - if self.payload.get("assistant_thread") is not None: + thread = self.payload.get("assistant_thread") + if isinstance(thread, dict) and thread.get("context", {}).get("channel_id") is not None: # assistant_thread_started - thread = self.payload["assistant_thread"] - self._thread_context = ( - AssistantThreadContext(thread["context"]) - if thread.get("context", {}).get("channel_id") is not None - else None - ) + self._thread_context = AssistantThreadContext(thread["context"]) # for this event, the context will never be changed self.thread_context_loaded = True elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: @@ -5231,6 +5252,97 @@

    Class variables

    +
    +class AsyncSayStream +(*,
    client: slack_sdk.web.async_client.AsyncWebClient,
    channel: str | None = None,
    recipient_team_id: str | None = None,
    recipient_user_id: str | None = None,
    thread_ts: str | None = None)
    +
    +
    +
    + +Expand source code + +
    class AsyncSayStream:
    +    client: AsyncWebClient
    +    channel: Optional[str]
    +    recipient_team_id: Optional[str]
    +    recipient_user_id: Optional[str]
    +    thread_ts: Optional[str]
    +
    +    def __init__(
    +        self,
    +        *,
    +        client: AsyncWebClient,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +    ):
    +        self.client = client
    +        self.channel = channel
    +        self.recipient_team_id = recipient_team_id
    +        self.recipient_user_id = recipient_user_id
    +        self.thread_ts = thread_ts
    +
    +    async def __call__(
    +        self,
    +        *,
    +        buffer_size: Optional[int] = None,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +        **kwargs,
    +    ) -> AsyncChatStream:
    +        """Starts a new chat stream with context."""
    +        channel = channel or self.channel
    +        thread_ts = thread_ts or self.thread_ts
    +        if channel is None:
    +            raise ValueError("say_stream without channel here is unsupported")
    +        if thread_ts is None:
    +            raise ValueError("say_stream without thread_ts here is unsupported")
    +
    +        if buffer_size is not None:
    +            return await self.client.chat_stream(
    +                buffer_size=buffer_size,
    +                channel=channel,
    +                recipient_team_id=recipient_team_id or self.recipient_team_id,
    +                recipient_user_id=recipient_user_id or self.recipient_user_id,
    +                thread_ts=thread_ts,
    +                **kwargs,
    +            )
    +        return await self.client.chat_stream(
    +            channel=channel,
    +            recipient_team_id=recipient_team_id or self.recipient_team_id,
    +            recipient_user_id=recipient_user_id or self.recipient_user_id,
    +            thread_ts=thread_ts,
    +            **kwargs,
    +        )
    +
    +
    +

    Class variables

    +
    +
    var channel : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var client : slack_sdk.web.async_client.AsyncWebClient
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_team_id : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_user_id : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var thread_ts : str | None
    +
    +

    The type of the None singleton.

    +
    +
    +
    class AsyncSetStatus (client: slack_sdk.web.async_client.AsyncWebClient,
    channel_id: str,
    thread_ts: str)
    @@ -5485,6 +5597,7 @@

    respond
  • save_thread_context
  • say
  • +
  • say_stream
  • set_status
  • set_suggested_prompts
  • set_title
  • @@ -5565,6 +5678,16 @@

    AsyncSayStream

    + + +
  • AsyncSetStatus

    • channel_id
    • diff --git a/docs/reference/authorization/authorize_result.html b/docs/reference/authorization/authorize_result.html index d53c5cd5c..6eac3724d 100644 --- a/docs/reference/authorization/authorize_result.html +++ b/docs/reference/authorization/authorize_result.html @@ -48,7 +48,7 @@

      Classes

      class AuthorizeResult -(*,
      enterprise_id: str | None,
      team_id: str | None,
      team: str | None = None,
      url: str | None = None,
      bot_user_id: str | None = None,
      bot_id: str | None = None,
      bot_token: str | None = None,
      bot_scopes: Sequence[str] | str | None = None,
      user_id: str | None = None,
      user: str | None = None,
      user_token: str | None = None,
      user_scopes: Sequence[str] | str | None = None)
      +(*,
      enterprise_id: str | None,
      team_id: str | None,
      team: str | None = None,
      url: str | None = None,
      bot_user_id: str | None = None,
      bot_id: str | None = None,
      bot_token: str | None = None,
      bot_scopes: str | Sequence[str] | None = None,
      user_id: str | None = None,
      user: str | None = None,
      user_token: str | None = None,
      user_scopes: str | Sequence[str] | None = None)
      @@ -246,7 +246,7 @@

      Class variables

      Static methods

      -def from_auth_test_response(*,
      bot_token: str | None = None,
      user_token: str | None = None,
      bot_scopes: Sequence[str] | str | None = None,
      user_scopes: Sequence[str] | str | None = None,
      auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse,
      user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse | None = None)
      +def from_auth_test_response(*,
      bot_token: str | None = None,
      user_token: str | None = None,
      bot_scopes: str | Sequence[str] | None = None,
      user_scopes: str | Sequence[str] | None = None,
      auth_test_response: slack_sdk.web.slack_response.SlackResponse | ForwardRef('AsyncSlackResponse'),
      user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | ForwardRef('AsyncSlackResponse') | None = None)
      diff --git a/docs/reference/authorization/index.html b/docs/reference/authorization/index.html index 2fdd1f916..19de311df 100644 --- a/docs/reference/authorization/index.html +++ b/docs/reference/authorization/index.html @@ -75,7 +75,7 @@

      Classes

      class AuthorizeResult -(*,
      enterprise_id: str | None,
      team_id: str | None,
      team: str | None = None,
      url: str | None = None,
      bot_user_id: str | None = None,
      bot_id: str | None = None,
      bot_token: str | None = None,
      bot_scopes: Sequence[str] | str | None = None,
      user_id: str | None = None,
      user: str | None = None,
      user_token: str | None = None,
      user_scopes: Sequence[str] | str | None = None)
      +(*,
      enterprise_id: str | None,
      team_id: str | None,
      team: str | None = None,
      url: str | None = None,
      bot_user_id: str | None = None,
      bot_id: str | None = None,
      bot_token: str | None = None,
      bot_scopes: str | Sequence[str] | None = None,
      user_id: str | None = None,
      user: str | None = None,
      user_token: str | None = None,
      user_scopes: str | Sequence[str] | None = None)
      @@ -273,7 +273,7 @@

      Class variables

      Static methods

      -def from_auth_test_response(*,
      bot_token: str | None = None,
      user_token: str | None = None,
      bot_scopes: Sequence[str] | str | None = None,
      user_scopes: Sequence[str] | str | None = None,
      auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse,
      user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse | None = None)
      +def from_auth_test_response(*,
      bot_token: str | None = None,
      user_token: str | None = None,
      bot_scopes: str | Sequence[str] | None = None,
      user_scopes: str | Sequence[str] | None = None,
      auth_test_response: slack_sdk.web.slack_response.SlackResponse | ForwardRef('AsyncSlackResponse'),
      user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | ForwardRef('AsyncSlackResponse') | None = None)
      diff --git a/docs/reference/context/assistant/assistant_utilities.html b/docs/reference/context/assistant/assistant_utilities.html index d446b3c02..40db52284 100644 --- a/docs/reference/context/assistant/assistant_utilities.html +++ b/docs/reference/context/assistant/assistant_utilities.html @@ -91,6 +91,13 @@

      Classes

      @property def set_status(self) -> SetStatus: + warnings.warn( + "AssistantUtilities.set_status is deprecated. " + "Use the set_status argument directly in your listener function " + "or access it via context.set_status instead.", + DeprecationWarning, + stacklevel=2, + ) return SetStatus(self.client, self.channel_id, self.thread_ts) @property @@ -205,6 +212,13 @@

      Instance variables

      @property
       def set_status(self) -> SetStatus:
      +    warnings.warn(
      +        "AssistantUtilities.set_status is deprecated. "
      +        "Use the set_status argument directly in your listener function "
      +        "or access it via context.set_status instead.",
      +        DeprecationWarning,
      +        stacklevel=2,
      +    )
           return SetStatus(self.client, self.channel_id, self.thread_ts)
      diff --git a/docs/reference/context/assistant/async_assistant_utilities.html b/docs/reference/context/assistant/async_assistant_utilities.html index fc3cbbe8b..fc77b80cb 100644 --- a/docs/reference/context/assistant/async_assistant_utilities.html +++ b/docs/reference/context/assistant/async_assistant_utilities.html @@ -91,6 +91,13 @@

      Classes

      @property def set_status(self) -> AsyncSetStatus: + warnings.warn( + "AsyncAssistantUtilities.set_status is deprecated. " + "Use the set_status argument directly in your listener function " + "or access it via context.set_status instead.", + DeprecationWarning, + stacklevel=2, + ) return AsyncSetStatus(self.client, self.channel_id, self.thread_ts) @property @@ -199,6 +206,13 @@

      Instance variables

      @property
       def set_status(self) -> AsyncSetStatus:
      +    warnings.warn(
      +        "AsyncAssistantUtilities.set_status is deprecated. "
      +        "Use the set_status argument directly in your listener function "
      +        "or access it via context.set_status instead.",
      +        DeprecationWarning,
      +        stacklevel=2,
      +    )
           return AsyncSetStatus(self.client, self.channel_id, self.thread_ts)
      diff --git a/docs/reference/context/assistant/thread_context_store/file/index.html b/docs/reference/context/assistant/thread_context_store/file/index.html index 4a5d944e1..cbb4e4db6 100644 --- a/docs/reference/context/assistant/thread_context_store/file/index.html +++ b/docs/reference/context/assistant/thread_context_store/file/index.html @@ -48,7 +48,7 @@

      Classes

      class FileAssistantThreadContextStore -(base_dir: str = '/Users/wbergamin/.bolt-app-assistant-thread-contexts') +(base_dir: str = '/Users/eden.zimbelman/.bolt-app-assistant-thread-contexts')
      diff --git a/docs/reference/context/async_context.html b/docs/reference/context/async_context.html index 9ce4ebd9e..8fc6d36bf 100644 --- a/docs/reference/context/async_context.html +++ b/docs/reference/context/async_context.html @@ -80,7 +80,7 @@

      Classes

      # The return type is intentionally string to avoid circular imports @property - def listener_runner(self) -> "AsyncioListenerRunner": # type: ignore[name-defined] + def listener_runner(self) -> "AsyncioListenerRunner": """The properly configured listener_runner that is available for middleware/listeners.""" return self["listener_runner"] @@ -149,7 +149,7 @@

      Classes

      Callable `say()` function """ if "say" not in self: - self["say"] = AsyncSay(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) + self["say"] = AsyncSay(client=self.client, channel=self.channel_id) return self["say"] @property @@ -242,6 +242,10 @@

      Classes

      def get_thread_context(self) -> Optional[AsyncGetThreadContext]: return self.get("get_thread_context") + @property + def say_stream(self) -> Optional[AsyncSayStream]: + return self.get("say_stream") + @property def save_thread_context(self) -> Optional[AsyncSaveThreadContext]: return self.get("save_thread_context")
      @@ -457,7 +461,7 @@

      Returns

      Expand source code
      @property
      -def listener_runner(self) -> "AsyncioListenerRunner":  # type: ignore[name-defined]
      +def listener_runner(self) -> "AsyncioListenerRunner":
           """The properly configured listener_runner that is available for middleware/listeners."""
           return self["listener_runner"]
      @@ -547,7 +551,7 @@

      Returns

      Callable `say()` function """ if "say" not in self: - self["say"] = AsyncSay(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) + self["say"] = AsyncSay(client=self.client, channel=self.channel_id) return self["say"]
  • say() function for this request.

    @@ -565,6 +569,18 @@

    Returns

    Returns

    Callable say() function

    +
    prop say_streamAsyncSayStream | None
    +
    +
    + +Expand source code + +
    @property
    +def say_stream(self) -> Optional[AsyncSayStream]:
    +    return self.get("say_stream")
    +
    +
    +
    prop set_statusAsyncSetStatus | None
    @@ -694,6 +710,7 @@

    respond
  • save_thread_context
  • say
  • +
  • say_stream
  • set_status
  • set_suggested_prompts
  • set_title
  • diff --git a/docs/reference/context/base_context.html b/docs/reference/context/base_context.html index 4a177f8dc..afe571163 100644 --- a/docs/reference/context/base_context.html +++ b/docs/reference/context/base_context.html @@ -89,6 +89,7 @@

    Classes

    "set_status", "set_title", "set_suggested_prompts", + "say_stream", ] # Note that these items are not copyable, so when you add new items to this list, # you must modify ThreadListenerRunner/AsyncioListenerRunner's _build_lazy_request method to pass the values. diff --git a/docs/reference/context/context.html b/docs/reference/context/context.html index 615432502..a7b531c20 100644 --- a/docs/reference/context/context.html +++ b/docs/reference/context/context.html @@ -81,7 +81,7 @@

    Classes

    # The return type is intentionally string to avoid circular imports @property - def listener_runner(self) -> "ThreadListenerRunner": # type: ignore[name-defined] + def listener_runner(self) -> "ThreadListenerRunner": """The properly configured listener_runner that is available for middleware/listeners.""" return self["listener_runner"] @@ -150,7 +150,7 @@

    Classes

    Callable `say()` function """ if "say" not in self: - self["say"] = Say(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) + self["say"] = Say(client=self.client, channel=self.channel_id) return self["say"] @property @@ -243,6 +243,10 @@

    Classes

    def get_thread_context(self) -> Optional[GetThreadContext]: return self.get("get_thread_context") + @property + def say_stream(self) -> Optional[SayStream]: + return self.get("say_stream") + @property def save_thread_context(self) -> Optional[SaveThreadContext]: return self.get("save_thread_context") @@ -458,7 +462,7 @@

    Returns

    Expand source code
    @property
    -def listener_runner(self) -> "ThreadListenerRunner":  # type: ignore[name-defined]
    +def listener_runner(self) -> "ThreadListenerRunner":
         """The properly configured listener_runner that is available for middleware/listeners."""
         return self["listener_runner"]
    @@ -548,7 +552,7 @@

    Returns

    Callable `say()` function """ if "say" not in self: - self["say"] = Say(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) + self["say"] = Say(client=self.client, channel=self.channel_id) return self["say"]

    say() function for this request.

    @@ -566,6 +570,18 @@

    Returns

    Returns

    Callable say() function

    +
    prop say_streamSayStream | None
    +
    +
    + +Expand source code + +
    @property
    +def say_stream(self) -> Optional[SayStream]:
    +    return self.get("say_stream")
    +
    +
    +
    prop set_statusSetStatus | None
    @@ -696,6 +712,7 @@

    respond
  • save_thread_context
  • say
  • +
  • say_stream
  • set_status
  • set_suggested_prompts
  • set_title
  • diff --git a/docs/reference/context/get_thread_context/async_get_thread_context.html b/docs/reference/context/get_thread_context/async_get_thread_context.html index 1c3fc4d6c..967581b50 100644 --- a/docs/reference/context/get_thread_context/async_get_thread_context.html +++ b/docs/reference/context/get_thread_context/async_get_thread_context.html @@ -82,14 +82,10 @@

    Classes

    if self.thread_context_loaded is True: return self._thread_context - if self.payload.get("assistant_thread") is not None: + thread = self.payload.get("assistant_thread") + if isinstance(thread, dict) and thread.get("context", {}).get("channel_id") is not None: # assistant_thread_started - thread = self.payload["assistant_thread"] - self._thread_context = ( - AssistantThreadContext(thread["context"]) - if thread.get("context", {}).get("channel_id") is not None - else None - ) + self._thread_context = AssistantThreadContext(thread["context"]) # for this event, the context will never be changed self.thread_context_loaded = True elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: diff --git a/docs/reference/context/get_thread_context/get_thread_context.html b/docs/reference/context/get_thread_context/get_thread_context.html index 4ac274368..cf2e17a86 100644 --- a/docs/reference/context/get_thread_context/get_thread_context.html +++ b/docs/reference/context/get_thread_context/get_thread_context.html @@ -82,14 +82,10 @@

    Classes

    if self.thread_context_loaded is True: return self._thread_context - if self.payload.get("assistant_thread") is not None: + thread = self.payload.get("assistant_thread") + if isinstance(thread, dict) and thread.get("context", {}).get("channel_id") is not None: # assistant_thread_started - thread = self.payload["assistant_thread"] - self._thread_context = ( - AssistantThreadContext(thread["context"]) - if thread.get("context", {}).get("channel_id") is not None - else None - ) + self._thread_context = AssistantThreadContext(thread["context"]) # for this event, the context will never be changed self.thread_context_loaded = True elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: diff --git a/docs/reference/context/get_thread_context/index.html b/docs/reference/context/get_thread_context/index.html index 13dcd1388..5f9e38e71 100644 --- a/docs/reference/context/get_thread_context/index.html +++ b/docs/reference/context/get_thread_context/index.html @@ -93,14 +93,10 @@

    Classes

    if self.thread_context_loaded is True: return self._thread_context - if self.payload.get("assistant_thread") is not None: + thread = self.payload.get("assistant_thread") + if isinstance(thread, dict) and thread.get("context", {}).get("channel_id") is not None: # assistant_thread_started - thread = self.payload["assistant_thread"] - self._thread_context = ( - AssistantThreadContext(thread["context"]) - if thread.get("context", {}).get("channel_id") is not None - else None - ) + self._thread_context = AssistantThreadContext(thread["context"]) # for this event, the context will never be changed self.thread_context_loaded = True elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: diff --git a/docs/reference/context/index.html b/docs/reference/context/index.html index c761aa47e..ebdfe8aa8 100644 --- a/docs/reference/context/index.html +++ b/docs/reference/context/index.html @@ -89,6 +89,10 @@

    Sub-modules

    +
    slack_bolt.context.say_stream
    +
    +
    +
    slack_bolt.context.set_status
    @@ -145,7 +149,7 @@

    Classes

    # The return type is intentionally string to avoid circular imports @property - def listener_runner(self) -> "ThreadListenerRunner": # type: ignore[name-defined] + def listener_runner(self) -> "ThreadListenerRunner": """The properly configured listener_runner that is available for middleware/listeners.""" return self["listener_runner"] @@ -214,7 +218,7 @@

    Classes

    Callable `say()` function """ if "say" not in self: - self["say"] = Say(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) + self["say"] = Say(client=self.client, channel=self.channel_id) return self["say"] @property @@ -307,6 +311,10 @@

    Classes

    def get_thread_context(self) -> Optional[GetThreadContext]: return self.get("get_thread_context") + @property + def say_stream(self) -> Optional[SayStream]: + return self.get("say_stream") + @property def save_thread_context(self) -> Optional[SaveThreadContext]: return self.get("save_thread_context") @@ -522,7 +530,7 @@

    Returns

    Expand source code
    @property
    -def listener_runner(self) -> "ThreadListenerRunner":  # type: ignore[name-defined]
    +def listener_runner(self) -> "ThreadListenerRunner":
         """The properly configured listener_runner that is available for middleware/listeners."""
         return self["listener_runner"]
    @@ -612,7 +620,7 @@

    Returns

    Callable `say()` function """ if "say" not in self: - self["say"] = Say(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) + self["say"] = Say(client=self.client, channel=self.channel_id) return self["say"]

    slack_bolt.context.say function for this request.

    @@ -630,6 +638,18 @@

    Returns

    Returns

    Callable slack_bolt.context.say function

    +
    prop say_streamSayStream | None
    +
    +
    + +Expand source code + +
    @property
    +def say_stream(self) -> Optional[SayStream]:
    +    return self.get("say_stream")
    +
    +
    +
    prop set_statusSetStatus | None
    @@ -759,6 +779,7 @@

    Inherited members

  • slack_bolt.context.respond
  • slack_bolt.context.save_thread_context
  • slack_bolt.context.say
  • +
  • slack_bolt.context.say_stream
  • slack_bolt.context.set_status
  • slack_bolt.context.set_suggested_prompts
  • slack_bolt.context.set_title
  • @@ -778,6 +799,7 @@

    respond
  • save_thread_context
  • say
  • +
  • say_stream
  • set_status
  • set_suggested_prompts
  • set_title
  • diff --git a/docs/reference/context/say_stream/async_say_stream.html b/docs/reference/context/say_stream/async_say_stream.html new file mode 100644 index 000000000..4010b284d --- /dev/null +++ b/docs/reference/context/say_stream/async_say_stream.html @@ -0,0 +1,174 @@ + + + + + + +slack_bolt.context.say_stream.async_say_stream API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.context.say_stream.async_say_stream

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class AsyncSayStream +(*,
    client: slack_sdk.web.async_client.AsyncWebClient,
    channel: str | None = None,
    recipient_team_id: str | None = None,
    recipient_user_id: str | None = None,
    thread_ts: str | None = None)
    +
    +
    +
    + +Expand source code + +
    class AsyncSayStream:
    +    client: AsyncWebClient
    +    channel: Optional[str]
    +    recipient_team_id: Optional[str]
    +    recipient_user_id: Optional[str]
    +    thread_ts: Optional[str]
    +
    +    def __init__(
    +        self,
    +        *,
    +        client: AsyncWebClient,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +    ):
    +        self.client = client
    +        self.channel = channel
    +        self.recipient_team_id = recipient_team_id
    +        self.recipient_user_id = recipient_user_id
    +        self.thread_ts = thread_ts
    +
    +    async def __call__(
    +        self,
    +        *,
    +        buffer_size: Optional[int] = None,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +        **kwargs,
    +    ) -> AsyncChatStream:
    +        """Starts a new chat stream with context."""
    +        channel = channel or self.channel
    +        thread_ts = thread_ts or self.thread_ts
    +        if channel is None:
    +            raise ValueError("say_stream without channel here is unsupported")
    +        if thread_ts is None:
    +            raise ValueError("say_stream without thread_ts here is unsupported")
    +
    +        if buffer_size is not None:
    +            return await self.client.chat_stream(
    +                buffer_size=buffer_size,
    +                channel=channel,
    +                recipient_team_id=recipient_team_id or self.recipient_team_id,
    +                recipient_user_id=recipient_user_id or self.recipient_user_id,
    +                thread_ts=thread_ts,
    +                **kwargs,
    +            )
    +        return await self.client.chat_stream(
    +            channel=channel,
    +            recipient_team_id=recipient_team_id or self.recipient_team_id,
    +            recipient_user_id=recipient_user_id or self.recipient_user_id,
    +            thread_ts=thread_ts,
    +            **kwargs,
    +        )
    +
    +
    +

    Class variables

    +
    +
    var channel : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var client : slack_sdk.web.async_client.AsyncWebClient
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_team_id : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_user_id : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var thread_ts : str | None
    +
    +

    The type of the None singleton.

    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/reference/context/say_stream/index.html b/docs/reference/context/say_stream/index.html new file mode 100644 index 000000000..645942c72 --- /dev/null +++ b/docs/reference/context/say_stream/index.html @@ -0,0 +1,191 @@ + + + + + + +slack_bolt.context.say_stream API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.context.say_stream

    +
    +
    +
    +
    +

    Sub-modules

    +
    +
    slack_bolt.context.say_stream.async_say_stream
    +
    +
    +
    +
    slack_bolt.context.say_stream.say_stream
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class SayStream +(*,
    client: slack_sdk.web.client.WebClient,
    channel: str | None = None,
    recipient_team_id: str | None = None,
    recipient_user_id: str | None = None,
    thread_ts: str | None = None)
    +
    +
    +
    + +Expand source code + +
    class SayStream:
    +    client: WebClient
    +    channel: Optional[str]
    +    recipient_team_id: Optional[str]
    +    recipient_user_id: Optional[str]
    +    thread_ts: Optional[str]
    +
    +    def __init__(
    +        self,
    +        *,
    +        client: WebClient,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +    ):
    +        self.client = client
    +        self.channel = channel
    +        self.recipient_team_id = recipient_team_id
    +        self.recipient_user_id = recipient_user_id
    +        self.thread_ts = thread_ts
    +
    +    def __call__(
    +        self,
    +        *,
    +        buffer_size: Optional[int] = None,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +        **kwargs,
    +    ) -> ChatStream:
    +        """Starts a new chat stream with context."""
    +        channel = channel or self.channel
    +        thread_ts = thread_ts or self.thread_ts
    +        if channel is None:
    +            raise ValueError("say_stream without channel here is unsupported")
    +        if thread_ts is None:
    +            raise ValueError("say_stream without thread_ts here is unsupported")
    +
    +        if buffer_size is not None:
    +            return self.client.chat_stream(
    +                buffer_size=buffer_size,
    +                channel=channel,
    +                recipient_team_id=recipient_team_id or self.recipient_team_id,
    +                recipient_user_id=recipient_user_id or self.recipient_user_id,
    +                thread_ts=thread_ts,
    +                **kwargs,
    +            )
    +        return self.client.chat_stream(
    +            channel=channel,
    +            recipient_team_id=recipient_team_id or self.recipient_team_id,
    +            recipient_user_id=recipient_user_id or self.recipient_user_id,
    +            thread_ts=thread_ts,
    +            **kwargs,
    +        )
    +
    +
    +

    Class variables

    +
    +
    var channel : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var client : slack_sdk.web.client.WebClient
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_team_id : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_user_id : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var thread_ts : str | None
    +
    +

    The type of the None singleton.

    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/reference/context/say_stream/say_stream.html b/docs/reference/context/say_stream/say_stream.html new file mode 100644 index 000000000..784a58bbe --- /dev/null +++ b/docs/reference/context/say_stream/say_stream.html @@ -0,0 +1,174 @@ + + + + + + +slack_bolt.context.say_stream.say_stream API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.context.say_stream.say_stream

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class SayStream +(*,
    client: slack_sdk.web.client.WebClient,
    channel: str | None = None,
    recipient_team_id: str | None = None,
    recipient_user_id: str | None = None,
    thread_ts: str | None = None)
    +
    +
    +
    + +Expand source code + +
    class SayStream:
    +    client: WebClient
    +    channel: Optional[str]
    +    recipient_team_id: Optional[str]
    +    recipient_user_id: Optional[str]
    +    thread_ts: Optional[str]
    +
    +    def __init__(
    +        self,
    +        *,
    +        client: WebClient,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +    ):
    +        self.client = client
    +        self.channel = channel
    +        self.recipient_team_id = recipient_team_id
    +        self.recipient_user_id = recipient_user_id
    +        self.thread_ts = thread_ts
    +
    +    def __call__(
    +        self,
    +        *,
    +        buffer_size: Optional[int] = None,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +        **kwargs,
    +    ) -> ChatStream:
    +        """Starts a new chat stream with context."""
    +        channel = channel or self.channel
    +        thread_ts = thread_ts or self.thread_ts
    +        if channel is None:
    +            raise ValueError("say_stream without channel here is unsupported")
    +        if thread_ts is None:
    +            raise ValueError("say_stream without thread_ts here is unsupported")
    +
    +        if buffer_size is not None:
    +            return self.client.chat_stream(
    +                buffer_size=buffer_size,
    +                channel=channel,
    +                recipient_team_id=recipient_team_id or self.recipient_team_id,
    +                recipient_user_id=recipient_user_id or self.recipient_user_id,
    +                thread_ts=thread_ts,
    +                **kwargs,
    +            )
    +        return self.client.chat_stream(
    +            channel=channel,
    +            recipient_team_id=recipient_team_id or self.recipient_team_id,
    +            recipient_user_id=recipient_user_id or self.recipient_user_id,
    +            thread_ts=thread_ts,
    +            **kwargs,
    +        )
    +
    +
    +

    Class variables

    +
    +
    var channel : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var client : slack_sdk.web.client.WebClient
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_team_id : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_user_id : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var thread_ts : str | None
    +
    +

    The type of the None singleton.

    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/reference/error/index.html b/docs/reference/error/index.html index 9a9998e63..f57d690e9 100644 --- a/docs/reference/error/index.html +++ b/docs/reference/error/index.html @@ -72,7 +72,7 @@

    Subclasses

    class BoltUnhandledRequestError -(*,
    request: BoltRequest | AsyncBoltRequest,
    current_response: BoltResponse | None,
    last_global_middleware_name: str | None = None)
    +(*,
    request: ForwardRef('BoltRequest') | ForwardRef('AsyncBoltRequest'),
    current_response: ForwardRef('BoltResponse') | None,
    last_global_middleware_name: str | None = None)
    diff --git a/docs/reference/index.html b/docs/reference/index.html index 1c02a8aeb..b2d19719d 100644 --- a/docs/reference/index.html +++ b/docs/reference/index.html @@ -188,7 +188,7 @@

    Class variables

    class App -(*,
    logger: logging.Logger | None = None,
    name: str | None = None,
    process_before_response: bool = False,
    raise_error_for_unhandled_request: bool = False,
    signing_secret: str | None = None,
    token: str | None = None,
    token_verification_enabled: bool = True,
    client: slack_sdk.web.client.WebClient | None = None,
    before_authorize: Middleware | Callable[..., Any] | None = None,
    authorize: Callable[..., AuthorizeResult] | None = None,
    user_facing_authorize_error_message: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None = None,
    installation_store_bot_only: bool | None = None,
    request_verification_enabled: bool = True,
    ignoring_self_events_enabled: bool = True,
    ignoring_self_assistant_message_events_enabled: bool = True,
    ssl_check_enabled: bool = True,
    url_verification_enabled: bool = True,
    attaching_function_token_enabled: bool = True,
    oauth_settings: OAuthSettings | None = None,
    oauth_flow: OAuthFlow | None = None,
    verification_token: str | None = None,
    listener_executor: concurrent.futures._base.Executor | None = None,
    assistant_thread_context_store: AssistantThreadContextStore | None = None)
    +(*,
    logger: logging.Logger | None = None,
    name: str | None = None,
    process_before_response: bool = False,
    raise_error_for_unhandled_request: bool = False,
    signing_secret: str | None = None,
    token: str | None = None,
    token_verification_enabled: bool = True,
    client: slack_sdk.web.client.WebClient | None = None,
    before_authorize: Middleware | Callable[..., Any] | None = None,
    authorize: Callable[..., AuthorizeResult] | None = None,
    user_facing_authorize_error_message: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None = None,
    installation_store_bot_only: bool | None = None,
    request_verification_enabled: bool = True,
    ignoring_self_events_enabled: bool = True,
    ignoring_self_assistant_message_events_enabled: bool = True,
    ssl_check_enabled: bool = True,
    url_verification_enabled: bool = True,
    attaching_function_token_enabled: bool = True,
    oauth_settings: OAuthSettings | None = None,
    oauth_flow: OAuthFlow | None = None,
    verification_token: str | None = None,
    listener_executor: concurrent.futures._base.Executor | None = None,
    assistant_thread_context_store: AssistantThreadContextStore | None = None,
    attaching_conversation_kwargs_enabled: bool = True)
    @@ -235,6 +235,7 @@

    Class variables

    listener_executor: Optional[Executor] = None, # for AI Agents & Assistants assistant_thread_context_store: Optional[AssistantThreadContextStore] = None, + attaching_conversation_kwargs_enabled: bool = True, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -455,6 +456,7 @@

    Class variables

    listener_executor = ThreadPoolExecutor(max_workers=5) self._assistant_thread_context_store = assistant_thread_context_store + self._attaching_conversation_kwargs_enabled = attaching_conversation_kwargs_enabled self._process_before_response = process_before_response self._listener_runner = ThreadListenerRunner( @@ -939,10 +941,13 @@

    Class variables

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, base_logger=self._base_logger) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__ @@ -1000,6 +1005,8 @@

    Class variables

    primary_matcher = builtin_matchers.message_event( keyword=keyword, constraints=constraints, base_logger=self._base_logger ) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) @@ -1496,20 +1503,6 @@

    Class variables

    # It is intended for apps that start lazy listeners from their custom global middleware. req.context["listener_runner"] = self.listener_runner - # For AI Agents & Assistants - if is_assistant_event(req.body): - assistant = AssistantUtilities( - payload=to_event(req.body), # type:ignore[arg-type] - context=req.context, - thread_context_store=self._assistant_thread_context_store, - ) - req.context["say"] = assistant.say - req.context["set_status"] = assistant.set_status - req.context["set_title"] = assistant.set_title - req.context["set_suggested_prompts"] = assistant.set_suggested_prompts - req.context["get_thread_context"] = assistant.get_thread_context - req.context["save_thread_context"] = assistant.save_thread_context - @staticmethod def _to_listener_functions( kwargs: dict, @@ -1555,7 +1548,7 @@

    Class variables

    CustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, # type:ignore[arg-type] + lazy_functions=functions, # type: ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, @@ -2343,10 +2336,13 @@

    Args

    middleware: A list of lister middleware functions. Only when all the middleware call `next()` method, the listener function can be invoked. """ + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.event(event, base_logger=self._base_logger) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) return __call__ @@ -2550,6 +2546,8 @@

    Args

    primary_matcher = builtin_matchers.message_event( keyword=keyword, constraints=constraints, base_logger=self._base_logger ) + if self._attaching_conversation_kwargs_enabled: + middleware.insert(0, AttachingConversationKwargs(self._assistant_thread_context_store)) middleware.insert(0, MessageListenerMatches(keyword)) return self._register_listener(list(functions), primary_matcher, matchers, middleware, True) @@ -3179,7 +3177,7 @@

    Args

    class Args -(*,
    logger: logging.Logger,
    client: slack_sdk.web.client.WebClient,
    req: BoltRequest,
    resp: BoltResponse,
    context: BoltContext,
    body: Dict[str, Any],
    payload: Dict[str, Any],
    options: Dict[str, Any] | None = None,
    shortcut: Dict[str, Any] | None = None,
    action: Dict[str, Any] | None = None,
    view: Dict[str, Any] | None = None,
    command: Dict[str, Any] | None = None,
    event: Dict[str, Any] | None = None,
    message: Dict[str, Any] | None = None,
    ack: Ack,
    say: Say,
    respond: Respond,
    complete: Complete,
    fail: Fail,
    set_status: SetStatus | None = None,
    set_title: SetTitle | None = None,
    set_suggested_prompts: SetSuggestedPrompts | None = None,
    get_thread_context: GetThreadContext | None = None,
    save_thread_context: SaveThreadContext | None = None,
    next: Callable[[], None],
    **kwargs)
    +(*,
    logger: logging.Logger,
    client: slack_sdk.web.client.WebClient,
    req: BoltRequest,
    resp: BoltResponse,
    context: BoltContext,
    body: Dict[str, Any],
    payload: Dict[str, Any],
    options: Dict[str, Any] | None = None,
    shortcut: Dict[str, Any] | None = None,
    action: Dict[str, Any] | None = None,
    view: Dict[str, Any] | None = None,
    command: Dict[str, Any] | None = None,
    event: Dict[str, Any] | None = None,
    message: Dict[str, Any] | None = None,
    ack: Ack,
    say: Say,
    respond: Respond,
    complete: Complete,
    fail: Fail,
    set_status: SetStatus | None = None,
    set_title: SetTitle | None = None,
    set_suggested_prompts: SetSuggestedPrompts | None = None,
    get_thread_context: GetThreadContext | None = None,
    save_thread_context: SaveThreadContext | None = None,
    say_stream: SayStream | None = None,
    next: Callable[[], None],
    **kwargs)
    @@ -3270,6 +3268,8 @@

    Args

    """`get_thread_context()` utility function for AI Agents & Assistants""" save_thread_context: Optional[SaveThreadContext] """`save_thread_context()` utility function for AI Agents & Assistants""" + say_stream: Optional[SayStream] + """`say_stream()` utility function for conversations, AI Agents & Assistants""" # middleware next: Callable[[], None] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -3303,6 +3303,7 @@

    Args

    set_suggested_prompts: Optional[SetSuggestedPrompts] = None, get_thread_context: Optional[GetThreadContext] = None, save_thread_context: Optional[SaveThreadContext] = None, + say_stream: Optional[SayStream] = None, # As this method is not supposed to be invoked by bolt-python users, # the naming conflict with the built-in one affects # only the internals of this method @@ -3336,6 +3337,7 @@

    Args

    self.set_suggested_prompts = set_suggested_prompts self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context + self.say_stream = say_stream self.next: Callable[[], None] = next self.next_: Callable[[], None] = next @@ -3459,6 +3461,10 @@

    Class variables

    say() utility function, which calls chat.postMessage API with the associated channel ID

    +
    var say_streamSayStream | None
    +
    +

    say_stream() utility function for conversations, AI Agents & Assistants

    +
    var set_statusSetStatus | None

    set_status() utility function for AI Agents & Assistants

    @@ -3531,7 +3537,7 @@

    Class variables

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3570,7 +3576,7 @@

    Class variables

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3609,7 +3615,7 @@

    Class variables

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3648,7 +3654,7 @@

    Class variables

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3678,13 +3684,13 @@

    Class variables

    ): return [CustomListenerMatcher(app_name=self.app_name, func=primary_matcher)] + ( custom_matchers or [] - ) # type:ignore[operator] + ) # type: ignore[operator] @staticmethod def default_thread_context_changed(save_thread_context: SaveThreadContext, payload: dict): save_thread_context(payload["assistant_thread"]["context"]) - def process( # type:ignore[return] + def process( # type: ignore[return] self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse] ) -> Optional[BoltResponse]: if self._thread_context_changed_listeners is None: @@ -3700,6 +3706,15 @@

    Class variables

    if listeners is not None: for listener in listeners: if listener.matches(req=req, resp=resp): + middleware_resp, next_was_not_called = listener.run_middleware(req=req, resp=resp) + if next_was_not_called: + if middleware_resp is not None: + return middleware_resp + # The listener middleware didn't call next(). + # Skip this listener and try the next one. + continue + if middleware_resp is not None: + resp = middleware_resp return listener_runner.run( request=req, response=resp, @@ -3719,13 +3734,14 @@

    Class variables

    middleware: Optional[List[Middleware]] = None, base_logger: Optional[Logger] = None, ) -> Listener: - if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] - listener_or_functions = [listener_or_functions] # type:ignore[list-item] + if isinstance(listener_or_functions, Callable): # type: ignore[arg-type] + listener_or_functions = [listener_or_functions] # type: ignore[list-item] if isinstance(listener_or_functions, Listener): return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] + middleware.insert(0, AttachingConversationKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) @@ -3734,7 +3750,7 @@

    Class variables

    for matcher in matchers: if isinstance(matcher, ListenerMatcher): listener_matchers.append(matcher) - elif isinstance(matcher, Callable): # type:ignore[arg-type] + elif isinstance(matcher, Callable): # type: ignore[arg-type] listener_matchers.append( build_listener_matcher( func=matcher, @@ -3813,7 +3829,7 @@

    Methods

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3853,13 +3869,14 @@

    Methods

    middleware: Optional[List[Middleware]] = None, base_logger: Optional[Logger] = None, ) -> Listener: - if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] - listener_or_functions = [listener_or_functions] # type:ignore[list-item] + if isinstance(listener_or_functions, Callable): # type: ignore[arg-type] + listener_or_functions = [listener_or_functions] # type: ignore[list-item] if isinstance(listener_or_functions, Listener): return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] + middleware.insert(0, AttachingConversationKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) @@ -3868,7 +3885,7 @@

    Methods

    for matcher in matchers: if isinstance(matcher, ListenerMatcher): listener_matchers.append(matcher) - elif isinstance(matcher, Callable): # type:ignore[arg-type] + elif isinstance(matcher, Callable): # type: ignore[arg-type] listener_matchers.append( build_listener_matcher( func=matcher, @@ -3914,7 +3931,7 @@

    Methods

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -3963,7 +3980,7 @@

    Methods

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -4012,7 +4029,7 @@

    Methods

    self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -4185,7 +4202,7 @@

    Methods

    # The return type is intentionally string to avoid circular imports @property - def listener_runner(self) -> "ThreadListenerRunner": # type: ignore[name-defined] + def listener_runner(self) -> "ThreadListenerRunner": """The properly configured listener_runner that is available for middleware/listeners.""" return self["listener_runner"] @@ -4254,7 +4271,7 @@

    Methods

    Callable `say()` function """ if "say" not in self: - self["say"] = Say(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) + self["say"] = Say(client=self.client, channel=self.channel_id) return self["say"] @property @@ -4347,6 +4364,10 @@

    Methods

    def get_thread_context(self) -> Optional[GetThreadContext]: return self.get("get_thread_context") + @property + def say_stream(self) -> Optional[SayStream]: + return self.get("say_stream") + @property def save_thread_context(self) -> Optional[SaveThreadContext]: return self.get("save_thread_context") @@ -4562,7 +4583,7 @@

    Returns

    Expand source code
    @property
    -def listener_runner(self) -> "ThreadListenerRunner":  # type: ignore[name-defined]
    +def listener_runner(self) -> "ThreadListenerRunner":
         """The properly configured listener_runner that is available for middleware/listeners."""
         return self["listener_runner"]
    @@ -4652,7 +4673,7 @@

    Returns

    Callable `say()` function """ if "say" not in self: - self["say"] = Say(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) + self["say"] = Say(client=self.client, channel=self.channel_id) return self["say"]

    say() function for this request.

    @@ -4670,6 +4691,18 @@

    Returns

    Returns

    Callable say() function

    +
    prop say_streamSayStream | None
    +
    +
    + +Expand source code + +
    @property
    +def say_stream(self) -> Optional[SayStream]:
    +    return self.get("say_stream")
    +
    +
    +
    prop set_statusSetStatus | None
    @@ -5304,7 +5337,7 @@

    Returns

    class FileAssistantThreadContextStore -(base_dir: str = '/Users/wbergamin/.bolt-app-assistant-thread-contexts') +(base_dir: str = '/Users/eden.zimbelman/.bolt-app-assistant-thread-contexts')
    @@ -5843,6 +5876,97 @@

    Class variables

    +
    +class SayStream +(*,
    client: slack_sdk.web.client.WebClient,
    channel: str | None = None,
    recipient_team_id: str | None = None,
    recipient_user_id: str | None = None,
    thread_ts: str | None = None)
    +
    +
    +
    + +Expand source code + +
    class SayStream:
    +    client: WebClient
    +    channel: Optional[str]
    +    recipient_team_id: Optional[str]
    +    recipient_user_id: Optional[str]
    +    thread_ts: Optional[str]
    +
    +    def __init__(
    +        self,
    +        *,
    +        client: WebClient,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +    ):
    +        self.client = client
    +        self.channel = channel
    +        self.recipient_team_id = recipient_team_id
    +        self.recipient_user_id = recipient_user_id
    +        self.thread_ts = thread_ts
    +
    +    def __call__(
    +        self,
    +        *,
    +        buffer_size: Optional[int] = None,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +        **kwargs,
    +    ) -> ChatStream:
    +        """Starts a new chat stream with context."""
    +        channel = channel or self.channel
    +        thread_ts = thread_ts or self.thread_ts
    +        if channel is None:
    +            raise ValueError("say_stream without channel here is unsupported")
    +        if thread_ts is None:
    +            raise ValueError("say_stream without thread_ts here is unsupported")
    +
    +        if buffer_size is not None:
    +            return self.client.chat_stream(
    +                buffer_size=buffer_size,
    +                channel=channel,
    +                recipient_team_id=recipient_team_id or self.recipient_team_id,
    +                recipient_user_id=recipient_user_id or self.recipient_user_id,
    +                thread_ts=thread_ts,
    +                **kwargs,
    +            )
    +        return self.client.chat_stream(
    +            channel=channel,
    +            recipient_team_id=recipient_team_id or self.recipient_team_id,
    +            recipient_user_id=recipient_user_id or self.recipient_user_id,
    +            thread_ts=thread_ts,
    +            **kwargs,
    +        )
    +
    +
    +

    Class variables

    +
    +
    var channel : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var client : slack_sdk.web.client.WebClient
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_team_id : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_user_id : str | None
    +
    +

    The type of the None singleton.

    +
    +
    var thread_ts : str | None
    +
    +

    The type of the None singleton.

    +
    +
    +
    class SetStatus (client: slack_sdk.web.client.WebClient, channel_id: str, thread_ts: str) @@ -6110,6 +6234,7 @@

    Args

    response
  • save_thread_context
  • say
  • +
  • say_stream
  • set_status
  • set_suggested_prompts
  • set_title
  • @@ -6157,6 +6282,7 @@

    BoltC
  • respond
  • save_thread_context
  • say
  • +
  • say_stream
  • set_status
  • set_suggested_prompts
  • set_title
  • @@ -6262,6 +6388,16 @@

    Say

  • +

    SayStream

    + +
  • +
  • SetStatus

    • channel_id
    • diff --git a/docs/reference/kwargs_injection/args.html b/docs/reference/kwargs_injection/args.html index 4d03687d1..bbba71eb8 100644 --- a/docs/reference/kwargs_injection/args.html +++ b/docs/reference/kwargs_injection/args.html @@ -48,7 +48,7 @@

      Classes

      class Args -(*,
      logger: logging.Logger,
      client: slack_sdk.web.client.WebClient,
      req: BoltRequest,
      resp: BoltResponse,
      context: BoltContext,
      body: Dict[str, Any],
      payload: Dict[str, Any],
      options: Dict[str, Any] | None = None,
      shortcut: Dict[str, Any] | None = None,
      action: Dict[str, Any] | None = None,
      view: Dict[str, Any] | None = None,
      command: Dict[str, Any] | None = None,
      event: Dict[str, Any] | None = None,
      message: Dict[str, Any] | None = None,
      ack: Ack,
      say: Say,
      respond: Respond,
      complete: Complete,
      fail: Fail,
      set_status: SetStatus | None = None,
      set_title: SetTitle | None = None,
      set_suggested_prompts: SetSuggestedPrompts | None = None,
      get_thread_context: GetThreadContext | None = None,
      save_thread_context: SaveThreadContext | None = None,
      next: Callable[[], None],
      **kwargs)
      +(*,
      logger: logging.Logger,
      client: slack_sdk.web.client.WebClient,
      req: BoltRequest,
      resp: BoltResponse,
      context: BoltContext,
      body: Dict[str, Any],
      payload: Dict[str, Any],
      options: Dict[str, Any] | None = None,
      shortcut: Dict[str, Any] | None = None,
      action: Dict[str, Any] | None = None,
      view: Dict[str, Any] | None = None,
      command: Dict[str, Any] | None = None,
      event: Dict[str, Any] | None = None,
      message: Dict[str, Any] | None = None,
      ack: Ack,
      say: Say,
      respond: Respond,
      complete: Complete,
      fail: Fail,
      set_status: SetStatus | None = None,
      set_title: SetTitle | None = None,
      set_suggested_prompts: SetSuggestedPrompts | None = None,
      get_thread_context: GetThreadContext | None = None,
      save_thread_context: SaveThreadContext | None = None,
      say_stream: SayStream | None = None,
      next: Callable[[], None],
      **kwargs)
      @@ -139,6 +139,8 @@

      Classes

      """`get_thread_context()` utility function for AI Agents & Assistants""" save_thread_context: Optional[SaveThreadContext] """`save_thread_context()` utility function for AI Agents & Assistants""" + say_stream: Optional[SayStream] + """`say_stream()` utility function for conversations, AI Agents & Assistants""" # middleware next: Callable[[], None] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -172,6 +174,7 @@

      Classes

      set_suggested_prompts: Optional[SetSuggestedPrompts] = None, get_thread_context: Optional[GetThreadContext] = None, save_thread_context: Optional[SaveThreadContext] = None, + say_stream: Optional[SayStream] = None, # As this method is not supposed to be invoked by bolt-python users, # the naming conflict with the built-in one affects # only the internals of this method @@ -205,6 +208,7 @@

      Classes

      self.set_suggested_prompts = set_suggested_prompts self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context + self.say_stream = say_stream self.next: Callable[[], None] = next self.next_: Callable[[], None] = next
      @@ -328,6 +332,10 @@

      Class variables

      say() utility function, which calls chat.postMessage API with the associated channel ID

      +
      var say_streamSayStream | None
      +
      +

      say_stream() utility function for conversations, AI Agents & Assistants

      +
      var set_statusSetStatus | None

      set_status() utility function for AI Agents & Assistants

      @@ -391,6 +399,7 @@

      response
    • save_thread_context
    • say
    • +
    • say_stream
    • set_status
    • set_suggested_prompts
    • set_title
    • diff --git a/docs/reference/kwargs_injection/async_args.html b/docs/reference/kwargs_injection/async_args.html index 959f35a43..5b0e7b70e 100644 --- a/docs/reference/kwargs_injection/async_args.html +++ b/docs/reference/kwargs_injection/async_args.html @@ -48,7 +48,7 @@

      Classes

      class AsyncArgs -(*,
      logger: logging.Logger,
      client: slack_sdk.web.async_client.AsyncWebClient,
      req: AsyncBoltRequest,
      resp: BoltResponse,
      context: AsyncBoltContext,
      body: Dict[str, Any],
      payload: Dict[str, Any],
      options: Dict[str, Any] | None = None,
      shortcut: Dict[str, Any] | None = None,
      action: Dict[str, Any] | None = None,
      view: Dict[str, Any] | None = None,
      command: Dict[str, Any] | None = None,
      event: Dict[str, Any] | None = None,
      message: Dict[str, Any] | None = None,
      ack: AsyncAck,
      say: AsyncSay,
      respond: AsyncRespond,
      complete: AsyncComplete,
      fail: AsyncFail,
      set_status: AsyncSetStatus | None = None,
      set_title: AsyncSetTitle | None = None,
      set_suggested_prompts: AsyncSetSuggestedPrompts | None = None,
      get_thread_context: AsyncGetThreadContext | None = None,
      save_thread_context: AsyncSaveThreadContext | None = None,
      next: Callable[[], Awaitable[None]],
      **kwargs)
      +(*,
      logger: logging.Logger,
      client: slack_sdk.web.async_client.AsyncWebClient,
      req: AsyncBoltRequest,
      resp: BoltResponse,
      context: AsyncBoltContext,
      body: Dict[str, Any],
      payload: Dict[str, Any],
      options: Dict[str, Any] | None = None,
      shortcut: Dict[str, Any] | None = None,
      action: Dict[str, Any] | None = None,
      view: Dict[str, Any] | None = None,
      command: Dict[str, Any] | None = None,
      event: Dict[str, Any] | None = None,
      message: Dict[str, Any] | None = None,
      ack: AsyncAck,
      say: AsyncSay,
      respond: AsyncRespond,
      complete: AsyncComplete,
      fail: AsyncFail,
      set_status: AsyncSetStatus | None = None,
      set_title: AsyncSetTitle | None = None,
      set_suggested_prompts: AsyncSetSuggestedPrompts | None = None,
      get_thread_context: AsyncGetThreadContext | None = None,
      save_thread_context: AsyncSaveThreadContext | None = None,
      say_stream: AsyncSayStream | None = None,
      next: Callable[[], Awaitable[None]],
      **kwargs)
      @@ -139,6 +139,8 @@

      Classes

      """`get_thread_context()` utility function for AI Agents & Assistants""" save_thread_context: Optional[AsyncSaveThreadContext] """`save_thread_context()` utility function for AI Agents & Assistants""" + say_stream: Optional[AsyncSayStream] + """`say_stream()` utility function for AI Agents & Assistants""" # middleware next: Callable[[], Awaitable[None]] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -172,6 +174,7 @@

      Classes

      set_suggested_prompts: Optional[AsyncSetSuggestedPrompts] = None, get_thread_context: Optional[AsyncGetThreadContext] = None, save_thread_context: Optional[AsyncSaveThreadContext] = None, + say_stream: Optional[AsyncSayStream] = None, next: Callable[[], Awaitable[None]], **kwargs, # noqa ): @@ -202,6 +205,7 @@

      Classes

      self.set_suggested_prompts = set_suggested_prompts self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context + self.say_stream = say_stream self.next: Callable[[], Awaitable[None]] = next self.next_: Callable[[], Awaitable[None]] = next @@ -325,6 +329,10 @@

      Class variables

      say() utility function, which calls chat.postMessage API with the associated channel ID

      +
      var say_streamAsyncSayStream | None
      +
      +

      say_stream() utility function for AI Agents & Assistants

      +
      var set_statusAsyncSetStatus | None

      set_status() utility function for AI Agents & Assistants

      @@ -388,6 +396,7 @@

      response
    • save_thread_context
    • say
    • +
    • say_stream
    • set_status
    • set_suggested_prompts
    • set_title
    • diff --git a/docs/reference/kwargs_injection/async_utils.html b/docs/reference/kwargs_injection/async_utils.html index 80952518d..7af3a7679 100644 --- a/docs/reference/kwargs_injection/async_utils.html +++ b/docs/reference/kwargs_injection/async_utils.html @@ -63,7 +63,7 @@

      Functions

      error: Optional[Exception] = None, # for error handlers next_keys_required: bool = True, # False for listeners / middleware / error handlers ) -> Dict[str, Any]: - all_available_args = { + all_available_args: Dict[str, Any] = { "logger": logger, "client": request.context.client, "req": request, @@ -92,6 +92,7 @@

      Functions

      "set_suggested_prompts": request.context.set_suggested_prompts, "get_thread_context": request.context.get_thread_context, "save_thread_context": request.context.save_thread_context, + "say_stream": request.context.say_stream, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function @@ -136,7 +137,7 @@

      Functions

      for name in required_arg_names: if name == "args": if isinstance(request, AsyncBoltRequest): - kwargs[name] = AsyncArgs(**all_available_args) # type: ignore[arg-type] + kwargs[name] = AsyncArgs(**all_available_args) else: logger.warning(f"Unknown Request object type detected ({type(request)})") diff --git a/docs/reference/kwargs_injection/index.html b/docs/reference/kwargs_injection/index.html index de7ef4a0a..cb17cea5d 100644 --- a/docs/reference/kwargs_injection/index.html +++ b/docs/reference/kwargs_injection/index.html @@ -85,7 +85,7 @@

      Functions

      error: Optional[Exception] = None, # for error handlers next_keys_required: bool = True, # False for listeners / middleware / error handlers ) -> Dict[str, Any]: - all_available_args = { + all_available_args: Dict[str, Any] = { "logger": logger, "client": request.context.client, "req": request, @@ -113,6 +113,7 @@

      Functions

      "set_title": request.context.set_title, "set_suggested_prompts": request.context.set_suggested_prompts, "save_thread_context": request.context.save_thread_context, + "say_stream": request.context.say_stream, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function @@ -157,7 +158,7 @@

      Functions

      for name in required_arg_names: if name == "args": if isinstance(request, BoltRequest): - kwargs[name] = Args(**all_available_args) # type: ignore[arg-type] + kwargs[name] = Args(**all_available_args) else: logger.warning(f"Unknown Request object type detected ({type(request)})") @@ -175,7 +176,7 @@

      Classes

      class Args -(*,
      logger: logging.Logger,
      client: slack_sdk.web.client.WebClient,
      req: BoltRequest,
      resp: BoltResponse,
      context: BoltContext,
      body: Dict[str, Any],
      payload: Dict[str, Any],
      options: Dict[str, Any] | None = None,
      shortcut: Dict[str, Any] | None = None,
      action: Dict[str, Any] | None = None,
      view: Dict[str, Any] | None = None,
      command: Dict[str, Any] | None = None,
      event: Dict[str, Any] | None = None,
      message: Dict[str, Any] | None = None,
      ack: Ack,
      say: Say,
      respond: Respond,
      complete: Complete,
      fail: Fail,
      set_status: SetStatus | None = None,
      set_title: SetTitle | None = None,
      set_suggested_prompts: SetSuggestedPrompts | None = None,
      get_thread_context: GetThreadContext | None = None,
      save_thread_context: SaveThreadContext | None = None,
      next: Callable[[], None],
      **kwargs)
      +(*,
      logger: logging.Logger,
      client: slack_sdk.web.client.WebClient,
      req: BoltRequest,
      resp: BoltResponse,
      context: BoltContext,
      body: Dict[str, Any],
      payload: Dict[str, Any],
      options: Dict[str, Any] | None = None,
      shortcut: Dict[str, Any] | None = None,
      action: Dict[str, Any] | None = None,
      view: Dict[str, Any] | None = None,
      command: Dict[str, Any] | None = None,
      event: Dict[str, Any] | None = None,
      message: Dict[str, Any] | None = None,
      ack: Ack,
      say: Say,
      respond: Respond,
      complete: Complete,
      fail: Fail,
      set_status: SetStatus | None = None,
      set_title: SetTitle | None = None,
      set_suggested_prompts: SetSuggestedPrompts | None = None,
      get_thread_context: GetThreadContext | None = None,
      save_thread_context: SaveThreadContext | None = None,
      say_stream: SayStream | None = None,
      next: Callable[[], None],
      **kwargs)
      @@ -266,6 +267,8 @@

      Classes

      """`get_thread_context()` utility function for AI Agents & Assistants""" save_thread_context: Optional[SaveThreadContext] """`save_thread_context()` utility function for AI Agents & Assistants""" + say_stream: Optional[SayStream] + """`say_stream()` utility function for conversations, AI Agents & Assistants""" # middleware next: Callable[[], None] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -299,6 +302,7 @@

      Classes

      set_suggested_prompts: Optional[SetSuggestedPrompts] = None, get_thread_context: Optional[GetThreadContext] = None, save_thread_context: Optional[SaveThreadContext] = None, + say_stream: Optional[SayStream] = None, # As this method is not supposed to be invoked by bolt-python users, # the naming conflict with the built-in one affects # only the internals of this method @@ -332,6 +336,7 @@

      Classes

      self.set_suggested_prompts = set_suggested_prompts self.get_thread_context = get_thread_context self.save_thread_context = save_thread_context + self.say_stream = say_stream self.next: Callable[[], None] = next self.next_: Callable[[], None] = next
      @@ -455,6 +460,10 @@

      Class variables

      say() utility function, which calls chat.postMessage API with the associated channel ID

      +
      var say_streamSayStream | None
      +
      +

      say_stream() utility function for conversations, AI Agents & Assistants

      +
      var set_statusSetStatus | None

      set_status() utility function for AI Agents & Assistants

      @@ -531,6 +540,7 @@

      response
    • save_thread_context
    • say
    • +
    • say_stream
    • set_status
    • set_suggested_prompts
    • set_title
    • diff --git a/docs/reference/kwargs_injection/utils.html b/docs/reference/kwargs_injection/utils.html index 2e6ecd001..0289fd410 100644 --- a/docs/reference/kwargs_injection/utils.html +++ b/docs/reference/kwargs_injection/utils.html @@ -63,7 +63,7 @@

      Functions

      error: Optional[Exception] = None, # for error handlers next_keys_required: bool = True, # False for listeners / middleware / error handlers ) -> Dict[str, Any]: - all_available_args = { + all_available_args: Dict[str, Any] = { "logger": logger, "client": request.context.client, "req": request, @@ -91,6 +91,7 @@

      Functions

      "set_title": request.context.set_title, "set_suggested_prompts": request.context.set_suggested_prompts, "save_thread_context": request.context.save_thread_context, + "say_stream": request.context.say_stream, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function @@ -135,7 +136,7 @@

      Functions

      for name in required_arg_names: if name == "args": if isinstance(request, BoltRequest): - kwargs[name] = Args(**all_available_args) # type: ignore[arg-type] + kwargs[name] = Args(**all_available_args) else: logger.warning(f"Unknown Request object type detected ({type(request)})") diff --git a/docs/reference/listener/async_listener_error_handler.html b/docs/reference/listener/async_listener_error_handler.html index 1f3789c40..ebee4441a 100644 --- a/docs/reference/listener/async_listener_error_handler.html +++ b/docs/reference/listener/async_listener_error_handler.html @@ -77,9 +77,10 @@

      Classes

      ) returned_response = await self.func(**kwargs) if returned_response is not None and isinstance(returned_response, BoltResponse): - response.status = returned_response.status # type: ignore[union-attr] - response.headers = returned_response.headers # type: ignore[union-attr] - response.body = returned_response.body # type: ignore[union-attr] + assert response is not None, "response must be provided when returning a BoltResponse from an error handler" + response.status = returned_response.status + response.headers = returned_response.headers + response.body = returned_response.body

      Ancestors

      diff --git a/docs/reference/listener/listener_error_handler.html b/docs/reference/listener/listener_error_handler.html index c9f7c2ccd..e344b15cb 100644 --- a/docs/reference/listener/listener_error_handler.html +++ b/docs/reference/listener/listener_error_handler.html @@ -77,9 +77,10 @@

      Classes

      ) returned_response = self.func(**kwargs) if returned_response is not None and isinstance(returned_response, BoltResponse): - response.status = returned_response.status # type: ignore[union-attr] - response.headers = returned_response.headers # type: ignore[union-attr] - response.body = returned_response.body # type: ignore[union-attr] + assert response is not None, "response must be provided when returning a BoltResponse from an error handler" + response.status = returned_response.status + response.headers = returned_response.headers + response.body = returned_response.body

      Ancestors

      diff --git a/docs/reference/logger/messages.html b/docs/reference/logger/messages.html index e69b45fc9..1072e6479 100644 --- a/docs/reference/logger/messages.html +++ b/docs/reference/logger/messages.html @@ -409,7 +409,7 @@

      Functions

      -def warning_unhandled_by_global_middleware(name: str,
      req: BoltRequest | AsyncBoltRequest) ‑> str
      +def warning_unhandled_by_global_middleware(name: str,
      req: BoltRequest | ForwardRef('AsyncBoltRequest')) ‑> str
      @@ -427,7 +427,7 @@

      Functions

      -def warning_unhandled_request(req: BoltRequest | AsyncBoltRequest) ‑> str +def warning_unhandled_request(req: BoltRequest | ForwardRef('AsyncBoltRequest')) ‑> str
      diff --git a/docs/reference/middleware/assistant/assistant.html b/docs/reference/middleware/assistant/assistant.html index d1184c407..946416d62 100644 --- a/docs/reference/middleware/assistant/assistant.html +++ b/docs/reference/middleware/assistant/assistant.html @@ -96,7 +96,7 @@

      Classes

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -135,7 +135,7 @@

      Classes

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -174,7 +174,7 @@

      Classes

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -213,7 +213,7 @@

      Classes

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -243,13 +243,13 @@

      Classes

      ): return [CustomListenerMatcher(app_name=self.app_name, func=primary_matcher)] + ( custom_matchers or [] - ) # type:ignore[operator] + ) # type: ignore[operator] @staticmethod def default_thread_context_changed(save_thread_context: SaveThreadContext, payload: dict): save_thread_context(payload["assistant_thread"]["context"]) - def process( # type:ignore[return] + def process( # type: ignore[return] self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse] ) -> Optional[BoltResponse]: if self._thread_context_changed_listeners is None: @@ -265,6 +265,15 @@

      Classes

      if listeners is not None: for listener in listeners: if listener.matches(req=req, resp=resp): + middleware_resp, next_was_not_called = listener.run_middleware(req=req, resp=resp) + if next_was_not_called: + if middleware_resp is not None: + return middleware_resp + # The listener middleware didn't call next(). + # Skip this listener and try the next one. + continue + if middleware_resp is not None: + resp = middleware_resp return listener_runner.run( request=req, response=resp, @@ -284,13 +293,14 @@

      Classes

      middleware: Optional[List[Middleware]] = None, base_logger: Optional[Logger] = None, ) -> Listener: - if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] - listener_or_functions = [listener_or_functions] # type:ignore[list-item] + if isinstance(listener_or_functions, Callable): # type: ignore[arg-type] + listener_or_functions = [listener_or_functions] # type: ignore[list-item] if isinstance(listener_or_functions, Listener): return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] + middleware.insert(0, AttachingConversationKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) @@ -299,7 +309,7 @@

      Classes

      for matcher in matchers: if isinstance(matcher, ListenerMatcher): listener_matchers.append(matcher) - elif isinstance(matcher, Callable): # type:ignore[arg-type] + elif isinstance(matcher, Callable): # type: ignore[arg-type] listener_matchers.append( build_listener_matcher( func=matcher, @@ -378,7 +388,7 @@

      Methods

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -418,13 +428,14 @@

      Methods

      middleware: Optional[List[Middleware]] = None, base_logger: Optional[Logger] = None, ) -> Listener: - if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] - listener_or_functions = [listener_or_functions] # type:ignore[list-item] + if isinstance(listener_or_functions, Callable): # type: ignore[arg-type] + listener_or_functions = [listener_or_functions] # type: ignore[list-item] if isinstance(listener_or_functions, Listener): return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] + middleware.insert(0, AttachingConversationKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) @@ -433,7 +444,7 @@

      Methods

      for matcher in matchers: if isinstance(matcher, ListenerMatcher): listener_matchers.append(matcher) - elif isinstance(matcher, Callable): # type:ignore[arg-type] + elif isinstance(matcher, Callable): # type: ignore[arg-type] listener_matchers.append( build_listener_matcher( func=matcher, @@ -479,7 +490,7 @@

      Methods

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -528,7 +539,7 @@

      Methods

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -577,7 +588,7 @@

      Methods

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func diff --git a/docs/reference/middleware/assistant/async_assistant.html b/docs/reference/middleware/assistant/async_assistant.html index 2faf0e34b..748be2cbf 100644 --- a/docs/reference/middleware/assistant/async_assistant.html +++ b/docs/reference/middleware/assistant/async_assistant.html @@ -94,7 +94,7 @@

      Classes

      func=is_assistant_thread_started_event, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -103,7 +103,7 @@

      Classes

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -140,7 +140,7 @@

      Classes

      func=is_user_message_event_in_assistant_thread, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -149,7 +149,7 @@

      Classes

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -186,7 +186,7 @@

      Classes

      func=is_bot_message_event_in_assistant_thread, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -195,7 +195,7 @@

      Classes

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -232,7 +232,7 @@

      Classes

      func=is_assistant_thread_context_changed_event, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -241,7 +241,7 @@

      Classes

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -269,14 +269,14 @@

      Classes

      primary_matcher: Union[Callable[..., bool], AsyncListenerMatcher], custom_matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]], ): - return [primary_matcher] + (custom_matchers or []) # type:ignore[operator] + return [primary_matcher] + (custom_matchers or []) # type: ignore[operator] @staticmethod async def default_thread_context_changed(save_thread_context: AsyncSaveThreadContext, payload: dict): new_context: dict = payload["assistant_thread"]["context"] await save_thread_context(new_context) - async def async_process( # type:ignore[return] + async def async_process( # type: ignore[return] self, *, req: AsyncBoltRequest, @@ -296,6 +296,15 @@

      Classes

      if listeners is not None: for listener in listeners: if listener is not None and await listener.async_matches(req=req, resp=resp): + middleware_resp, next_was_not_called = await listener.run_async_middleware(req=req, resp=resp) + if next_was_not_called: + if middleware_resp is not None: + return middleware_resp + # The listener middleware didn't call next(). + # Skip this listener and try the next one. + continue + if middleware_resp is not None: + resp = middleware_resp return await listener_runner.run( request=req, response=resp, @@ -315,13 +324,14 @@

      Classes

      middleware: Optional[List[AsyncMiddleware]] = None, base_logger: Optional[Logger] = None, ) -> AsyncListener: - if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] - listener_or_functions = [listener_or_functions] # type:ignore[list-item] + if isinstance(listener_or_functions, Callable): # type: ignore[arg-type] + listener_or_functions = [listener_or_functions] # type: ignore[list-item] if isinstance(listener_or_functions, AsyncListener): return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] + middleware.insert(0, AsyncAttachingConversationKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) @@ -333,7 +343,7 @@

      Classes

      else: listener_matchers.append( build_listener_matcher( - func=matcher, # type:ignore[arg-type] + func=matcher, # type: ignore[arg-type] asyncio=True, base_logger=base_logger, ) @@ -408,7 +418,7 @@

      Methods

      func=is_bot_message_event_in_assistant_thread, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -417,7 +427,7 @@

      Methods

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -457,13 +467,14 @@

      Methods

      middleware: Optional[List[AsyncMiddleware]] = None, base_logger: Optional[Logger] = None, ) -> AsyncListener: - if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] - listener_or_functions = [listener_or_functions] # type:ignore[list-item] + if isinstance(listener_or_functions, Callable): # type: ignore[arg-type] + listener_or_functions = [listener_or_functions] # type: ignore[list-item] if isinstance(listener_or_functions, AsyncListener): return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] + middleware.insert(0, AsyncAttachingConversationKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) @@ -475,7 +486,7 @@

      Methods

      else: listener_matchers.append( build_listener_matcher( - func=matcher, # type:ignore[arg-type] + func=matcher, # type: ignore[arg-type] asyncio=True, base_logger=base_logger, ) @@ -516,7 +527,7 @@

      Methods

      func=is_assistant_thread_context_changed_event, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -525,7 +536,7 @@

      Methods

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -572,7 +583,7 @@

      Methods

      func=is_assistant_thread_started_event, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -581,7 +592,7 @@

      Methods

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -628,7 +639,7 @@

      Methods

      func=is_user_message_event_in_assistant_thread, asyncio=True, base_logger=self.base_logger, - ), # type:ignore[arg-type] + ), # type: ignore[arg-type] matchers, ) if is_used_without_argument(args): @@ -637,7 +648,7 @@

      Methods

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func diff --git a/docs/reference/middleware/assistant/index.html b/docs/reference/middleware/assistant/index.html index 92f405cad..e9fce8d64 100644 --- a/docs/reference/middleware/assistant/index.html +++ b/docs/reference/middleware/assistant/index.html @@ -107,7 +107,7 @@

      Classes

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -146,7 +146,7 @@

      Classes

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -185,7 +185,7 @@

      Classes

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -224,7 +224,7 @@

      Classes

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -254,13 +254,13 @@

      Classes

      ): return [CustomListenerMatcher(app_name=self.app_name, func=primary_matcher)] + ( custom_matchers or [] - ) # type:ignore[operator] + ) # type: ignore[operator] @staticmethod def default_thread_context_changed(save_thread_context: SaveThreadContext, payload: dict): save_thread_context(payload["assistant_thread"]["context"]) - def process( # type:ignore[return] + def process( # type: ignore[return] self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse] ) -> Optional[BoltResponse]: if self._thread_context_changed_listeners is None: @@ -276,6 +276,15 @@

      Classes

      if listeners is not None: for listener in listeners: if listener.matches(req=req, resp=resp): + middleware_resp, next_was_not_called = listener.run_middleware(req=req, resp=resp) + if next_was_not_called: + if middleware_resp is not None: + return middleware_resp + # The listener middleware didn't call next(). + # Skip this listener and try the next one. + continue + if middleware_resp is not None: + resp = middleware_resp return listener_runner.run( request=req, response=resp, @@ -295,13 +304,14 @@

      Classes

      middleware: Optional[List[Middleware]] = None, base_logger: Optional[Logger] = None, ) -> Listener: - if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] - listener_or_functions = [listener_or_functions] # type:ignore[list-item] + if isinstance(listener_or_functions, Callable): # type: ignore[arg-type] + listener_or_functions = [listener_or_functions] # type: ignore[list-item] if isinstance(listener_or_functions, Listener): return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] + middleware.insert(0, AttachingConversationKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) @@ -310,7 +320,7 @@

      Classes

      for matcher in matchers: if isinstance(matcher, ListenerMatcher): listener_matchers.append(matcher) - elif isinstance(matcher, Callable): # type:ignore[arg-type] + elif isinstance(matcher, Callable): # type: ignore[arg-type] listener_matchers.append( build_listener_matcher( func=matcher, @@ -389,7 +399,7 @@

      Methods

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -429,13 +439,14 @@

      Methods

      middleware: Optional[List[Middleware]] = None, base_logger: Optional[Logger] = None, ) -> Listener: - if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] - listener_or_functions = [listener_or_functions] # type:ignore[list-item] + if isinstance(listener_or_functions, Callable): # type: ignore[arg-type] + listener_or_functions = [listener_or_functions] # type: ignore[list-item] if isinstance(listener_or_functions, Listener): return listener_or_functions elif isinstance(listener_or_functions, list): middleware = middleware if middleware else [] + middleware.insert(0, AttachingConversationKwargs(self.thread_context_store)) functions = listener_or_functions ack_function = functions.pop(0) @@ -444,7 +455,7 @@

      Methods

      for matcher in matchers: if isinstance(matcher, ListenerMatcher): listener_matchers.append(matcher) - elif isinstance(matcher, Callable): # type:ignore[arg-type] + elif isinstance(matcher, Callable): # type: ignore[arg-type] listener_matchers.append( build_listener_matcher( func=matcher, @@ -490,7 +501,7 @@

      Methods

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -539,7 +550,7 @@

      Methods

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func @@ -588,7 +599,7 @@

      Methods

      self.build_listener( listener_or_functions=func, matchers=all_matchers, - middleware=middleware, # type:ignore[arg-type] + middleware=middleware, # type: ignore[arg-type] ) ) return func diff --git a/docs/reference/middleware/async_builtins.html b/docs/reference/middleware/async_builtins.html index d32deff15..1ddea9222 100644 --- a/docs/reference/middleware/async_builtins.html +++ b/docs/reference/middleware/async_builtins.html @@ -46,6 +46,82 @@

      Module slack_bolt.middleware.async_builtins

      Classes

      +
      +class AsyncAttachingConversationKwargs +(thread_context_store: AsyncAssistantThreadContextStore | None = None) +
      +
      +
      + +Expand source code + +
      class AsyncAttachingConversationKwargs(AsyncMiddleware):
      +
      +    thread_context_store: Optional[AsyncAssistantThreadContextStore]
      +
      +    def __init__(self, thread_context_store: Optional[AsyncAssistantThreadContextStore] = None):
      +        self.thread_context_store = thread_context_store
      +
      +    async def async_process(
      +        self,
      +        *,
      +        req: AsyncBoltRequest,
      +        resp: BoltResponse,
      +        next: Callable[[], Awaitable[BoltResponse]],
      +    ) -> Optional[BoltResponse]:
      +        event = to_event(req.body)
      +        if event is not None:
      +            if is_assistant_event(req.body):
      +                assistant = AsyncAssistantUtilities(
      +                    payload=event,
      +                    context=req.context,
      +                    thread_context_store=self.thread_context_store,
      +                )
      +                req.context["say"] = assistant.say
      +                req.context["set_title"] = assistant.set_title
      +                req.context["set_suggested_prompts"] = assistant.set_suggested_prompts
      +                req.context["get_thread_context"] = assistant.get_thread_context
      +                req.context["save_thread_context"] = assistant.save_thread_context
      +
      +            # TODO: in the future we might want to introduce a "proper" extract_ts utility
      +            thread_ts = req.context.thread_ts or event.get("ts")
      +            if req.context.channel_id and thread_ts:
      +                req.context["set_status"] = AsyncSetStatus(
      +                    client=req.context.client,
      +                    channel_id=req.context.channel_id,
      +                    thread_ts=thread_ts,
      +                )
      +                req.context["say_stream"] = AsyncSayStream(
      +                    client=req.context.client,
      +                    channel=req.context.channel_id,
      +                    recipient_team_id=req.context.team_id or req.context.enterprise_id,
      +                    recipient_user_id=req.context.user_id,
      +                    thread_ts=thread_ts,
      +                )
      +        return await next()
      +
      +

      A middleware can process request data before other middleware and listener functions.

      +

      Ancestors

      + +

      Class variables

      +
      +
      var thread_context_storeAsyncAssistantThreadContextStore | None
      +
      +

      The type of the None singleton.

      +
      +
      +

      Inherited members

      + +
      class AsyncAttachingFunctionToken
      @@ -395,6 +471,12 @@

      Inherited members

    • Classes

      • +

        AsyncAttachingConversationKwargs

        + +
      • +
      • AsyncAttachingFunctionToken

      • diff --git a/docs/reference/middleware/async_middleware.html b/docs/reference/middleware/async_middleware.html index 33b4273e7..f7713b881 100644 --- a/docs/reference/middleware/async_middleware.html +++ b/docs/reference/middleware/async_middleware.html @@ -104,6 +104,7 @@

        Subclasses

        • AsyncAssistant
        • AsyncCustomMiddleware
        • +
        • AsyncAttachingConversationKwargs
        • AsyncAttachingFunctionToken
        • AsyncAuthorization
        • AsyncIgnoringSelfEvents
        • diff --git a/docs/reference/middleware/async_middleware_error_handler.html b/docs/reference/middleware/async_middleware_error_handler.html index e7cd8bb32..bf5b101f6 100644 --- a/docs/reference/middleware/async_middleware_error_handler.html +++ b/docs/reference/middleware/async_middleware_error_handler.html @@ -77,9 +77,10 @@

          Classes

          ) returned_response = await self.func(**kwargs) if returned_response is not None and isinstance(returned_response, BoltResponse): - response.status = returned_response.status # type: ignore[union-attr] - response.headers = returned_response.headers # type: ignore[union-attr] - response.body = returned_response.body # type: ignore[union-attr] + assert response is not None, "response must be provided when returning a BoltResponse from an error handler" + response.status = returned_response.status + response.headers = returned_response.headers + response.body = returned_response.body
    • Ancestors

      diff --git a/docs/reference/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.html b/docs/reference/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.html new file mode 100644 index 000000000..a0f5bdf85 --- /dev/null +++ b/docs/reference/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.html @@ -0,0 +1,155 @@ + + + + + + +slack_bolt.middleware.attaching_conversation_kwargs.async_attaching_conversation_kwargs API documentation + + + + + + + + + + + +
      +
      +
      +

      Module slack_bolt.middleware.attaching_conversation_kwargs.async_attaching_conversation_kwargs

      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +

      Classes

      +
      +
      +class AsyncAttachingConversationKwargs +(thread_context_store: AsyncAssistantThreadContextStore | None = None) +
      +
      +
      + +Expand source code + +
      class AsyncAttachingConversationKwargs(AsyncMiddleware):
      +
      +    thread_context_store: Optional[AsyncAssistantThreadContextStore]
      +
      +    def __init__(self, thread_context_store: Optional[AsyncAssistantThreadContextStore] = None):
      +        self.thread_context_store = thread_context_store
      +
      +    async def async_process(
      +        self,
      +        *,
      +        req: AsyncBoltRequest,
      +        resp: BoltResponse,
      +        next: Callable[[], Awaitable[BoltResponse]],
      +    ) -> Optional[BoltResponse]:
      +        event = to_event(req.body)
      +        if event is not None:
      +            if is_assistant_event(req.body):
      +                assistant = AsyncAssistantUtilities(
      +                    payload=event,
      +                    context=req.context,
      +                    thread_context_store=self.thread_context_store,
      +                )
      +                req.context["say"] = assistant.say
      +                req.context["set_title"] = assistant.set_title
      +                req.context["set_suggested_prompts"] = assistant.set_suggested_prompts
      +                req.context["get_thread_context"] = assistant.get_thread_context
      +                req.context["save_thread_context"] = assistant.save_thread_context
      +
      +            # TODO: in the future we might want to introduce a "proper" extract_ts utility
      +            thread_ts = req.context.thread_ts or event.get("ts")
      +            if req.context.channel_id and thread_ts:
      +                req.context["set_status"] = AsyncSetStatus(
      +                    client=req.context.client,
      +                    channel_id=req.context.channel_id,
      +                    thread_ts=thread_ts,
      +                )
      +                req.context["say_stream"] = AsyncSayStream(
      +                    client=req.context.client,
      +                    channel=req.context.channel_id,
      +                    recipient_team_id=req.context.team_id or req.context.enterprise_id,
      +                    recipient_user_id=req.context.user_id,
      +                    thread_ts=thread_ts,
      +                )
      +        return await next()
      +
      +

      A middleware can process request data before other middleware and listener functions.

      +

      Ancestors

      + +

      Class variables

      +
      +
      var thread_context_storeAsyncAssistantThreadContextStore | None
      +
      +

      The type of the None singleton.

      +
      +
      +

      Inherited members

      + +
      +
      +
      +
      + +
      + + + diff --git a/docs/reference/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.html b/docs/reference/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.html new file mode 100644 index 000000000..8a1911323 --- /dev/null +++ b/docs/reference/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.html @@ -0,0 +1,149 @@ + + + + + + +slack_bolt.middleware.attaching_conversation_kwargs.attaching_conversation_kwargs API documentation + + + + + + + + + + + +
      +
      +
      +

      Module slack_bolt.middleware.attaching_conversation_kwargs.attaching_conversation_kwargs

      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +

      Classes

      +
      +
      +class AttachingConversationKwargs +(thread_context_store: AssistantThreadContextStore | None = None) +
      +
      +
      + +Expand source code + +
      class AttachingConversationKwargs(Middleware):
      +
      +    thread_context_store: Optional[AssistantThreadContextStore]
      +
      +    def __init__(self, thread_context_store: Optional[AssistantThreadContextStore] = None):
      +        self.thread_context_store = thread_context_store
      +
      +    def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse]) -> Optional[BoltResponse]:
      +        event = to_event(req.body)
      +        if event is not None:
      +            if is_assistant_event(req.body):
      +                assistant = AssistantUtilities(
      +                    payload=event,
      +                    context=req.context,
      +                    thread_context_store=self.thread_context_store,
      +                )
      +                req.context["say"] = assistant.say
      +                req.context["set_title"] = assistant.set_title
      +                req.context["set_suggested_prompts"] = assistant.set_suggested_prompts
      +                req.context["get_thread_context"] = assistant.get_thread_context
      +                req.context["save_thread_context"] = assistant.save_thread_context
      +
      +            # TODO: in the future we might want to introduce a "proper" extract_ts utility
      +            thread_ts = req.context.thread_ts or event.get("ts")
      +            if req.context.channel_id and thread_ts:
      +                req.context["set_status"] = SetStatus(
      +                    client=req.context.client,
      +                    channel_id=req.context.channel_id,
      +                    thread_ts=thread_ts,
      +                )
      +                req.context["say_stream"] = SayStream(
      +                    client=req.context.client,
      +                    channel=req.context.channel_id,
      +                    recipient_team_id=req.context.team_id or req.context.enterprise_id,
      +                    recipient_user_id=req.context.user_id,
      +                    thread_ts=thread_ts,
      +                )
      +        return next()
      +
      +

      A middleware can process request data before other middleware and listener functions.

      +

      Ancestors

      + +

      Class variables

      +
      +
      var thread_context_storeAssistantThreadContextStore | None
      +
      +

      The type of the None singleton.

      +
      +
      +

      Inherited members

      + +
      +
      +
      +
      + +
      + + + diff --git a/docs/reference/middleware/attaching_conversation_kwargs/index.html b/docs/reference/middleware/attaching_conversation_kwargs/index.html new file mode 100644 index 000000000..308a52712 --- /dev/null +++ b/docs/reference/middleware/attaching_conversation_kwargs/index.html @@ -0,0 +1,166 @@ + + + + + + +slack_bolt.middleware.attaching_conversation_kwargs API documentation + + + + + + + + + + + +
      +
      +
      +

      Module slack_bolt.middleware.attaching_conversation_kwargs

      +
      +
      +
      +
      +

      Sub-modules

      +
      +
      slack_bolt.middleware.attaching_conversation_kwargs.async_attaching_conversation_kwargs
      +
      +
      +
      +
      slack_bolt.middleware.attaching_conversation_kwargs.attaching_conversation_kwargs
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +

      Classes

      +
      +
      +class AttachingConversationKwargs +(thread_context_store: AssistantThreadContextStore | None = None) +
      +
      +
      + +Expand source code + +
      class AttachingConversationKwargs(Middleware):
      +
      +    thread_context_store: Optional[AssistantThreadContextStore]
      +
      +    def __init__(self, thread_context_store: Optional[AssistantThreadContextStore] = None):
      +        self.thread_context_store = thread_context_store
      +
      +    def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse]) -> Optional[BoltResponse]:
      +        event = to_event(req.body)
      +        if event is not None:
      +            if is_assistant_event(req.body):
      +                assistant = AssistantUtilities(
      +                    payload=event,
      +                    context=req.context,
      +                    thread_context_store=self.thread_context_store,
      +                )
      +                req.context["say"] = assistant.say
      +                req.context["set_title"] = assistant.set_title
      +                req.context["set_suggested_prompts"] = assistant.set_suggested_prompts
      +                req.context["get_thread_context"] = assistant.get_thread_context
      +                req.context["save_thread_context"] = assistant.save_thread_context
      +
      +            # TODO: in the future we might want to introduce a "proper" extract_ts utility
      +            thread_ts = req.context.thread_ts or event.get("ts")
      +            if req.context.channel_id and thread_ts:
      +                req.context["set_status"] = SetStatus(
      +                    client=req.context.client,
      +                    channel_id=req.context.channel_id,
      +                    thread_ts=thread_ts,
      +                )
      +                req.context["say_stream"] = SayStream(
      +                    client=req.context.client,
      +                    channel=req.context.channel_id,
      +                    recipient_team_id=req.context.team_id or req.context.enterprise_id,
      +                    recipient_user_id=req.context.user_id,
      +                    thread_ts=thread_ts,
      +                )
      +        return next()
      +
      +

      A middleware can process request data before other middleware and listener functions.

      +

      Ancestors

      + +

      Class variables

      +
      +
      var thread_context_storeAssistantThreadContextStore | None
      +
      +

      The type of the None singleton.

      +
      +
      +

      Inherited members

      + +
      +
      +
      +
      + +
      + + + diff --git a/docs/reference/middleware/index.html b/docs/reference/middleware/index.html index 05d773415..ce2629224 100644 --- a/docs/reference/middleware/index.html +++ b/docs/reference/middleware/index.html @@ -65,6 +65,10 @@

      Sub-modules

      +
      slack_bolt.middleware.attaching_conversation_kwargs
      +
      +
      +
      slack_bolt.middleware.attaching_function_token
      @@ -114,6 +118,76 @@

      Sub-modules

      Classes

      +
      +class AttachingConversationKwargs +(thread_context_store: AssistantThreadContextStore | None = None) +
      +
      +
      + +Expand source code + +
      class AttachingConversationKwargs(Middleware):
      +
      +    thread_context_store: Optional[AssistantThreadContextStore]
      +
      +    def __init__(self, thread_context_store: Optional[AssistantThreadContextStore] = None):
      +        self.thread_context_store = thread_context_store
      +
      +    def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse]) -> Optional[BoltResponse]:
      +        event = to_event(req.body)
      +        if event is not None:
      +            if is_assistant_event(req.body):
      +                assistant = AssistantUtilities(
      +                    payload=event,
      +                    context=req.context,
      +                    thread_context_store=self.thread_context_store,
      +                )
      +                req.context["say"] = assistant.say
      +                req.context["set_title"] = assistant.set_title
      +                req.context["set_suggested_prompts"] = assistant.set_suggested_prompts
      +                req.context["get_thread_context"] = assistant.get_thread_context
      +                req.context["save_thread_context"] = assistant.save_thread_context
      +
      +            # TODO: in the future we might want to introduce a "proper" extract_ts utility
      +            thread_ts = req.context.thread_ts or event.get("ts")
      +            if req.context.channel_id and thread_ts:
      +                req.context["set_status"] = SetStatus(
      +                    client=req.context.client,
      +                    channel_id=req.context.channel_id,
      +                    thread_ts=thread_ts,
      +                )
      +                req.context["say_stream"] = SayStream(
      +                    client=req.context.client,
      +                    channel=req.context.channel_id,
      +                    recipient_team_id=req.context.team_id or req.context.enterprise_id,
      +                    recipient_user_id=req.context.user_id,
      +                    thread_ts=thread_ts,
      +                )
      +        return next()
      +
      +

      A middleware can process request data before other middleware and listener functions.

      +

      Ancestors

      + +

      Class variables

      +
      +
      var thread_context_storeAssistantThreadContextStore | None
      +
      +

      The type of the None singleton.

      +
      +
      +

      Inherited members

      + +
      class AttachingFunctionToken
      @@ -385,6 +459,7 @@

      Inherited members

      Subclasses

      Ancestors

      diff --git a/docs/reference/oauth/async_callback_options.html b/docs/reference/oauth/async_callback_options.html index 822867ea8..d07f1aee5 100644 --- a/docs/reference/oauth/async_callback_options.html +++ b/docs/reference/oauth/async_callback_options.html @@ -101,7 +101,7 @@

      Class variables

      reason: str, error: Optional[Exception] = None, suggested_status_code: int, - settings: "AsyncOAuthSettings", # type: ignore[name-defined] + settings: "AsyncOAuthSettings", default: "AsyncCallbackOptions", ): """The arguments for a failure function. @@ -153,7 +153,7 @@

      Args

      *, request: AsyncBoltRequest, installation: Installation, - settings: "AsyncOAuthSettings", # type: ignore[name-defined] + settings: "AsyncOAuthSettings", default: "AsyncCallbackOptions", ): """The arguments for a success function. diff --git a/docs/reference/oauth/async_oauth_settings.html b/docs/reference/oauth/async_oauth_settings.html index 3b8c04edb..5e6a543c4 100644 --- a/docs/reference/oauth/async_oauth_settings.html +++ b/docs/reference/oauth/async_oauth_settings.html @@ -48,7 +48,7 @@

      Classes

      class AsyncOAuthSettings -(*,
      client_id: str | None = None,
      client_secret: str | None = None,
      scopes: Sequence[str] | str | None = None,
      user_scopes: Sequence[str] | str | None = None,
      redirect_uri: str | None = None,
      install_path: str = '/slack/install',
      install_page_rendering_enabled: bool = True,
      redirect_uri_path: str = '/slack/oauth_redirect',
      callback_options: AsyncCallbackOptions | None = None,
      success_url: str | None = None,
      failure_url: str | None = None,
      authorization_url: str | None = None,
      installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None = None,
      installation_store_bot_only: bool = False,
      token_rotation_expiration_minutes: int = 120,
      user_token_resolution: str = 'authed_user',
      state_validation_enabled: bool = True,
      state_store: slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore | None = None,
      state_cookie_name: str = 'slack-app-oauth-state',
      state_expiration_seconds: int = 600,
      logger: logging.Logger = <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>)
      +(*,
      client_id: str | None = None,
      client_secret: str | None = None,
      scopes: str | Sequence[str] | None = None,
      user_scopes: str | Sequence[str] | None = None,
      redirect_uri: str | None = None,
      install_path: str = '/slack/install',
      install_page_rendering_enabled: bool = True,
      redirect_uri_path: str = '/slack/oauth_redirect',
      callback_options: AsyncCallbackOptions | None = None,
      success_url: str | None = None,
      failure_url: str | None = None,
      authorization_url: str | None = None,
      installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None = None,
      installation_store_bot_only: bool = False,
      token_rotation_expiration_minutes: int = 120,
      user_token_resolution: str = 'authed_user',
      state_validation_enabled: bool = True,
      state_store: slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore | None = None,
      state_cookie_name: str = 'slack-app-oauth-state',
      state_expiration_seconds: int = 600,
      logger: logging.Logger = <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>)
      diff --git a/docs/reference/oauth/callback_options.html b/docs/reference/oauth/callback_options.html index 7ad3734b3..c6fc81286 100644 --- a/docs/reference/oauth/callback_options.html +++ b/docs/reference/oauth/callback_options.html @@ -181,7 +181,7 @@

      Inherited members

      reason: str, error: Optional[Exception] = None, suggested_status_code: int, - settings: "OAuthSettings", # type: ignore[name-defined] + settings: "OAuthSettings", default: "CallbackOptions", ): """The arguments for a failure function. @@ -233,7 +233,7 @@

      Args

      *, request: BoltRequest, installation: Installation, - settings: "OAuthSettings", # type: ignore[name-defined] + settings: "OAuthSettings", default: "CallbackOptions", ): """The arguments for a success function. diff --git a/docs/reference/oauth/oauth_settings.html b/docs/reference/oauth/oauth_settings.html index cd8def497..1eb2ab7dd 100644 --- a/docs/reference/oauth/oauth_settings.html +++ b/docs/reference/oauth/oauth_settings.html @@ -48,7 +48,7 @@

      Classes

      class OAuthSettings -(*,
      client_id: str | None = None,
      client_secret: str | None = None,
      scopes: Sequence[str] | str | None = None,
      user_scopes: Sequence[str] | str | None = None,
      redirect_uri: str | None = None,
      install_path: str = '/slack/install',
      install_page_rendering_enabled: bool = True,
      redirect_uri_path: str = '/slack/oauth_redirect',
      callback_options: CallbackOptions | None = None,
      success_url: str | None = None,
      failure_url: str | None = None,
      authorization_url: str | None = None,
      installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None = None,
      installation_store_bot_only: bool = False,
      token_rotation_expiration_minutes: int = 120,
      user_token_resolution: str = 'authed_user',
      state_validation_enabled: bool = True,
      state_store: slack_sdk.oauth.state_store.state_store.OAuthStateStore | None = None,
      state_cookie_name: str = 'slack-app-oauth-state',
      state_expiration_seconds: int = 600,
      logger: logging.Logger = <Logger slack_bolt.oauth.oauth_settings (WARNING)>)
      +(*,
      client_id: str | None = None,
      client_secret: str | None = None,
      scopes: str | Sequence[str] | None = None,
      user_scopes: str | Sequence[str] | None = None,
      redirect_uri: str | None = None,
      install_path: str = '/slack/install',
      install_page_rendering_enabled: bool = True,
      redirect_uri_path: str = '/slack/oauth_redirect',
      callback_options: CallbackOptions | None = None,
      success_url: str | None = None,
      failure_url: str | None = None,
      authorization_url: str | None = None,
      installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None = None,
      installation_store_bot_only: bool = False,
      token_rotation_expiration_minutes: int = 120,
      user_token_resolution: str = 'authed_user',
      state_validation_enabled: bool = True,
      state_store: slack_sdk.oauth.state_store.state_store.OAuthStateStore | None = None,
      state_cookie_name: str = 'slack-app-oauth-state',
      state_expiration_seconds: int = 600,
      logger: logging.Logger = <Logger slack_bolt.oauth.oauth_settings (WARNING)>)
      diff --git a/docs/reference/request/internals.html b/docs/reference/request/internals.html index bc13932ec..bd8319183 100644 --- a/docs/reference/request/internals.html +++ b/docs/reference/request/internals.html @@ -268,12 +268,12 @@

      Functions

      return channel.get("id") if "channel_id" in payload: return payload.get("channel_id") - if payload.get("event") is not None: + if isinstance(payload.get("event"), dict): return extract_channel_id(payload["event"]) - if payload.get("item") is not None: + if isinstance(payload.get("item"), dict): # reaction_added: body["event"]["item"] return extract_channel_id(payload["item"]) - if payload.get("assistant_thread") is not None: + if isinstance(payload.get("assistant_thread"), dict): # assistant_thread_started return extract_channel_id(payload["assistant_thread"]) return None @@ -317,10 +317,10 @@

      Functions

      return extract_enterprise_id(payload["authorizations"][0]) if "enterprise_id" in payload: return payload.get("enterprise_id") - if payload.get("team") is not None and "enterprise_id" in payload["team"]: + if isinstance(payload.get("team"), dict) and "enterprise_id" in payload["team"]: # In the case where the type is view_submission return payload["team"].get("enterprise_id") - if payload.get("event") is not None: + if isinstance(payload.get("event"), dict): return extract_enterprise_id(payload["event"]) return None
      @@ -337,7 +337,7 @@

      Functions

      def extract_function_bot_access_token(payload: Dict[str, Any]) -> Optional[str]:
           if payload.get("bot_access_token") is not None:
               return payload.get("bot_access_token")
      -    if payload.get("event") is not None:
      +    if isinstance(payload.get("event"), dict):
               return payload["event"].get("bot_access_token")
           return None
      @@ -354,9 +354,9 @@

      Functions

      def extract_function_execution_id(payload: Dict[str, Any]) -> Optional[str]:
           if payload.get("function_execution_id") is not None:
               return payload.get("function_execution_id")
      -    if payload.get("event") is not None:
      +    if isinstance(payload.get("event"), dict):
               return extract_function_execution_id(payload["event"])
      -    if payload.get("function_data") is not None:
      +    if isinstance(payload.get("function_data"), dict):
               return payload["function_data"].get("execution_id")
           return None
      @@ -371,9 +371,9 @@

      Functions

      Expand source code
      def extract_function_inputs(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
      -    if payload.get("event") is not None:
      +    if isinstance(payload.get("event"), dict):
               return payload["event"].get("inputs")
      -    if payload.get("function_data") is not None:
      +    if isinstance(payload.get("function_data"), dict):
               return payload["function_data"].get("inputs")
           return None
      @@ -408,13 +408,13 @@

      Functions

      Expand source code
      def extract_team_id(payload: Dict[str, Any]) -> Optional[str]:
      -    app_installed_team_id = payload.get("view", {}).get("app_installed_team_id")
      -    if app_installed_team_id is not None:
      +    view = payload.get("view")
      +    if isinstance(view, dict) and view.get("app_installed_team_id") is not None:
               # view_submission payloads can have `view.app_installed_team_id` when a modal view that was opened
               # in a different workspace via some operations inside a Slack Connect channel.
               # Note that the same for enterprise_id does not exist. When you need to know the enterprise_id as well,
               # you have to run some query toward your InstallationStore to know the org where the team_id belongs to.
      -        return app_installed_team_id
      +        return view["app_installed_team_id"]
           if payload.get("team") is not None:
               # With org-wide installations, payload.team in interactivity payloads can be None
               # You need to extract either payload.user.team_id or payload.view.team_id as below
      @@ -429,12 +429,12 @@ 

      Functions

      return extract_team_id(payload["authorizations"][0]) if "team_id" in payload: return payload.get("team_id") - if payload.get("event") is not None: + if isinstance(payload.get("event"), dict): return extract_team_id(payload["event"]) - if payload.get("user") is not None: + if isinstance(payload.get("user"), dict): return payload["user"]["team_id"] - if payload.get("view") is not None: - return payload.get("view", {})["team_id"] + if isinstance(payload.get("view"), dict): + return payload["view"]["team_id"] return None
      @@ -448,30 +448,17 @@

      Functions

      Expand source code
      def extract_thread_ts(payload: Dict[str, Any]) -> Optional[str]:
      -    # This utility initially supports only the use cases for AI assistants, but it may be fine to add more patterns.
      -    # That said, note that thread_ts is always required for assistant threads, but it's not for channels.
      -    # Thus, blindly setting this thread_ts to say utility can break existing apps' behaviors.
      -    if is_assistant_event(payload):
      -        event = payload["event"]
      -        if (
      -            event.get("assistant_thread") is not None
      -            and event["assistant_thread"].get("channel_id") is not None
      -            and event["assistant_thread"].get("thread_ts") is not None
      -        ):
      -            # assistant_thread_started, assistant_thread_context_changed
      -            # "assistant_thread" property can exist for message event without channel_id and thread_ts
      -            # Thus, the above if check verifies these properties exist
      -            return event["assistant_thread"]["thread_ts"]
      -        elif event.get("channel") is not None:
      -            if event.get("thread_ts") is not None:
      -                # message in an assistant thread
      -                return event["thread_ts"]
      -            elif event.get("message", {}).get("thread_ts") is not None:
      -                # message_changed
      -                return event["message"]["thread_ts"]
      -            elif event.get("previous_message", {}).get("thread_ts") is not None:
      -                # message_deleted
      -                return event["previous_message"]["thread_ts"]
      +    thread_ts = payload.get("thread_ts")
      +    if thread_ts is not None:
      +        return thread_ts
      +    if isinstance(payload.get("event"), dict):
      +        return extract_thread_ts(payload["event"])
      +    if isinstance(payload.get("assistant_thread"), dict):
      +        return extract_thread_ts(payload["assistant_thread"])
      +    if isinstance(payload.get("message"), dict):
      +        return extract_thread_ts(payload["message"])
      +    if isinstance(payload.get("previous_message"), dict):
      +        return extract_thread_ts(payload["previous_message"])
           return None
      @@ -493,12 +480,12 @@

      Functions

      return user.get("id") if "user_id" in payload: return payload.get("user_id") - if payload.get("event") is not None: + if isinstance(payload.get("event"), dict): return extract_user_id(payload["event"]) - if payload.get("message") is not None: + if isinstance(payload.get("message"), dict): # message_changed: body["event"]["message"] return extract_user_id(payload["message"]) - if payload.get("previous_message") is not None: + if isinstance(payload.get("previous_message"), dict): # message_deleted: body["event"]["previous_message"] return extract_user_id(payload["previous_message"]) return None diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 9b1349aea..ebda7dafb 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,3 +1,3 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.27.0" +__version__ = "1.28.0" From 7e9b08bf636c7b2193a2fe5da277a5e7f2c5fd8f Mon Sep 17 00:00:00 2001 From: Luke Russell <31357343+lukegalbraithrussell@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:23:22 -0700 Subject: [PATCH 864/865] docs: agent kit (#1478) Co-authored-by: William Bergamin Co-authored-by: Tracy Rericha <108959677+technically-tracy@users.noreply.github.com> Co-authored-by: William Bergamin --- docs/english/_sidebar.json | 21 +- .../english/concepts/adding-agent-features.md | 746 ++++++++++++++++++ docs/english/concepts/message-sending.md | 75 +- ...i-apps.md => using-the-assistant-class.md} | 201 +---- ...{building-an-app.md => creating-an-app.md} | 6 +- docs/english/experiments.md | 4 - docs/english/getting-started.md | 51 +- .../english/tutorial/ai-chatbot/ai-chatbot.md | 134 ++-- docs/japanese/concepts/assistant.md | 227 ------ 9 files changed, 892 insertions(+), 573 deletions(-) create mode 100644 docs/english/concepts/adding-agent-features.md rename docs/english/concepts/{ai-apps.md => using-the-assistant-class.md} (66%) rename docs/english/{building-an-app.md => creating-an-app.md} (99%) delete mode 100644 docs/japanese/concepts/assistant.md diff --git a/docs/english/_sidebar.json b/docs/english/_sidebar.json index eab9d94f8..79721bdcd 100644 --- a/docs/english/_sidebar.json +++ b/docs/english/_sidebar.json @@ -7,7 +7,19 @@ }, "tools/bolt-python/getting-started", { "type": "html", "value": "
      " }, - "tools/bolt-python/building-an-app", + "tools/bolt-python/creating-an-app", + { + "type": "category", + "label": "AI & Agents", + "link": { + "type": "doc", + "id": "tools/bolt-python/concepts/adding-agent-features" + }, + "items": [ + "tools/bolt-python/concepts/adding-agent-features", + "tools/bolt-python/concepts/using-the-assistant-class" + ] + }, { "type": "category", "label": "Slack API calls", @@ -39,7 +51,6 @@ "tools/bolt-python/concepts/app-home" ] }, - "tools/bolt-python/concepts/ai-apps", { "type": "category", "label": "Custom Steps", @@ -85,11 +96,7 @@ "tools/bolt-python/concepts/token-rotation" ] }, - { - "type": "category", - "label": "Experiments", - "items": ["tools/bolt-python/experiments"] - }, + "tools/bolt-python/experiments", { "type": "category", "label": "Legacy", diff --git a/docs/english/concepts/adding-agent-features.md b/docs/english/concepts/adding-agent-features.md new file mode 100644 index 000000000..cbd164630 --- /dev/null +++ b/docs/english/concepts/adding-agent-features.md @@ -0,0 +1,746 @@ +--- +sidebar_label: Adding agent features +--- + +# Adding agent features with Bolt for Python + +:::tip[Check out the Support Agent sample app] +The code snippets throughout this guide are from our [Support Agent sample app](https://github.com/slack-samples/bolt-python-support-agent), Casey, which supports integration with Pydantic, Anthropic, and OpenAI. + +View our [agent quickstart](/ai/agent-quickstart) to get up and running with Casey. Otherwise, read on for exploration and explanation of agent-focused Bolt features found within Casey. +::: + +Your agent can utilize features applicable to messages throughout Slack, like [chat streaming](#text-streaming) and [feedback buttons](#adding-and-handling-feedback). They can also [utilize the `Assistant` class](/tools/bolt-python/concepts/assistant-class) for a side-panel view designed with AI in mind. + +If you're unfamiliar with using these feature within Slack, you may want to read the [API docs on the subject](/ai/). Then come back here to implement them with Bolt! + +--- + +## Slack MCP Server {#slack-mcp-server} + +Casey can harness the [Slack MCP Server](https://docs.slack.dev/ai/slack-mcp-server/developing) when deployed via an HTTP Server with OAuth. + +To enable the Slack MCP Server: + +1. Install [ngrok](https://ngrok.com/download) and start a tunnel: + +```sh +ngrok http 3000 +``` + +2. Copy the `https://*.ngrok-free.app` URL from the ngrok output. + +3. Update `manifest.json` for HTTP mode: + - Set `socket_mode_enabled` to `false` + - Replace `ngrok-free.app` with your ngrok domain (e.g. `YOUR_NGROK_SUBDOMAIN.ngrok-free.app`) + +4. Create a new local dev app: + +```sh +slack install -E local +``` + +5. Enable MCP for your app: + - Run `slack app settings` to open your app's settings + - Navigate to **Agents & AI Apps** in the left-side navigation + - Toggle **Model Context Protocol** on + +6. Update your `.env` OAuth environment variables: + - Run `slack app settings` to open App Settings + - Copy **Client ID**, **Client Secret**, and **Signing Secret** + - Update `SLACK_REDIRECT_URI` in `.env` with your ngrok domain + +```sh +SLACK_CLIENT_ID=YOUR_CLIENT_ID +SLACK_CLIENT_SECRET=YOUR_CLIENT_SECRET +SLACK_REDIRECT_URI=https://YOUR_NGROK_SUBDOMAIN.ngrok-free.app/slack/oauth_redirect +SLACK_SIGNING_SECRET=YOUR_SIGNING_SECRET +``` + +7. Start the app: + +```sh +slack run app_oauth.py +``` + +8. Click the install URL printed in the terminal to install the app to your workspace via OAuth. + +Your agent can now access the Slack MCP server! + +--- + +## Listening for user invocation + +Agents can be invoked throughout Slack, such as via @mentions in channels, messaging the agent, and using the assistant side panel. + + + + +```python +import re +from logging import Logger + +from agents import Runner +from slack_bolt import BoltContext, Say, SayStream, SetStatus +from slack_sdk import WebClient + +from agent import CaseyDeps, casey_agent +from thread_context import conversation_store +from listeners.views.feedback_builder import build_feedback_blocks + + +def handle_app_mentioned( + client: WebClient, + context: BoltContext, + event: dict, + logger: Logger, + say: Say, + say_stream: SayStream, + set_status: SetStatus, +): + """Handle @Casey mentions in channels.""" + try: + channel_id = context.channel_id + text = event.get("text", "") + thread_ts = event.get("thread_ts") or event["ts"] + user_id = context.user_id + + # Strip the bot mention from the text + cleaned_text = re.sub(r"<@[A-Z0-9]+>", "", text).strip() + + if not cleaned_text: + say( + text="Hey there! How can I help you? Describe your IT issue and I'll do my best to assist.", + thread_ts=thread_ts, + ) + return + + # Add eyes reaction only to the first message (not threaded replies) + if not event.get("thread_ts"): + client.reactions_add( + channel=channel_id, + timestamp=event["ts"], + name="eyes", + ) + ... +``` + + + + +```python +from logging import Logger + +from slack_bolt.context import BoltContext +from slack_bolt.context.say import Say +from slack_bolt.context.say_stream import SayStream +from slack_bolt.context.set_status import SetStatus +from slack_sdk import WebClient + +from agent import CaseyDeps, run_casey_agent +from thread_context import session_store +from listeners.views.feedback_builder import build_feedback_blocks + + +def handle_message( + client: WebClient, + context: BoltContext, + event: dict, + logger: Logger, + say: Say, + say_stream: SayStream, + set_status: SetStatus, +): + """Handle messages sent to Casey via DM or in threads the bot is part of.""" + # Issue submissions are posted by the bot with metadata so the message + # handler can run the agent on behalf of the original user. + is_issue_submission = ( + event.get("metadata", {}).get("event_type") == "issue_submission" + ) + + # Skip message subtypes (edits, deletes, etc.) and bot messages that + # are not issue submissions. + if event.get("subtype"): + return + if event.get("bot_id") and not is_issue_submission: + return + + is_dm = event.get("channel_type") == "im" + is_thread_reply = event.get("thread_ts") is not None + + if is_dm: + pass + elif is_thread_reply: + # Channel thread replies are handled only if the bot is already engaged + session = session_store.get_session(context.channel_id, event["thread_ts"]) + if session is None: + return + else: + # Top-level channel messages are handled by app_mentioned + return + + try: + channel_id = context.channel_id + text = event.get("text", "") + thread_ts = event.get("thread_ts") or event["ts"] + + # Get session ID for conversation context + existing_session_id = session_store.get_session(channel_id, thread_ts) + + # Add eyes reaction only to the first message (DMs only — channel + # threads already have the reaction from the initial app_mention) + if is_dm and not existing_session_id: + await client.reactions_add( + channel=channel_id, + timestamp=event["ts"], + name="eyes", + ) + + ... +``` + + + + + +:::tip[Using the Assistant side panel] +The Assistant side panel requires additional setup. See the [Assistant class guide](/tools/bolt-python/concepts/assistant-class). +::: + + +```py +from logging import Logger + +from slack_bolt.context.set_suggested_prompts import SetSuggestedPrompts + +SUGGESTED_PROMPTS = [ + {"title": "Reset Password", "message": "I need to reset my password"}, + {"title": "Request Access", "message": "I need access to a system or tool"}, + {"title": "Network Issues", "message": "I'm having network connectivity issues"}, +] + + +def handle_assistant_thread_started( + set_suggested_prompts: SetSuggestedPrompts, logger: Logger +): + """Handle assistant thread started events by setting suggested prompts.""" + try: + set_suggested_prompts( + prompts=SUGGESTED_PROMPTS, + title="How can I help you today?", + ) + except Exception as e: + logger.exception(f"Failed to handle assistant thread started: {e}") +``` + + + + +--- + +## Setting status {#setting-assistant-status} + +Your app can show actions are happening behind the scenes by setting its thread status. + +```python +def handle_app_mentioned( + set_status: SetStatus, + ... +): + set_status( + status="Thinking...", + loading_messages=[ + "Teaching the hamsters to type faster…", + "Untangling the internet cables…", + "Consulting the office goldfish…", + "Polishing up the response just for you…", + "Convincing the AI to stop overthinking…", + ], + ) +``` + +--- + +## Streaming messages {#text-streaming} + +You can have your app's messages stream in to replicate conventional agent behavior. Bolt for Python provides a `say_stream` utility as a listener argument available for `app.event` and `app.message` listeners. + +The `say_stream` utility streamlines calling the Python Slack SDK's [`WebClient.chat_stream`](https://docs.slack.dev/tools/python-slack-sdk/reference/web/client.html#slack_sdk.web.client.WebClient.chat_stream) helper utility by sourcing parameter values from the relevant event payload. + +| Parameter | Value | +|---|---| +| `channel_id` | Sourced from the event payload. +| `thread_ts` | Sourced from the event payload. Falls back to the `ts` value if available. +| `recipient_team_id` | Sourced from the event `team_id` (`enterprise_id` if the app is installed on an org). +| `recipient_user_id` | Sourced from the `user_id` of the event. + +If neither a `channel_id` or `thread_ts` can be sourced, then the utility will be `None`. + +```python +streamer = say_stream() +streamer.append(markdown_text="Here's my response...") +streamer.append(markdown_text="And here's more...") +streamer.stop() +``` + +--- + +## Adding and handling feedback {#adding-and-handling-feedback} + +You can use the [feedback buttons block element](/reference/block-kit/block-elements/feedback-buttons-element/) to allow users to immediately provide feedback regarding the app's responses. Here's what the feedback buttons look like from the Support Agent sample app: + +```py title=".../listeners/views/feedback_builder.py" +from slack_sdk.models.blocks import ( + Block, + ContextActionsBlock, + FeedbackButtonObject, + FeedbackButtonsElement, +) + + +def build_feedback_blocks() -> list[Block]: + """Build feedback blocks with thumbs up/down buttons.""" + return [ + ContextActionsBlock( + elements=[ + FeedbackButtonsElement( + action_id="feedback", + positive_button=FeedbackButtonObject( + text="Good Response", + accessibility_label="Submit positive feedback on this response", + value="good-feedback", + ), + negative_button=FeedbackButtonObject( + text="Bad Response", + accessibility_label="Submit negative feedback on this response", + value="bad-feedback", + ), + ) + ] + ) + ] +``` + +That feedback block is then rendered at the bottom of your app's message via the `say_stream` utility. + +```py +... + # Stream response in thread with feedback buttons + streamer = say_stream() + streamer.append(markdown_text=result.output) + feedback_blocks = build_feedback_blocks() + streamer.stop(blocks=feedback_blocks) +... +``` + +You can also add a response for when the user provides feedback. + +```python title="...listeners/actions/feedback_button.py" +from logging import Logger + +from slack_bolt import Ack, BoltContext +from slack_sdk import WebClient + + +def handle_feedback_button( + ack: Ack, body: dict, client: WebClient, context: BoltContext, logger: Logger +): + """Handle thumbs up/down feedback on Casey's responses.""" + ack() + + try: + channel_id = context.channel_id + user_id = context.user_id + message_ts = body["message"]["ts"] + feedback_value = body["actions"][0]["value"] + + if feedback_value == "good-feedback": + client.chat_postEphemeral( + channel=channel_id, + user=user_id, + thread_ts=message_ts, + text="Glad that was helpful! :tada:", + ) + else: + client.chat_postEphemeral( + channel=channel_id, + user=user_id, + thread_ts=message_ts, + text="Sorry that wasn't helpful. :slightly_frowning_face: Try rephrasing your question or I can create a support ticket for you.", + ) + + logger.debug( + f"Feedback received: value={feedback_value}, message_ts={message_ts}" + ) + except Exception as e: + logger.exception(f"Failed to handle feedback: {e}") +``` + +--- + +## Full example + +Putting all those concepts together results in a dynamic agent ready to helpfully respond. + + +
      +Full example + + + +```python title="app_mentioned.py" +import re +from logging import Logger + +from slack_bolt import BoltContext, Say, SayStream, SetStatus +from slack_sdk import WebClient + +from agent import CaseyDeps, casey_agent, get_model +from thread_context import conversation_store +from listeners.views.feedback_builder import build_feedback_blocks + + +def handle_app_mentioned( + client: WebClient, + context: BoltContext, + event: dict, + logger: Logger, + say: Say, + say_stream: SayStream, + set_status: SetStatus, +): + """Handle @Casey mentions in channels.""" + try: + channel_id = context.channel_id + text = event.get("text", "") + thread_ts = event.get("thread_ts") or event["ts"] + user_id = context.user_id + + # Strip the bot mention from the text + cleaned_text = re.sub(r"<@[A-Z0-9]+>", "", text).strip() + + if not cleaned_text: + say( + text="Hey there! How can I help you? Describe your IT issue and I'll do my best to assist.", + thread_ts=thread_ts, + ) + return + + # Add eyes reaction only to the first message (not threaded replies) + if not event.get("thread_ts"): + client.reactions_add( + channel=channel_id, + timestamp=event["ts"], + name="eyes", + ) + + # Set assistant thread status with loading messages + set_status( + status="Thinking...", + loading_messages=[ + "Teaching the hamsters to type faster…", + "Untangling the internet cables…", + "Consulting the office goldfish…", + "Polishing up the response just for you…", + "Convincing the AI to stop overthinking…", + ], + ) + + # Get conversation history + history = conversation_store.get_history(channel_id, thread_ts) + + # Run the agent + deps = CaseyDeps( + client=client, + user_id=user_id, + channel_id=channel_id, + thread_ts=thread_ts, + message_ts=event["ts"], + ) + result = casey_agent.run_sync( + cleaned_text, + model=get_model(), + deps=deps, + message_history=history, + ) + + # Stream response in thread with feedback buttons + streamer = say_stream() + streamer.append(markdown_text=result.output) + feedback_blocks = build_feedback_blocks() + streamer.stop(blocks=feedback_blocks) + + # Store conversation history + conversation_store.set_history(channel_id, thread_ts, result.all_messages()) + + except Exception as e: + logger.exception(f"Failed to handle app mention: {e}") + say( + text=f":warning: Something went wrong! ({e})", + thread_ts=event.get("thread_ts") or event["ts"], + ) +``` + + + + +```python title="app_mentioned.py" +import re +from logging import Logger + +from slack_bolt.context import BoltContext +from slack_bolt.context.say import Say +from slack_bolt.context.say_stream import SayStream +from slack_bolt.context.set_status import SetStatus +from slack_sdk import WebClient + +from agent import CaseyDeps, run_casey_agent +from thread_context import session_store +from listeners.views.feedback_builder import build_feedback_blocks + + +def handle_app_mentioned( + client: WebClient, + context: BoltContext, + event: dict, + logger: Logger, + say: Say, + say_stream: SayStream, + set_status: SetStatus, +): + """Handle @Casey mentions in channels.""" + try: + channel_id = context.channel_id + text = event.get("text", "") + thread_ts = event.get("thread_ts") or event["ts"] + + # Strip the bot mention from the text + cleaned_text = re.sub(r"<@[A-Z0-9]+>", "", text).strip() + + if not cleaned_text: + say( + text="Hey there! How can I help you? Describe your IT issue and I'll do my best to assist.", + thread_ts=thread_ts, + ) + return + + # Add eyes reaction only to the first message (not threaded replies) + if not event.get("thread_ts"): + client.reactions_add( + channel=channel_id, + timestamp=event["ts"], + name="eyes", + ) + + # Set assistant thread status with loading messages + set_status( + status="Thinking...", + loading_messages=[ + "Teaching the hamsters to type faster…", + "Untangling the internet cables…", + "Consulting the office goldfish…", + "Polishing up the response just for you…", + "Convincing the AI to stop overthinking…", + ], + ) + + # Get session ID for conversation context + existing_session_id = session_store.get_session(channel_id, thread_ts) + + # Run the agent with deps for tool access + deps = CaseyDeps( + client=client, + user_id=context.user_id, + channel_id=channel_id, + thread_ts=thread_ts, + message_ts=event["ts"], + ) + response_text, new_session_id = run_casey_agent( + cleaned_text, session_id=existing_session_id, deps=deps + ) + + # Stream response in thread with feedback buttons + streamer = say_stream() + streamer.append(markdown_text=response_text) + feedback_blocks = build_feedback_blocks() + streamer.stop(blocks=feedback_blocks) + + # Store session ID for future context + if new_session_id: + session_store.set_session(channel_id, thread_ts, new_session_id) + + except Exception as e: + logger.exception(f"Failed to handle app mention: {e}") + await say( + text=f":warning: Something went wrong! ({e})", + thread_ts=event.get("thread_ts") or event["ts"], + ) +``` + + + +```python title="app_mentioned.py" +import re +from logging import Logger + +from agents import Runner +from slack_bolt import BoltContext, Say, SayStream, SetStatus +from slack_sdk import WebClient + +from agent import CaseyDeps, casey_agent +from thread_context import conversation_store +from listeners.views.feedback_builder import build_feedback_blocks + + +def handle_app_mentioned( + client: WebClient, + context: BoltContext, + event: dict, + logger: Logger, + say: Say, + say_stream: SayStream, + set_status: SetStatus, +): + """Handle @Casey mentions in channels.""" + try: + channel_id = context.channel_id + text = event.get("text", "") + thread_ts = event.get("thread_ts") or event["ts"] + user_id = context.user_id + + # Strip the bot mention from the text + cleaned_text = re.sub(r"<@[A-Z0-9]+>", "", text).strip() + + if not cleaned_text: + say( + text="Hey there! How can I help you? Describe your IT issue and I'll do my best to assist.", + thread_ts=thread_ts, + ) + return + + # Add eyes reaction only to the first message (not threaded replies) + if not event.get("thread_ts"): + client.reactions_add( + channel=channel_id, + timestamp=event["ts"], + name="eyes", + ) + + # Set assistant thread status with loading messages + set_status( + status="Thinking...", + loading_messages=[ + "Teaching the hamsters to type faster…", + "Untangling the internet cables…", + "Consulting the office goldfish…", + "Polishing up the response just for you…", + "Convincing the AI to stop overthinking…", + ], + ) + + # Get conversation history + history = conversation_store.get_history(channel_id, thread_ts) + + # Build input for the agent + if history: + input_items = history + [{"role": "user", "content": cleaned_text}] + else: + input_items = cleaned_text + + # Run the agent + deps = CaseyDeps( + client=client, + user_id=user_id, + channel_id=channel_id, + thread_ts=thread_ts, + message_ts=event["ts"], + ) + result = Runner.run_sync(casey_agent, input=input_items, context=deps) + + # Stream response in thread with feedback buttons + streamer = say_stream() + streamer.append(markdown_text=result.final_output) + feedback_blocks = build_feedback_blocks() + streamer.stop(blocks=feedback_blocks) + + # Store conversation history + conversation_store.set_history(channel_id, thread_ts, result.to_input_list()) + + except Exception as e: + logger.exception(f"Failed to handle app mention: {e}") + say( + text=f":warning: Something went wrong! ({e})", + thread_ts=event.get("thread_ts") or event["ts"], + ) +``` + + + +
      + +--- + +## Onward: adding custom tools + +Casey comes with test tools and simulated systems. You can extend it with custom tools to make it a fully functioning Slack agent. + +In this example, we'll add a tool that makes live calls to check the GitHub status. + +1. Create `agent/tools/{tool-name}.py` and define the tool with the `@tool` decorator: + +```python title="agent/tools/check_github_status.py" +from claude_agent_sdk import tool +import httpx + +@tool( + name="check_github_status", + description="Check GitHub's current operational status", + input_schema={}, +) +async def check_github_status_tool(args): + """Check if GitHub is operational.""" + async with httpx.AsyncClient() as client: + response = await client.get("https://www.githubstatus.com/api/v2/status.json") + data = response.json() + status = data["status"]["indicator"] + description = data["status"]["description"] + + return { + "content": [ + { + "type": "text", + "text": f"**GitHub Status** — {status}\n{description}", + } + ] + } +``` + +2. Import the tool in `agent/casey.py`: + +```python title="agent/casey.py" +from agent.tools import check_github_status_tool +``` + +3. Register in `casey_tools_server`: + +```python title="agent/casey.py" +casey_tools_server = create_sdk_mcp_server( + name="casey-tools", + version="1.0.0", + tools=[ + check_github_status_tool, # Add here + # ... other tools + ], +) +``` + +4. Add to `CASEY_TOOLS`: + +```python title="agent/casey.py" +CASEY_TOOLS = [ + "check_github_status", # Add here + # ... other tools +] +``` + +Use this example as a jumping off point for building out an agent with the capabilities you need! \ No newline at end of file diff --git a/docs/english/concepts/message-sending.md b/docs/english/concepts/message-sending.md index 87c433129..090503ff2 100644 --- a/docs/english/concepts/message-sending.md +++ b/docs/english/concepts/message-sending.md @@ -43,37 +43,58 @@ def show_datepicker(event, say): ## Streaming messages {#streaming-messages} -You can have your app's messages stream in to replicate conventional AI chatbot behavior. This is done through three Web API methods: +You can have your app's messages stream in to replicate conventional agent behavior. Bolt for Python provides a `say_stream` utility as a listener argument available for `app.event` and `app.message` listeners. -* [`chat_startStream`](/reference/methods/chat.startStream) -* [`chat_appendStream`](/reference/methods/chat.appendStream) -* [`chat_stopStream`](/reference/methods/chat.stopStream) +The `say_stream` utility streamlines calling the Python Slack SDK's [`WebClient.chat_stream`](https://docs.slack.dev/tools/python-slack-sdk/reference/web/client.html#slack_sdk.web.client.WebClient.chat_stream) helper utility by sourcing parameter values from the relevant event payload. -The Python Slack SDK provides a [`chat_stream()`](https://docs.slack.dev/tools/python-slack-sdk/reference/web/client.html#slack_sdk.web.client.WebClient.chat_stream) helper utility to streamline calling these methods. Here's an excerpt from our [Assistant template app](https://github.com/slack-samples/bolt-python-assistant-template): +| Parameter | Value | +|---|---| +| `channel_id` | Sourced from the event payload. +| `thread_ts` | Sourced from the event payload. Falls back to the `ts` value if available. +| `recipient_team_id` | Sourced from the event `team_id` (`enterprise_id` if the app is installed on an org). +| `recipient_user_id` | Sourced from the `user_id` of the event. -```python -streamer = client.chat_stream( - channel=channel_id, - recipient_team_id=team_id, - recipient_user_id=user_id, - thread_ts=thread_ts, -) - -# Loop over OpenAI response stream -# https://platform.openai.com/docs/api-reference/responses/create -for event in returned_message: - if event.type == "response.output_text.delta": - streamer.append(markdown_text=f"{event.delta}") - else: - continue - -feedback_block = create_feedback_block() -streamer.stop(blocks=feedback_block) +If neither a `channel_id` or `thread_ts` can be sourced, then the utility will be `None`. + +For information on calling the `chat_*Stream` API methods directly, see the [_Sending streaming messages_](/tools/python-slack-sdk/web#sending-streaming-messages) section of the Python Slack SDK docs. + +### Example {#example} + +```py +import os + +from slack_bolt import App, SayStream +from slack_bolt.adapter.socket_mode import SocketModeHandler +from slack_sdk import WebClient + +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) + +@app.event("app_mention") +def handle_app_mention(client: WebClient, say_stream: SayStream): + stream = say_stream() + stream.append(markdown_text="Someone rang the bat signal!") + stream.stop() + +@app.message("") +def handle_message(client: WebClient, say_stream: SayStream): + stream = say_stream() + + stream.append(markdown_text="Let me consult my *vast knowledge database*...) + stream.stop() + +if __name__ == "__main__": + SocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")).start() ``` -In that example, a [feedback buttons](/reference/block-kit/block-elements/feedback-buttons-element) block element is passed to `streamer.stop` to provide feedback buttons to the user at the bottom of the message. Interaction with these buttons will send a block action event to your app to receive the feedback. +#### Adding feedback buttons after a stream -```python +You can pass a [feedback buttons](/reference/block-kit/block-elements/feedback-buttons-element) block element to `stream.stop` to provide feedback buttons to the user at the bottom of the message. Interaction with these buttons will send a block action event to your app to receive the feedback. + +```py +stream.stop(blocks=feedback_block) +``` + +```py def create_feedback_block() -> List[Block]: blocks: List[Block] = [ ContextActionsBlock( @@ -95,6 +116,4 @@ def create_feedback_block() -> List[Block]: ) ] return blocks -``` - -For information on calling the `chat_*Stream` API methods without the helper utility, see the [_Sending streaming messages_](/tools/python-slack-sdk/web#sending-streaming-messages) section of the Python Slack SDK docs. \ No newline at end of file +``` \ No newline at end of file diff --git a/docs/english/concepts/ai-apps.md b/docs/english/concepts/using-the-assistant-class.md similarity index 66% rename from docs/english/concepts/ai-apps.md rename to docs/english/concepts/using-the-assistant-class.md index 3b057bc7e..ed004dc35 100644 --- a/docs/english/concepts/ai-apps.md +++ b/docs/english/concepts/using-the-assistant-class.md @@ -1,17 +1,10 @@ - -# Using AI in Apps {#using-ai-in-apps} - -The Slack platform offers features tailored for AI agents and assistants. Your apps can [utilize the `Assistant` class](#assistant) for a side-panel view designed with AI in mind, or they can utilize features applicable to messages throughout Slack, like [chat streaming](#text-streaming) and [feedback buttons](#adding-and-handling-feedback). - -If you're unfamiliar with using these feature within Slack, you may want to read the [API documentation on the subject](/ai/). Then come back here to implement them with Bolt! - -## The `Assistant` class instance {#assistant} +# Using the Assistant class :::info[Some features within this guide require a paid plan] If you don't have a paid workspace for development, you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. ::: -The [`Assistant`](/tools/bolt-js/reference#the-assistantconfig-configuration-object) class can be used to handle the incoming events expected from a user interacting with an app in Slack that has the Agents & AI Apps feature enabled. +The `Assistant` class can be used to handle the incoming events expected from a user interacting with an app in Slack that has the Agents & AI Apps feature enabled. A typical flow would look like: @@ -63,7 +56,7 @@ If you do provide your own `threadContextStore` property, it must feature `get` :::tip[Refer to the [reference docs](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments.] ::: -### Configuring your app to support the `Assistant` class {#configuring-assistant-class} +## Configuring your app to support the `Assistant` class {#configuring-assistant-class} 1. Within [App Settings](https://api.slack.com/apps), enable the **Agents & AI Apps** feature. @@ -77,7 +70,7 @@ If you do provide your own `threadContextStore` property, it must feature `get` * [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) * [`message.im`](/reference/events/message.im) -### Handling a new thread {#handling-new-thread} +## Handling a new thread {#handling-new-thread} When the user opens a new thread with your AI-enabled app, the [`assistant_thread_started`](/reference/events/assistant_thread_started) event will be sent to your app. @@ -122,7 +115,7 @@ def start_assistant_thread( You can send more complex messages to the user — see [Sending Block Kit alongside messages](#block-kit-interactions) for more info. -### Handling thread context changes {#handling-thread-context-changes} +## Handling thread context changes {#handling-thread-context-changes} When the user switches channels, the [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) event will be sent to your app. @@ -137,7 +130,7 @@ from slack_bolt import FileAssistantThreadContextStore assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) ``` -### Handling the user response {#handling-user-response} +## Handling the user response {#handling-user-response} When the user messages your app, the [`message.im`](/reference/events/message.im) event will be sent to your app. @@ -205,7 +198,7 @@ def respond_in_assistant_thread( app.use(assistant) ``` -### Sending Block Kit alongside messages {#block-kit-interactions} +## Sending Block Kit alongside messages {#block-kit-interactions} For advanced use cases, Block Kit buttons may be used instead of suggested prompts, as well as the sending of messages with structured [metadata](/messaging/message-metadata/) to trigger subsequent interactions with the user. @@ -331,182 +324,6 @@ def respond_to_bot_messages(logger: logging.Logger, set_status: SetStatus, say: ... ``` -See the [_Adding and handling feedback_](#adding-and-handling-feedback) section for adding feedback buttons with Block Kit. - -## Text streaming in messages {#text-streaming} - -Three Web API methods work together to provide users a text streaming experience: - -* the [`chat.startStream`](/reference/methods/chat.startStream) method starts the text stream, -* the [`chat.appendStream`](/reference/methods/chat.appendStream) method appends text to the stream, and -* the [`chat.stopStream`](/reference/methods/chat.stopStream) method stops it. - -Since you're using Bolt for Python, built upon the Python Slack SDK, you can use the [`chat_stream()`](https://docs.slack.dev/tools/python-slack-sdk/reference/web/client.html#slack_sdk.web.client.WebClient.chat_stream) utility to streamline all three aspects of streaming in your app's messages. - -The following example uses OpenAI's streaming API with the new `chat_stream()` functionality, but you can substitute it with the AI client of your choice. - - -```python -import os -from typing import List, Dict - -import openai -from openai import Stream -from openai.types.responses import ResponseStreamEvent - -DEFAULT_SYSTEM_CONTENT = """ -You're an assistant in a Slack workspace. -Users in the workspace will ask you to help them write something or to think better about a specific topic. -You'll respond to those questions in a professional way. -When you include markdown text, convert them to Slack compatible ones. -When a prompt has Slack's special syntax like <@USER_ID> or <#CHANNEL_ID>, you must keep them as-is in your response. -""" - -def call_llm( - messages_in_thread: List[Dict[str, str]], - system_content: str = DEFAULT_SYSTEM_CONTENT, -) -> Stream[ResponseStreamEvent]: - openai_client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY")) - messages = [{"role": "system", "content": system_content}] - messages.extend(messages_in_thread) - response = openai_client.responses.create(model="gpt-4o-mini", input=messages, stream=True) - return response - -@assistant.user_message -def respond_in_assistant_thread( - ... -): - try: - ... - replies = client.conversations_replies( - channel=context.channel_id, - ts=context.thread_ts, - oldest=context.thread_ts, - limit=10, - ) - messages_in_thread: List[Dict[str, str]] = [] - for message in replies["messages"]: - role = "user" if message.get("bot_id") is None else "assistant" - messages_in_thread.append({"role": role, "content": message["text"]}) - - returned_message = call_llm(messages_in_thread) - - streamer = client.chat_stream( - channel=channel_id, - recipient_team_id=team_id, - recipient_user_id=user_id, - thread_ts=thread_ts, - ) - - # Loop over OpenAI response stream - # https://platform.openai.com/docs/api-reference/responses/create - for event in returned_message: - if event.type == "response.output_text.delta": - streamer.append(markdown_text=f"{event.delta}") - else: - continue - - streamer.stop() - - except Exception as e: - logger.exception(f"Failed to handle a user message event: {e}") - say(f":warning: Something went wrong! ({e})") -``` - -## Adding and handling feedback {#adding-and-handling-feedback} - -Use the [feedback buttons block element](/reference/block-kit/block-elements/feedback-buttons-element/) to allow users to immediately provide feedback regarding your app's responses. Here's a quick example: - -```py -from typing import List -from slack_sdk.models.blocks import Block, ContextActionsBlock, FeedbackButtonsElement, FeedbackButtonObject - - -def create_feedback_block() -> List[Block]: - """ - Create feedback block with thumbs up/down buttons - - Returns: - Block Kit context_actions block - """ - blocks: List[Block] = [ - ContextActionsBlock( - elements=[ - FeedbackButtonsElement( - action_id="feedback", - positive_button=FeedbackButtonObject( - text="Good Response", - accessibility_label="Submit positive feedback on this response", - value="good-feedback", - ), - negative_button=FeedbackButtonObject( - text="Bad Response", - accessibility_label="Submit negative feedback on this response", - value="bad-feedback", - ), - ) - ] - ) - ] - return blocks -``` - -Use the `chat_stream` utility to render the feedback block at the bottom of your app's message. - -```js -... - streamer = client.chat_stream( - channel=channel_id, - recipient_team_id=team_id, - recipient_user_id=user_id, - thread_ts=thread_ts, - ) - - # Loop over OpenAI response stream - # https://platform.openai.com/docs/api-reference/responses/create - for event in returned_message: - if event.type == "response.output_text.delta": - streamer.append(markdown_text=f"{event.delta}") - else: - continue - - feedback_block = create_feedback_block() - streamer.stop(blocks=feedback_block) -... -``` - -Then add a response for when the user provides feedback. - -```python -# Handle feedback buttons (thumbs up/down) -def handle_feedback(ack, body, client, logger: logging.Logger): - try: - ack() - message_ts = body["message"]["ts"] - channel_id = body["channel"]["id"] - feedback_type = body["actions"][0]["value"] - is_positive = feedback_type == "good-feedback" - - if is_positive: - client.chat_postEphemeral( - channel=channel_id, - user=body["user"]["id"], - thread_ts=message_ts, - text="We're glad you found this useful.", - ) - else: - client.chat_postEphemeral( - channel=channel_id, - user=body["user"]["id"], - thread_ts=message_ts, - text="Sorry to hear that response wasn't up to par :slightly_frowning_face: Starting a new chat may help with AI mistakes and hallucinations.", - ) - - logger.debug(f"Handled feedback: type={feedback_type}, message_ts={message_ts}") - except Exception as error: - logger.error(f":warning: Something went wrong! {error}") -``` - -## Full example: App Agent Template {#app-agent-template} +See the [_Creating agents: adding and handling feedback_](/tools/bolt-python/concepts/adding-agent-features#adding-and-handling-feedback) section for adding feedback buttons with Block Kit. -Want to see the functionality described throughout this guide in action? We've created a [App Agent Template](https://github.com/slack-samples/bolt-python-assistant-template) repo for you to build off of. +Want to see the functionality described throughout this guide in action? We've created a [App Agent Template](https://github.com/slack-samples/bolt-python-assistant-template) repo for you to build from. \ No newline at end of file diff --git a/docs/english/building-an-app.md b/docs/english/creating-an-app.md similarity index 99% rename from docs/english/building-an-app.md rename to docs/english/creating-an-app.md index bde340961..7f06e9d42 100644 --- a/docs/english/building-an-app.md +++ b/docs/english/creating-an-app.md @@ -1,8 +1,8 @@ --- -sidebar_label: Building an App +sidebar_label: Creating an app --- -# Building an App with Bolt for Python +# Creating an app with Bolt for Python This guide is meant to walk you through getting up and running with a Slack app using Bolt for Python. Along the way, we’ll create a new Slack app, set up your local environment, and develop an app that listens and responds to messages from a Slack workspace. @@ -10,7 +10,7 @@ When you're finished, you'll have created the [Getting Started app](https://gith --- -### Create an app {#create-an-app} +### Create a new app {#create-an-app} First thing's first: before you start developing with Bolt, you'll want to [create a Slack app](https://api.slack.com/apps/new). :::tip[A place to test and learn] diff --git a/docs/english/experiments.md b/docs/english/experiments.md index 681c8cbc6..13adf0a32 100644 --- a/docs/english/experiments.md +++ b/docs/english/experiments.md @@ -28,7 +28,3 @@ def handle_mention(agent: BoltAgent): stream.append(markdown_text="Hello!") stream.stop() ``` - -### Limitations - -The `chat_stream()` method currently only works when the `thread_ts` field is available in the event context (DMs and threaded replies). Top-level channel messages do not have a `thread_ts` field, and the `ts` field is not yet provided to `BoltAgent`. \ No newline at end of file diff --git a/docs/english/getting-started.md b/docs/english/getting-started.md index 934dd3bae..6964df23b 100644 --- a/docs/english/getting-started.md +++ b/docs/english/getting-started.md @@ -279,55 +279,10 @@ This will open the following page: On these pages you're free to make changes such as updating your app icon, configuring app features, and perhaps even distributing your app! -## Adding AI features {#ai-features} - -Now that you're familiar with a basic app setup, try it out again, this time using the AI agent template! - - - - -Get started with the agent template: - -```sh -$ slack create ai-app --template slack-samples/bolt-python-assistant-template -$ cd ai-app -``` - - - - -Get started with the agent template: - -```sh -$ git clone https://github.com/slack-samples/bolt-python-assistant-template ai-app -$ cd ai-app -``` - -Using this method, be sure to set the app and bot tokens as we did in the [Running the app](#running-the-app) section above. - - - - -Once the project is created, update the `.env.sample` file by setting the `OPENAI_API_KEY` with the value of your key and removing the `.sample` from the file name. - -In the `ai` folder of this app, you'll find default instructions for the LLM and an OpenAI client setup. - -The `listeners` include utilities intended for messaging with an LLM. Those are outlined in detail in the guide to [Using AI in apps](/tools/bolt-python/concepts/ai-apps) and [Sending messages](/tools/bolt-python/concepts/message-sending). - ## Next steps {#next-steps} -Congrats once more on getting up and running with this quick start. - -:::info[Dive deeper] - -Follow along with the steps that went into making this app on the [building an app](/tools/bolt-python/building-an-app) guide for an educational overview. - -::: - You can now continue customizing your app with various features to make it right for whatever job's at hand. Here are some ideas about what to explore next: -- Explore the different events your bot can listen to with the [`app.event()`](/tools/bolt-python/concepts/event-listening) method. See the full events reference [here](/reference/events). -- Bolt allows you to call [Web API](/tools/bolt-python/concepts/web-api) methods with the client attached to your app. There are [over 200 methods](/reference/methods) available. -- Learn more about the different [token types](/authentication/tokens) and [authentication setups](/tools/bolt-python/concepts/authenticating-oauth). Your app might need different tokens depending on the actions you want to perform or for installations to multiple workspaces. -- Receive events using HTTP for various deployment methods, such as deploying to Heroku or AWS Lambda. -- Read on [app design](/surfaces/app-design) and compose fancy messages with blocks using [Block Kit Builder](https://app.slack.com/block-kit-builder) to prototype messages. +- Follow along with the steps that went into making this app on the [creating an app](/tools/bolt-python/creating-an-app) guide for an educational overview. +- Check out the [Agent quickstart](/ai/agent-quickstart) to get up and running with an agent. +- Browse our [curated catalog of samples](/samples) for more apps to use as a starting point for development. \ No newline at end of file diff --git a/docs/english/tutorial/ai-chatbot/ai-chatbot.md b/docs/english/tutorial/ai-chatbot/ai-chatbot.md index 72005f817..2fcc16e9a 100644 --- a/docs/english/tutorial/ai-chatbot/ai-chatbot.md +++ b/docs/english/tutorial/ai-chatbot/ai-chatbot.md @@ -1,64 +1,72 @@ # AI Chatbot -In this tutorial, you'll learn how to bring the power of AI into your Slack workspace using a chatbot called Bolty that uses Anthropic or OpenAI. Here's what we'll do with this sample app: - -1. Create your app from an app manifest and clone a starter template -2. Set up and run your local project -3. Create a workflow using Workflow Builder to summarize messages in conversations -4. Select your preferred API and model to customize Bolty's responses -5. Interact with Bolty via direct message, the `/ask-bolty` slash command, or by mentioning the app in conversations +In this tutorial, you'll learn how to bring the power of AI into your Slack workspace using a chatbot called Bolty that uses Anthropic or OpenAI. + +With Bolty, users can: + +- send direct messages to Bolty and get AI-powered responses in response, +- use the `/ask-bolty` slash command to ask Bolty questions, and +- receive channel summaries when joining new channels. + +Intrigued? First, grab your tools by following the three steps below. + +import QuickstartGuide from '@site/src/components/QuickstartGuide'; + + + +
      ## Prerequisites {#prereqs} -Before getting started, you will need the following: +You will also need the following: -- a development workspace where you have permissions to install apps. If you don’t have a workspace, go ahead and set that up now — you can [go here](https://slack.com/get-started#create) to create one, or you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. +- a development workspace where you have permissions to install apps. If you don’t have a workspace you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. - a development environment with [Python 3.7](https://www.python.org/downloads/) or later. - an Anthropic or OpenAI account with sufficient credits, and in which you have generated a secret key. -**Skip to the code** -If you'd rather skip the tutorial and just head straight to the code, you can use our [Bolt for Python AI Chatbot sample](https://github.com/slack-samples/bolt-python-ai-chatbot) as a template. - -## Creating your app {#create-app} - -1. Navigate to the [app creation page](https://api.slack.com/apps/new) and select **From a manifest**. -2. Select the workspace you want to install the application in. -3. Copy the contents of the [`manifest.json`](https://github.com/slack-samples/bolt-python-ai-chatbot/blob/main/manifest.json) file into the text box that says **Paste your manifest code here** (within the **JSON** tab) and click **Next**. -4. Review the configuration and click **Create**. -5. You're now in your app configuration's **Basic Information** page. Navigate to the **Install App** link in the left nav and click **Install to Workspace**, then **Allow** on the screen that follows. - ### Obtaining and storing your environment variables {#environment-variables} Before you'll be able to successfully run the app, you'll need to first obtain and set some environment variables. -#### Slack tokens {#slack-tokens} - -From your app's page on [app settings](https://api.slack.com/apps) collect an app and bot token: - -1. On the **Install App** page, copy your **Bot User OAuth Token**. You will store this in your environment as `SLACK_BOT_TOKEN` (we'll get to that next). -2. Navigate to **Basic Information** and in the **App-Level Tokens** section , click **Generate Token and Scopes**. Add the [`connections:write`](/reference/scopes/connections.write) scope, name the token, and click **Generate**. (For more details, refer to [understanding OAuth scopes for bots](/authentication/tokens#bot)). Copy this token. You will store this in your environment as `SLACK_APP_TOKEN`. - -To store your tokens and environment variables, run the following commands in the terminal. Replace the placeholder values with your bot and app tokens collected above: - -**For macOS** - -```bash -export SLACK_BOT_TOKEN= -export SLACK_APP_TOKEN= -``` - -**For Windows** - -```pwsh -set SLACK_BOT_TOKEN= -set SLACK_APP_TOKEN= -``` - #### Provider tokens {#provider-tokens} Models from different AI providers are available if the corresponding environment variable is added as shown in the sections below. -##### Anthropic {#anthropic} + + To interact with Anthropic models, navigate to your Anthropic account dashboard to [create an API key](https://console.anthropic.com/settings/keys), then export the key as follows: @@ -66,7 +74,8 @@ To interact with Anthropic models, navigate to your Anthropic account dashboard export ANTHROPIC_API_KEY= ``` -##### Google Cloud Vertex AI {#google-cloud-vertex-ai} + + To use Google Cloud Vertex AI, [follow this quick start](https://cloud.google.com/vertex-ai/generative-ai/docs/start/quickstarts/quickstart-multimodal#expandable-1) to create a project for sending requests to the Gemini API, then gather [Application Default Credentials](https://cloud.google.com/docs/authentication/provide-credentials-adc) with the strategy to match your development environment. @@ -79,7 +88,8 @@ export VERTEX_AI_LOCATION= The project location can be located under the **Region** on the [Vertex AI](https://console.cloud.google.com/vertex-ai) dashboard, as well as more details about available Gemini models. -##### OpenAI {#openai} + + Unlock the OpenAI models from your OpenAI account dashboard by clicking [create a new secret key](https://platform.openai.com/api-keys), then export the key like so: @@ -87,49 +97,46 @@ Unlock the OpenAI models from your OpenAI account dashboard by clicking [create export OPENAI_API_KEY= ``` -## Setting up and running your local project {#configure-project} - -Clone the starter template onto your machine by running the following command: - -```bash -git clone https://github.com/slack-samples/bolt-python-ai-chatbot.git -``` + + -Change into the new project directory: +## Setting up and running your local project {#configure-project} -```bash -cd bolt-python-ai-chatbot -``` Start your Python virtual environment: -**For macOS** + + ```bash python3 -m venv .venv source .venv/bin/activate ``` -**For Windows** + + ```bash py -m venv .venv .venv\Scripts\activate ``` + + + Install the required dependencies: ```bash pip install -r requirements.txt ``` -Start your local server: +Run your app locally: ```bash -python app.py +slack run ``` -If your app is up and running, you'll see a message that says "⚡️ Bolt app is running!" +If your app is indeed up and running, you'll see a message that says "⚡️ Bolt app is running!" ## Choosing your provider {#provider} @@ -235,5 +242,4 @@ You can also navigate to **Bolty** in your **Apps** list and select the **Messag Congratulations! You've successfully integrated the power of AI into your workspace. Check out these links to take the next steps in your Bolt for Python journey. - To learn more about Bolt for Python, refer to the [Getting started](/tools/bolt-python/getting-started) documentation. -- For more details about creating workflow steps using the Bolt SDK, refer to the [workflow steps for Bolt](/workflows/workflow-steps) guide. -- To use the Bolt for Python SDK to develop on the automations platform, refer to the [Create a workflow step for Workflow Builder: Bolt for Python](/tools/bolt-python/tutorial/custom-steps-workflow-builder-new) tutorial. +- For more details about creating workflow steps using the Bolt SDK, refer to the [workflow steps for Bolt](/workflows/workflow-steps) guide. \ No newline at end of file diff --git a/docs/japanese/concepts/assistant.md b/docs/japanese/concepts/assistant.md deleted file mode 100644 index 664108607..000000000 --- a/docs/japanese/concepts/assistant.md +++ /dev/null @@ -1,227 +0,0 @@ -# エージェント・アシスタント - -このページは、Bolt を使ってエージェント・アシスタントを実装するための方法を紹介します。この機能に関する一般的な情報については、[こちらのドキュメントページ(英語)](/ai/)を参照してください。 - -この機能を実装するためには、まず[アプリの設定画面](https://api.slack.com/apps)で **Agents & Assistants** 機能を有効にし、**OAuth & Permissions** のページで [`assistant:write`](/reference/scopes/assistant.write)、[chat:write](/reference/scopes/chat.write)、[`im:history`](/reference/scopes/im.history) を**ボットの**スコープに追加し、**Event Subscriptions** のページで [`assistant_thread_started`](/reference/events/assistant_thread_started)、[`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed)、[`message.im`](/reference/events/message.im) イベントを有効にしてください。 - -また、この機能は Slack の有料プランでのみ利用可能です。もし開発用の有料プランのワークスペースをお持ちでない場合は、[Developer Program](https://api.slack.com/developer-program) に参加し、全ての有料プラン向け機能を利用可能なサンドボックス環境をつくることができます。 - -ユーザーとのアシスタントスレッド内でのやりとりを処理するには、`assistant_thread_started`、`assistant_thread_context_changed`、`message` イベントの `app.event(...)` リスナーを使うことも可能ですが、Bolt はよりシンプルなアプローチを提供しています。`Assistant` インスタンスを作り、それに必要なイベントリスナーを追加し、最後にこのアシスタント設定を `App` インスタンスに渡すだけでよいのです。 - -```python -assistant = Assistant() - -# ユーザーがアシスタントスレッドを開いたときに呼び出されます -@assistant.thread_started -def start_assistant_thread(say: Say, set_suggested_prompts: SetSuggestedPrompts): - # ユーザーに対して最初の返信を送信します - say(":wave: Hi, how can I help you today?") - - # プロンプト例を送るのは必須ではありません - set_suggested_prompts( - prompts=[ - # もしプロンプトが長い場合は {"title": "表示する短いラベル", "message": "完全なプロンプト"} を使うことができます - "What does SLACK stand for?", - "When Slack was released?", - ], - ) - -# ユーザーがスレッド内で返信したときに呼び出されます -@assistant.user_message -def respond_in_assistant_thread( - payload: dict, - logger: logging.Logger, - context: BoltContext, - set_status: SetStatus, - say: Say, - client: WebClient, -): - try: - # ユーザーにこのbotがリクエストを受信して作業中であることを伝えます - set_status("is typing...") - - # 会話の履歴を取得します - replies_in_thread = client.conversations_replies( - channel=context.channel_id, - ts=context.thread_ts, - oldest=context.thread_ts, - limit=10, - ) - messages_in_thread: List[Dict[str, str]] = [] - for message in replies_in_thread["messages"]: - role = "user" if message.get("bot_id") is None else "assistant" - messages_in_thread.append({"role": role, "content": message["text"]}) - - # プロンプトと会話の履歴を LLM に渡します(この call_llm はあなた自身のコードです) - returned_message = call_llm(messages_in_thread) - - # 結果をアシスタントスレッドに送信します - say(text=returned_message) - - except Exception as e: - logger.exception(f"Failed to respond to an inquiry: {e}") - # エラーになった場合は必ずメッセージを送信するようにしてください - # そうしなかった場合、'is typing...' の表示のままになってしまい、ユーザーは会話を続けることができなくなります - say(f":warning: Sorry, something went wrong during processing your request (error: {e})") - -# このミドルウェアを Bolt アプリに追加します -app.use(assistant) -``` - -リスナーに指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 - -ユーザーがチャンネルの横でアシスタントスレッドを開いた場合、そのチャンネルの情報は、そのスレッドの `AssistantThreadContext` データとして保持され、 `get_thread_context` ユーティリティを使ってアクセスすることができます。Bolt がこのユーティリティを提供している理由は、後続のユーザーメッセージ投稿のイベントペイロードに最新のスレッドのコンテキスト情報は含まれないためです。そのため、アプリはコンテキスト情報が変更されたタイミングでそれを何らかの方法で保存し、後続のメッセージイベントのリスナーコードから参照できるようにする必要があります。 - -そのユーザーがチャンネルを切り替えた場合、`assistant_thread_context_changed` イベントがあなたのアプリに送信されます。(上記のコード例のように)組み込みの `Assistant` ミドルウェアをカスタム設定なしで利用している場合、この更新されたチャンネル情報は、自動的にこのアシスタントボットからの最初の返信のメッセージメタデータとして保存されます。これは、組み込みの仕組みを使う場合は、このコンテキスト情報を自前で用意したデータストアに保存する必要はないということです。この組み込みの仕組みの唯一の短所は、追加の Slack API 呼び出しによる処理時間のオーバーヘッドです。具体的には `get_thread_context` を実行したときに、この保存されたメッセージメタデータにアクセスするために `conversations.history` API が呼び出されます。 - -このデータを別の場所に保存したい場合、自前の `AssistantThreadContextStore` 実装を `Assistant` のコンストラクターに渡すことができます。リファレンス実装として、`FileAssistantThreadContextStore` というローカルファイルシステムを使って実装を提供しています: - -```python -# これはあくまで例であり、自前のものを渡すことができます -from slack_bolt import FileAssistantThreadContextStore -assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) -``` - -このリファレンス実装はローカルファイルに依存しており、本番環境での利用は推奨しません。本番アプリでは `AssistantThreadContextStore` を継承した自前のクラスを使うようにしてください。 - -最後に、動作する完全なサンプルコード例を確認したい場合は、私たちが GitHub 上で提供している[サンプルアプリのリポジトリ](https://github.com/slack-samples/bolt-python-assistant-template)をチェックしてみてください。 - -## アシスタントスレッドでの Block Kit インタラクション - -より高度なユースケースでは、上のようなプロンプト例の提案ではなく Block Kit のボタンなどを使いたいという場合があるかもしれません。そして、後続の処理のために[構造化されたメッセージメタデータ](/messaging/message-metadata/)を含むメッセージを送信したいという場合もあるでしょう。 - -例えば、アプリが最初の返信で「参照しているチャンネルを要約」のようなボタンを表示し、ユーザーがそれをクリックして、より詳細な情報(例:要約するメッセージ数・日数、要約の目的など)を送信、アプリがそれを構造化されたメータデータに整理した上でリクエスト内容をボットのメッセージとして送信するようなシナリオです。 - -デフォルトでは、アプリはそのアプリ自身から送信したボットメッセージに応答することはできません(Bolt にはあらかじめ無限ループを防止する制御が入っているため)。`ignoring_self_assistant_message_events_enabled=False` を `App` のコンストラクターに渡し、`bot_message` リスナーを `Assistant` ミドルウェアに追加すると、上記の例のようなリクエストを伝えるボットメッセージを使って処理を継続することができるようになります。 - -```python -app = App( - token=os.environ["SLACK_BOT_TOKEN"], - # bot message を受け取るには必ずこれを指定してください - ignoring_self_assistant_message_events_enabled=False, -) - -assistant = Assistant() - -# リスナーに指定可能な引数の一覧は https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html を参照してください - -@assistant.thread_started -def start_assistant_thread(say: Say): - say( - text=":wave: Hi, how can I help you today?", - blocks=[ - { - "type": "section", - "text": {"type": "mrkdwn", "text": ":wave: Hi, how can I help you today?"}, - }, - { - "type": "actions", - "elements": [ - # 複数のボタンを配置することが可能です - { - "type": "button", - "action_id": "assistant-generate-random-numbers", - "text": {"type": "plain_text", "text": "Generate random numbers"}, - "value": "clicked", - }, - ], - }, - ], - ) - -# 上のボタンがクリックされたときに実行されます -@app.action("assistant-generate-random-numbers") -def configure_random_number_generation(ack: Ack, client: WebClient, body: dict): - ack() - client.views_open( - trigger_id=body["trigger_id"], - view={ - "type": "modal", - "callback_id": "configure_assistant_summarize_channel", - "title": {"type": "plain_text", "text": "My Assistant"}, - "submit": {"type": "plain_text", "text": "Submit"}, - "close": {"type": "plain_text", "text": "Cancel"}, - # アシスタントスレッドの情報を app.view リスナーに引き継ぎます - "private_metadata": json.dumps( - { - "channel_id": body["channel"]["id"], - "thread_ts": body["message"]["thread_ts"], - } - ), - "blocks": [ - { - "type": "input", - "block_id": "num", - "label": {"type": "plain_text", "text": "# of outputs"}, - # 自然言語のテキストではなく、あらかじめ決められた形式の入力を受け取ることができます - "element": { - "type": "static_select", - "action_id": "input", - "placeholder": {"type": "plain_text", "text": "How many numbers do you need?"}, - "options": [ - {"text": {"type": "plain_text", "text": "5"}, "value": "5"}, - {"text": {"type": "plain_text", "text": "10"}, "value": "10"}, - {"text": {"type": "plain_text", "text": "20"}, "value": "20"}, - ], - "initial_option": {"text": {"type": "plain_text", "text": "5"}, "value": "5"}, - }, - } - ], - }, - ) - -# 上のモーダルが送信されたときに実行されます -@app.view("configure_assistant_summarize_channel") -def receive_random_number_generation_details(ack: Ack, client: WebClient, payload: dict): - ack() - num = payload["state"]["values"]["num"]["input"]["selected_option"]["value"] - thread = json.loads(payload["private_metadata"]) - - # 構造化された入力情報とともにボットのメッセージを送信します - # 以下の assistant.bot_message リスナーが処理を継続します - # このリスナー内で処理したい場合はそれでも構いません! - # bot_message リスナーが必要ない場合は ignoring_self_assistant_message_events_enabled=False を設定する必要はありません - client.chat_postMessage( - channel=thread["channel_id"], - thread_ts=thread["thread_ts"], - text=f"OK, you need {num} numbers. I will generate it shortly!", - metadata={ - "event_type": "assistant-generate-random-numbers", - "event_payload": {"num": int(num)}, - }, - ) - -# このアプリのボットユーザーがメッセージを送信したときに実行されます -@assistant.bot_message -def respond_to_bot_messages(logger: logging.Logger, set_status: SetStatus, say: Say, payload: dict): - try: - if payload.get("metadata", {}).get("event_type") == "assistant-generate-random-numbers": - # 上の random-number-generation リクエストを処理します - set_status("is generating an array of random numbers...") - time.sleep(1) - nums: Set[str] = set() - num = payload["metadata"]["event_payload"]["num"] - while len(nums) < num: - nums.add(str(random.randint(1, 100))) - say(f"Here you are: {', '.join(nums)}") - else: - # それ以外のパターンでは何もしません - # さらに他のパターンを追加する場合、メッセージ送信の無限ループを起こさないよう注意して実装してください - pass - - except Exception as e: - logger.exception(f"Failed to respond to an inquiry: {e}") - -# ユーザーが返信したときに実行されます -@assistant.user_message -def respond_to_user_messages(logger: logging.Logger, set_status: SetStatus, say: Say): - try: - set_status("is typing...") - say("Please use the buttons in the first reply instead :bow:") - except Exception as e: - logger.exception(f"Failed to respond to an inquiry: {e}") - say(f":warning: Sorry, something went wrong during processing your request (error: {e})") - -# このミドルウェアを Bolt アプリに追加します -app.use(assistant) -``` \ No newline at end of file From 2266ac7d9ea8c36c2b17266eb6e1dc45578372aa Mon Sep 17 00:00:00 2001 From: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:53:11 +0200 Subject: [PATCH 865/865] fix: handle malformed user/view payloads in extract_team_id (#1481) --- slack_bolt/request/internals.py | 4 ++-- tests/slack_bolt/request/test_internals.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 15d1e7367..e0863a713 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -112,9 +112,9 @@ def extract_team_id(payload: Dict[str, Any]) -> Optional[str]: if isinstance(payload.get("event"), dict): return extract_team_id(payload["event"]) if isinstance(payload.get("user"), dict): - return payload["user"]["team_id"] + return payload["user"].get("team_id") if isinstance(payload.get("view"), dict): - return payload["view"]["team_id"] + return payload["view"].get("team_id") return None diff --git a/tests/slack_bolt/request/test_internals.py b/tests/slack_bolt/request/test_internals.py index 8cccf0431..31ac35bdd 100644 --- a/tests/slack_bolt/request/test_internals.py +++ b/tests/slack_bolt/request/test_internals.py @@ -1253,8 +1253,10 @@ def test_extraction_functions_invalid_dict_keys(self): invalid_payloads = { "event": {"event": "some_event_type"}, "user": {"user": "U12345"}, + "user_missing_team_id": {"user": {"id": "U12345"}}, "team": {"team": "T12345"}, "view": {"view": "V12345"}, + "view_missing_team_id": {"view": {"id": "V12345"}}, "message": {"message": "some text"}, "item": {"item": "item_id"}, "function_data": {"function_data": "fd_123"},
  • kuHci1T>%df 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ÁL-1d-(Q|-QVD5f?wX!S;g7ykz;DfF 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�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 diff --git a/docs/static/img/favicon.ico b/docs/static/img/favicon.ico deleted file mode 100644 index e6e9a4aa71ffae29bbfaeacc2bb431272df45cc3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24499 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 diff --git a/docs/static/img/slack-logo-on-white.png b/docs/static/img/slack-logo-on-white.png deleted file mode 100644 index 2a73996c6c402db042920a47e65a82be85e25e1c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25811 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 Q{izOvV`#l%UB?^$2eLeXjsO4v diff --git a/docs/static/img/slack-logo.svg b/docs/static/img/slack-logo.svg deleted file mode 100644 index fb55f7245..000000000 --- a/docs/static/img/slack-logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/examples/getting_started/app.py b/examples/getting_started/app.py index 22cdf5f31..aa5223d51 100644 --- a/examples/getting_started/app.py +++ b/examples/getting_started/app.py @@ -10,7 +10,7 @@ # Listens to incoming messages that contain "hello" # To learn available listener method arguments, -# visit https://slack.dev/bolt-python/api-docs/slack_bolt/kwargs_injection/args.html +# visit https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html @app.message("hello") def message_hello(message, say): # say() sends a message to the channel where the event was triggered diff --git a/scripts/generate_api_docs.sh b/scripts/generate_api_docs.sh index 68988f428..459476122 100755 --- a/scripts/generate_api_docs.sh +++ b/scripts/generate_api_docs.sh @@ -5,6 +5,6 @@ script_dir=`dirname $0` cd ${script_dir}/.. pip install -U pdoc3 -rm -rf docs/static/api-docs -pdoc slack_bolt --html -o docs/static/api-docs -open docs/static/api-docs/slack_bolt/index.html +rm -rf docs/reference +pdoc reference --html -o docs +open docs/reference/index.html From 974816eee7966adab9e14d890e0908922fa3a77a Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 13 Aug 2025 17:28:46 -0400 Subject: [PATCH 783/865] fix: make the function handler timeout 5 seconds (#1348) Co-authored-by: Eden Zimbelman --- slack_bolt/app/app.py | 6 ++- slack_bolt/app/async_app.py | 6 ++- slack_bolt/listener/async_listener.py | 4 ++ slack_bolt/listener/asyncio_runner.py | 2 +- slack_bolt/listener/custom_listener.py | 3 ++ slack_bolt/listener/listener.py | 1 + slack_bolt/listener/thread_runner.py | 2 +- tests/scenario_tests/test_function.py | 35 ++++++++++++++++- tests/scenario_tests_async/test_function.py | 43 ++++++++++++++++++++- 9 files changed, 95 insertions(+), 7 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index c117740a1..86909ed18 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -946,7 +946,9 @@ def reverse_string(ack: Ack, inputs: dict, complete: Complete, fail: Fail): def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger) - return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge) + return self._register_listener( + functions, primary_matcher, matchers, middleware, auto_acknowledge, acknowledgement_timeout=5 + ) return __call__ @@ -1422,6 +1424,7 @@ def _register_listener( matchers: Optional[Sequence[Callable[..., bool]]], middleware: Optional[Sequence[Union[Callable, Middleware]]], auto_acknowledgement: bool = False, + acknowledgement_timeout: int = 3, ) -> Optional[Callable[..., Optional[BoltResponse]]]: value_to_return = None if not isinstance(functions, list): @@ -1452,6 +1455,7 @@ def _register_listener( matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + acknowledgement_timeout=acknowledgement_timeout, base_logger=self._base_logger, ) ) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index c04326291..294fb8b0c 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -976,7 +976,9 @@ def __call__(*args, **kwargs): primary_matcher = builtin_matchers.function_executed( callback_id=callback_id, base_logger=self._base_logger, asyncio=True ) - return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge) + return self._register_listener( + functions, primary_matcher, matchers, middleware, auto_acknowledge, acknowledgement_timeout=5 + ) return __call__ @@ -1456,6 +1458,7 @@ def _register_listener( matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]], middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]], auto_acknowledgement: bool = False, + acknowledgement_timeout: int = 3, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: value_to_return = None if not isinstance(functions, list): @@ -1491,6 +1494,7 @@ def _register_listener( matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + acknowledgement_timeout=acknowledgement_timeout, base_logger=self._base_logger, ) ) diff --git a/slack_bolt/listener/async_listener.py b/slack_bolt/listener/async_listener.py index c8758daf2..ca069b097 100644 --- a/slack_bolt/listener/async_listener.py +++ b/slack_bolt/listener/async_listener.py @@ -15,6 +15,7 @@ class AsyncListener(metaclass=ABCMeta): ack_function: Callable[..., Awaitable[BoltResponse]] lazy_functions: Sequence[Callable[..., Awaitable[None]]] auto_acknowledgement: bool + acknowledgement_timeout: int async def async_matches( self, @@ -87,6 +88,7 @@ class AsyncCustomListener(AsyncListener): matchers: Sequence[AsyncListenerMatcher] middleware: Sequence[AsyncMiddleware] auto_acknowledgement: bool + acknowledgement_timeout: int arg_names: MutableSequence[str] logger: Logger @@ -99,6 +101,7 @@ def __init__( matchers: Sequence[AsyncListenerMatcher], middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False, + acknowledgement_timeout: int = 3, base_logger: Optional[Logger] = None, ): self.app_name = app_name @@ -107,6 +110,7 @@ def __init__( self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement + self.acknowledgement_timeout = acknowledgement_timeout self.arg_names = get_arg_names_of_callable(ack_function) self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) diff --git a/slack_bolt/listener/asyncio_runner.py b/slack_bolt/listener/asyncio_runner.py index 56dc29cc1..98d3bf4f8 100644 --- a/slack_bolt/listener/asyncio_runner.py +++ b/slack_bolt/listener/asyncio_runner.py @@ -149,7 +149,7 @@ async def run_ack_function_asynchronously( self._start_lazy_function(lazy_func, request) # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= 3: + while ack.response is None and time.time() - starting_time <= listener.acknowledgement_timeout: await asyncio.sleep(0.01) if response is None and ack.response is None: diff --git a/slack_bolt/listener/custom_listener.py b/slack_bolt/listener/custom_listener.py index b785dab6d..e2977effa 100644 --- a/slack_bolt/listener/custom_listener.py +++ b/slack_bolt/listener/custom_listener.py @@ -18,6 +18,7 @@ class CustomListener(Listener): matchers: Sequence[ListenerMatcher] middleware: Sequence[Middleware] auto_acknowledgement: bool + acknowledgement_timeout: int = 3 arg_names: MutableSequence[str] logger: Logger @@ -30,6 +31,7 @@ def __init__( matchers: Sequence[ListenerMatcher], middleware: Sequence[Middleware], auto_acknowledgement: bool = False, + acknowledgement_timeout: int = 3, base_logger: Optional[Logger] = None, ): self.app_name = app_name @@ -38,6 +40,7 @@ def __init__( self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement + self.acknowledgement_timeout = acknowledgement_timeout self.arg_names = get_arg_names_of_callable(ack_function) self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) diff --git a/slack_bolt/listener/listener.py b/slack_bolt/listener/listener.py index d938935df..51dadae56 100644 --- a/slack_bolt/listener/listener.py +++ b/slack_bolt/listener/listener.py @@ -13,6 +13,7 @@ class Listener(metaclass=ABCMeta): ack_function: Callable[..., BoltResponse] lazy_functions: Sequence[Callable[..., None]] auto_acknowledgement: bool + acknowledgement_timeout: int = 3 def matches( self, diff --git a/slack_bolt/listener/thread_runner.py b/slack_bolt/listener/thread_runner.py index c144daf1d..61e8d6129 100644 --- a/slack_bolt/listener/thread_runner.py +++ b/slack_bolt/listener/thread_runner.py @@ -160,7 +160,7 @@ def run_ack_function_asynchronously(): self._start_lazy_function(lazy_func, request) # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= 3: + while ack.response is None and time.time() - starting_time <= listener.acknowledgement_timeout: time.sleep(0.01) if response is None and ack.response is None: diff --git a/tests/scenario_tests/test_function.py b/tests/scenario_tests/test_function.py index 00f0efba8..41290de8f 100644 --- a/tests/scenario_tests/test_function.py +++ b/tests/scenario_tests/test_function.py @@ -1,6 +1,7 @@ import json import time import pytest +from unittest.mock import Mock from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient @@ -51,6 +52,10 @@ def build_request_from_body(self, message_body: dict) -> BoltRequest: timestamp, body = str(int(time.time())), json.dumps(message_body) return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + def setup_time_mocks(self, *, monkeypatch: pytest.MonkeyPatch, time_mock: Mock, sleep_mock: Mock): + monkeypatch.setattr(time, "time", time_mock) + monkeypatch.setattr(time, "sleep", sleep_mock) + def test_valid_callback_id_success(self): app = App( client=self.web_client, @@ -124,7 +129,7 @@ def test_auto_acknowledge_false_with_acknowledging(self): assert response.status == 200 assert_auth_test_count(self, 1) - def test_auto_acknowledge_false_without_acknowledging(self, caplog): + def test_auto_acknowledge_false_without_acknowledging(self, caplog, monkeypatch): app = App( client=self.web_client, signing_secret=self.signing_secret, @@ -132,12 +137,40 @@ def test_auto_acknowledge_false_without_acknowledging(self, caplog): app.function("reverse", auto_acknowledge=False)(just_no_ack) request = self.build_request_from_body(function_body) + self.setup_time_mocks( + monkeypatch=monkeypatch, + time_mock=Mock(side_effect=[current_time for current_time in range(100)]), + sleep_mock=Mock(), + ) response = app.dispatch(request) assert response.status == 404 assert_auth_test_count(self, 1) assert f"WARNING {just_no_ack.__name__} didn't call ack()" in caplog.text + def test_function_handler_timeout(self, monkeypatch): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse", auto_acknowledge=False)(just_no_ack) + request = self.build_request_from_body(function_body) + + sleep_mock = Mock() + self.setup_time_mocks( + monkeypatch=monkeypatch, + time_mock=Mock(side_effect=[current_time for current_time in range(100)]), + sleep_mock=sleep_mock, + ) + + response = app.dispatch(request) + + assert response.status == 404 + assert_auth_test_count(self, 1) + assert ( + sleep_mock.call_count == 5 + ), f"Expected handler to time out after calling time.sleep 5 times, but it was called {sleep_mock.call_count} times" + function_body = { "token": "verification_token", diff --git a/tests/scenario_tests_async/test_function.py b/tests/scenario_tests_async/test_function.py index a2c10950c..fc1299e55 100644 --- a/tests/scenario_tests_async/test_function.py +++ b/tests/scenario_tests_async/test_function.py @@ -3,6 +3,7 @@ import time import pytest +from unittest.mock import Mock, MagicMock from slack_sdk.signature import SignatureVerifier from slack_sdk.web.async_client import AsyncWebClient @@ -17,6 +18,10 @@ from tests.utils import remove_os_env_temporarily, restore_os_env +async def fake_sleep(seconds): + pass + + class TestAsyncFunction: signing_secret = "secret" valid_token = "xoxb-valid" @@ -56,6 +61,10 @@ def build_request_from_body(self, message_body: dict) -> AsyncBoltRequest: timestamp, body = str(int(time.time())), json.dumps(message_body) return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + def setup_time_mocks(self, *, monkeypatch: pytest.MonkeyPatch, time_mock: Mock, sleep_mock: MagicMock): + monkeypatch.setattr(time, "time", time_mock) + monkeypatch.setattr(asyncio, "sleep", sleep_mock) + @pytest.mark.asyncio async def test_mock_server_is_running(self): resp = await self.web_client.api_test() @@ -130,19 +139,49 @@ async def test_auto_acknowledge_false_with_acknowledging(self): await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio - async def test_auto_acknowledge_false_without_acknowledging(self, caplog): + async def test_auto_acknowledge_false_without_acknowledging(self, caplog, monkeypatch): app = AsyncApp( client=self.web_client, signing_secret=self.signing_secret, ) app.function("reverse", auto_acknowledge=False)(just_no_ack) - request = self.build_request_from_body(function_body) + + self.setup_time_mocks( + monkeypatch=monkeypatch, + time_mock=Mock(side_effect=[current_time for current_time in range(100)]), + sleep_mock=MagicMock(side_effect=fake_sleep), + ) + response = await app.async_dispatch(request) assert response.status == 404 await assert_auth_test_count_async(self, 1) assert f"WARNING {just_no_ack.__name__} didn't call ack()" in caplog.text + @pytest.mark.asyncio + async def test_function_handler_timeout(self, monkeypatch): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse", auto_acknowledge=False)(just_no_ack) + request = self.build_request_from_body(function_body) + + sleep_mock = MagicMock(side_effect=fake_sleep) + self.setup_time_mocks( + monkeypatch=monkeypatch, + time_mock=Mock(side_effect=[current_time for current_time in range(100)]), + sleep_mock=sleep_mock, + ) + + response = await app.async_dispatch(request) + + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + assert ( + sleep_mock.call_count == 5 + ), f"Expected handler to time out after calling time.sleep 5 times, but it was called {sleep_mock.call_count} times" + function_body = { "token": "verification_token", From 76f70278403f1b0e283d2e9a7276a691d5e91c6f Mon Sep 17 00:00:00 2001 From: Luke Russell <31357343+lukegalbraithrussell@users.noreply.github.com> Date: Fri, 22 Aug 2025 12:03:31 -0700 Subject: [PATCH 784/865] Docs: adds updated bolt-py image to README (#1352) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c6b6a536c..10a44a0e5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

    !upo#yRarR$>iMX%E}u3c5tY9YLE!e_24l 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ɒUR(Y7uPJOR4?$#Uet? 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�b=%; zbEVjFfC7=FnHQ5$uBdMESTxjprgn28b9+AX%PJ2!kJ~xB`PQpat%nHM!t#PCqm}F= zvmo_IuelRjRW!*D&5^^$=+5&8F{?Q!F5Ryi3 z26jo~`a>mWs@wotmcP(t%_eK(C`S6v0>GZ4tK;Dbz6V)ZSq#rqe!)xezDGpyRm78D z8zfijgZc5HG2qYs2+q3lgQV}@L?$NwN>*H7S#ulM)Ka$LvU;#Hd*K7)Vj7Lww6_#w zw^LwB>38Q2yl~hw)R6@u;*zmk=K1Pl3B1lc28SD-zXXvR(MV&Ynoky>wvfn#E%uqd z_27|NG9T-%I$LhwVXXuGuH@HDIB&1c z*K@^u@L2e?GWH?A}3wszJI5(ClF`nEB+7&3t@)dHTS;wk;Qu!n0(}|Ru z^9dYZW96&mY04Ngr>jrn*bB{Q=%_}-M;mG9>;cI4cFuB^_gS&U@%vhK`E==9Sk6zJy= zd?;xU*5r0jZCX>lk2u7Uvcg3bfuI$doi6u7!+RRp_qQ z6mno_qNJ>hER&`iP_&}zjMaxEpXR#t5>fBS`#LI z4u#aWLX&1IBWu5?sF8&$NxECIrLMVpsSG)HCtroYy?Ot52baK6~)kBUDUOG#Y*d^dU)YD6J0R zVc9W=xcGQ(Gsn@93Sv&ZEP$hBtz>~>m*lYg3o6bxqbhyy# z*L6Fuigw2_?&b9mq9nmnm%6JHji*H(DPN%SrxJlUE#8mW^_)uS?rVor0FQ`B5@EAB z-8HQ_ef_SzIw!Q{)Jl0M2xjKJuuN&$<)zfrR6sjR%gRoaSE_DYLZFmax=e`)+;TJ; z#h$2dmE_bq)3QH(`kX0|U=<}Qm!6qHuTjjx$%%wC;HDbx9~KiAH}21+^yksv#j@G_ z>3(%)4x{Earf?{h-d%mdBguJ+(DX~izj}j0qf(s) zoGEz+Bkzs*`On=eIXT${8V%+0v@k-eGMvwN>VCoMx`YmBNGLgM$O^r`n2>--8(Z<1 z+i7sL&0*rubd#&}*7*H|K%^kYzCg9iUu+pB*x)z?Qx$L~I(Pm((-!6eF`A{6a;_KW z)5pi^;3)$fo2;T*Vh8c`{u_Hlyr-uq5ZscOOb`$p5YU+(5QWv?e!8%_tKWHzPvMPz z^Y;h=pZjmNsc`~8TM&n2OHJZ?(Y$yuT5D+i{(T0Nqb{u{U?cdNb!Ve}V3qlOT4s9H zV;g)(iBMnGH>Rlm5FnW14s!Lv?jbtJA^NZC;Dj!?K)_jpS9kK}IlC ziKJ0AQOy}HP8WxBNDUYeaAUyME;Ak$OYeuZ9*oZ=q@|VU9|44;)dtZy8cXPRUz$wv znLdaRONY75OoMp?Y*G9p=G3bvv`4aUE^z;7d0B>u%e!2FUMcSdRq!6sg^TO8Kj?A| z6rxt%#b2fJU*aV{FhcbZ;JwxIIwJ?8XD8;B^8F4xI|@4Az4KV*&ye&-Ym-aHfU{EBk=MBJ)*p`rb%#$i?V!-o}MkiibAa2_!WCI9HDK&{kAuRK-c3Z*3# z0thu9P6Jm5kDwz!is1u;0y8CJp%5C*)wG^lxU-gRCH(hXN}m7^kHvI&A259oqKimM zrfXGRuJn-=Dd*;;>+9&8LJbWQBG_>Dbz3NWz#@%hrH|MB&Gh+k9IhDJ72AnB44{(L zO^uC=%*Lu0;pxKu0IUU`(O?%+>jJ44*F3;bdV6YFVw>jhU=z;1@jBrz(6wKj9rncX zZmWfp2_uq%bi!}Gkx3B~s&YL;vUm`s#v@w`t8#;oX+UnZvi>&5F7NgATYat*R!Jam z&B=+?+~}@=37miCtUl8iSRY*D<9i4L*z}Qx`ua=23t7roq~l*<<2-Q#3Nf4uR9%Au z1CJbEnvLX7!ID@lM=IVbYib6{df_c5@;Kf_OjX-)yX@aSD5j#K0*I;3a!&|yS)uuO zX+FO3=TDM@Wo7c$S(xdrQe2g9j_0e^8G#B50qM}ef%^!k?BEK4KoD=wF&HlvQTeOK zhGW^%)6&EY=_{R zibO5;&fkI;z4Ck+;(jbwc9Qcos)dAw>9xwwe>S6Sw7XL`Rn+D)ER@+D5TGP+Zr6&0 zPHvSFxk82RK&ZMsJTPF*y~GJsj2fHS?u_Q=SG|=_Y&T~bt`h2;7tCrZUHgjr zY3V9dZYjJ3053>Mb*Fd*0CxG(Xg{mmG(c=_b@imB)o0;p7hata z2gKaF|95W>ZT~?bJq%7rB)z&>6SPOF$)4MwFBWX%JDQq`N^#~Fe1J?HGb*IJ*V9}OJ_vi2~$8Ma&u)bD?>gK1}L^I!lU7N!;n^&%nqP^BY@ zXD0L*kQUnLdAu$^^A2xiphUMOa{cj);M!CNj8`llg#Vqm{t%wJwS!RLVj8)70(cP`otB`-1@aBaua&rzRrG04A@!^H(3g=g<9 ztEE+36b}xQnh&=o<37A|--X1R@EhGY3R+qdpn1hc1^BV+$c+?+1BQ#gaF8wxywy!z z09#=p)YuLYBLKQeJ4}Mcq46?JuO{mAnp;3b{FNF9@}A;vp!BZ)^y*a!by z@GQaL#mmeEHRBrf&9!Xq@lLo^#SeZ2Q5}fCUE<2ETGo%7=ec`odpGD1nnGao88&$a zvIKZb27mfw0F->-gTw*X2`GE86ZT5Y1dl#!*$^nS)^iMSn)~zd!@FMU@b5c)s>)qm zU5~b18IfymBkX6s$ezp5Vz&Yn0JaTD%UM$)7edT++VAg35E5`DK8$1YTx^${Me*jArX2Wayon7DOx546n z<^IPjxkdA@%k!sr!tqb6E)}%T@s*U7!E{bohefB}ViOb}vc8cig8w$>-{Hn3C56lc zudl5se&kW+it#=_ot&IRX@R4aNplICA?5vojgv%9jM;-p$!)BI?IDJF;Q6f$SK%R7 zonx^WG?b{K7{p=+Fz$c)Sl)Df(VQg>gHaY4nab|fWP8Vrf{X6oEeZd?MKN6M$q z`D4jZIlNpqxt!=x9?Qw~Kz}Yr57t%tEB%@-2b&8EG{m?lG_*qg>95h3!?bQEzgu)b zKRQN3!N0|$#%Ay4%*Dq~g_S4R!Uu zK5>^=2u#iIf9d{o$M!1IBMupPx#9}%N6RxmWDx3XzRjf~7=D$$gfw@1izUH_$bL!* zJxW?hPR$Ri0=+|YB**S4lBU~uvQbw-W*SMsg-UixRHSR?J|!J1wnHMHZ)^T>S*h6N zNO{9H_%0+Yq0BTk5}E^8z(eNuc@hNe)%Wy{d-DPy{4190s@p?-v+vW z>zn=Fm@t^EB(G0q@I|mQ^R)Sd>|drmdQ)F+!=kRPt|AU^YHhviX8!Z^XNt0}&dzAW zm9CXBM!iF@A~Rf`6FVL6nY+9{IC$UZwItekAwzCO+39^K3=wo>UP<3GWI!~Jfpp`B z#S{67aY71JhaCx=WstQ(>lZKcc}RIEjA+sfwzG)GYqt_E1t~bxoV;>U%rqqK27_ePXyF)UVd&g-1Lds zKZS&aL@~T+Wy_s&p1RlroNsYOq;+mAs%u z+D&!?@%E~JsOE$TxkjaOep>Di+u1*qT;sQ5aj=IuG!GXaCQ>;YW&$N2qSUCv?D9xG zboyg;0%tB7A_grz4uXok{?8o=2@Ct;@{<;E`7`u(xA-k3wzY&?V)cuOAxexE+y{NLzPChbnF) zuX*fa{`|e0wWkNnP8vRk7sBiEd1{{Hf9|6Uc708EPn|;u@7%fE{q=;U1=I|PFM4@N zYv6&~9zD=J#d3j;33?sg0FM|Stl;)34Pf$=i zdos{(%)9FD7;l_|s6>2dF1vRiGpx?27%cgRqF~?*Eni zx%M=D4XjlN5wTb>LEd=zjwy)ol|c&~M{ME^^XJA53BI40_ix}1hc?}Lf7P7t+jNv6z-riCs-RXItD!PVD zGVqdVH{dP#s8Vc65uA}SF*DI%FF3cK=^7vXB|5&4K?n=ai~DG#aFKt1waC`MQFRDQ zN=k}<}FtP|MnUSn3+l0CxBqoOFM5*Se8c&e1laVQLX1dJS z%tQeJ5F5~qZ=%1Hsh!4lVGX3&C`m^he|WZKyZYx(L2fRB5_2}^9|OGnjPevA$Jh&b zP9Uoc2ZoDD#APdjhNmw@2Eh2CV2HNr$XS&aK6l8pgmLw|S`K8e810??J*zL&NRvKw zo7yiJw1F3-Z0hy@Ak@SYrzOz9$Vj0(QKBHFkwY^SA1LC)Si*4YbIGGzkWG&$mj)%i z`_a$CKcY(A>JKI6X7w=?MQ9%K-o<%sd1dboGAC4GH8nNJC#5~CbRHi7+yLDd6aYZn z8@_|#jSHqIiUae;9r3IFn$>6U{PI-{{fOr0Co32~%g? zdNqF|yaT;ulv%Z}zklsnTU#rNFOsWTPauz~RR7ctK~M*iMKEv*<>k@EU(`NGYy)rs z3<;s_0TN)MT-I~rT|nFno4wVQmDeTEg|<>v!fG zU*3SJQVA_I`yl#3N-HZz$HsmqkZ%&{I;)NUFxO)vV4myb0>7fp5S38UED+oFSmegB z2Hlr8NEh0tZC;kr{-*UIwwaijK!$ufI+_mnBr-JE9%rjuAEM?fM|n_b8nk+)0-mIO z{4>h+OV{5dYVxd2HGah!1qbM5pEw<9f$wSYQPf@JK3^pI@W8;+q4|>6lt(|@pk;yn z;t+HN@UA*%(@8K;aD|D~z zJ~&H+jCo1k?8*+Dz_L)q?t=XMjMzbk&}=1DP06zOlZd;r4<0?brLV4t07FN~zBt`O z75lI}-2|ttHT-}qbtvvF&=O!lK^|U=3_S)zamUYxl|@HhAS#9Ij-w?|0hg8nhtz$2 z6vxX4mS10qp`Vg>#BBsxTn6IM&njy$kkzexF7C6mxp~gZiifOnID=rpYvRi(}lsu$zNK$|2?Qw8F6B!Ma7CuXG5%R|S_ zO-CoWHO6@jGc#W9OW)}|-)RihCZ!bprTbkWi_LrY3*yV7u05tWb9vh}>jk2_dB+ zMNf6X*ytH{NLFuRPga{{<^9c&+a%QZ2&x}VYPHDIS5N%W%d@=|(`Am%5Az?Bt*A%8 zdq!?aueq3fav~8E(i84<^vsGvO74fSfCP*Z=n5ht;+I#JS(Arx5XJxes$VQ11jt<( z+l7R0`$2ri6jX7peqzuA(G!PivHj*-fwz0fY$eS$>D@1!LH=CX$u@}jzREc_z*R6_93vJK5b)5>)oE&dw9sl~(s?DVVoiwo*A}|> zP#6!{*R14Zf?)ah`VaMq*<*lM zf$Ir?_v3aYFx5vDgZ+x>P+rX0TUDkOo+F+~7!|>ZQEP}~wV*v-X2j9bzqw#A->~k;h=rL9o>0=W~Q68I^(4OoA zNfylPvZ$^-e*BnpO-!=*eFfFLi6isn@aB(S6Ol==v9trkXx$0a2*oYROdAo%wl9mh zxVQhPNbU0Jc572ox1E}Aw}|7;r=69|s`_Pot>lfJQW`nz{GkONkO zVMsB1A-sN*==N9mvaF_qf#&wUcPVs2b?RPFRako3W9 z=4ad+;7=>jM0d*(1=3)nz7Ke)csuLIBK@mB`Fn&PP_2ft(d9ZPfSCUTaDTu;!1DZ-%rhrvN5`=~*9`BxjV%VJ^{hXgxN zNApy#go+gwee`~MWv``17!}!zlD^l!lS|bs(6EczpR?l52Da`Tysr1}6Uca+Zq16C zb}6Qa*n!^uHSygZE#udCJ?_4Ae)JSPWV+qh8Q(D5j6;=OrEJ8EIr9JL#n~c&nY=1gm_r5ka0eS+Izu!K@d6;3$P1wPB7;Li_VZ1RdMeK`QZIA$ z)<>4J#P#)OC)z2nvGITYxvvF(`PNP$_se*qy*&Uumj12)S16s9wg$d-8oi&q?P60J z4-=)SVt*^Kz>l|)p~^bB8HL>$2b8k%@{%?uzsY}xJ*%6F&WmFbp8xg`3l6lx{7h9T z)e4A;heB0QP!P;*jt*0OZi{$6gzSY#+81puNB+E}fe8#aGzl_m<6jenZTwZ~3)xV_ zXk&K1_l~xcPELW>!h6YRsbab$xi%&8#1t7s1{;S4X_ahyN&bDfm6+j1nx$pn^6v0mc? zNKlGzz3@8qdi^@SQ|dRX23k~k#!jAPQ*sy*J)-t8??W=)-l>;L0 z4&&fP0_FZ_WOpWvNA$Xkg%nFLz**pA_et0*yQG94qEl`7i-Q9KoBqArgm4Q`l>dGx zG?!n=F_6>o>sM2n*&6%6JvxGz__&|2<*~icBP1nNYZgEMVKXk|z4!1T{n2E&rDln~ z-Au0I`+HSkM8M%5Bu5dkX1RB71q?@oOC5Z`OR!|F(QSM3{O2#MOR-C=sM~tA9_i1w zsiRuS?r2!#a{JQ&nL!LS`oSbLcgO;49!Oe#zTod|uAaF2(yH=t=((1f-v zdh(xq{{$S~hUK-pD|CL=?Zd|onqjS6Zt+CgJrQANB&C|HnYlt<2GYu9<&_f6?|aJl zYVmhEy2j*{RpeQL_iJTkfoEHr>iFc&x{j{<>O{I%7XfwHWu_0S-1A>%j2~qg8(aC^RvuqwA*Q++LB6mm z?b*M`t-EOJXvJ;*x#W$c%!?OhYGztmNx?Z-IAAY=wf-?zjAnH<=Ws192e|Se1;1!H zMTs6PdgV?=M%Eeee&1ZUL>Ko6Jv$gaaoF2XMFlwpS=lR{QJj=MDMO4yJZ)4OdVBc? zh92a}zBZY>gv<+UopZH-FSmZQs&r=gsQT!lL1^9bmtfFG(ot>rMij*b9fc!lm*ub6ZK zPlZZeAdx)r+sW6Wyg{rl*^9*(B&sCwwhDS|Q7WWz;x=8QOD$%g2_6`t7y}tsG+nKt-of(f-+uIdT^?S z$7OviV